Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,9 @@ Contributor references are in [CONTRIBUTING.md](CONTRIBUTING.md#code-and-referen

## Make Core your own

See [private playback](docs/private_playback.md) for per-video access controls,
local MinIO support and deployment adapter integration.

- **Your storage:** use a compatible hosted S3 provider or run your own storage.
MinIO is the bundled default, not a requirement. Other providers need matching
storage, upload, and public-media configuration; see the
Expand Down
55 changes: 53 additions & 2 deletions assets/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -254,10 +254,50 @@ const ComponentLoaderHook = {
}

await window.__maveComponentImports[src]
this.applyPlaybackSession()
} catch (error) {
console.error("Failed to load mave components bundle", error)
}
},

destroyed() {
clearTimeout(this.playbackRenewal)
this.playbackDestroyed = true
},

applyPlaybackSession() {
const key = `${this.el.dataset.playbackEmbed}:${this.el.dataset.playbackStatus}`
if (!this.el.dataset.playbackEmbed || this.playbackDestroyed) return

const apply = ({ session, embed }) => {
document.querySelectorAll("mave-player, mave-clip, mave-audio").forEach((element) => {
if (element.embed === embed) element.token = session?.token || ""
})
}
if (this.playbackKey === key && this.playbackData &&
(!this.playbackData.session || this.playbackData.session.expires_at * 1000 > Date.now() + 60000)) {
apply(this.playbackData)
return
}
if (this.playbackPending) return
this.playbackPending = true
this.pushEvent("playback_session", {}, (data) => {
this.playbackPending = false
if (this.playbackDestroyed) return
if (key !== `${this.el.dataset.playbackEmbed}:${this.el.dataset.playbackStatus}`) {
this.applyPlaybackSession()
return
}
this.playbackKey = key
this.playbackData = data
apply(data)
clearTimeout(this.playbackRenewal)
if (data.session) {
this.playbackRenewal = setTimeout(() => this.applyPlaybackSession(),
Math.max(1000, data.session.expires_at * 1000 - Date.now() - 60000))
}
})
},
}

const FolderDropHook = {
Expand Down Expand Up @@ -401,6 +441,14 @@ const ThumbnailPreviewHook = {

this.eventsBound = true

this.video.addEventListener("error", () => {
const preferredSrc = this.el.dataset.previewSrc
if (this.currentSource === preferredSrc && this.el.dataset.fallbackSrc !== preferredSrc) {
this.failedPreferredSrc = preferredSrc
this.loadVideoSource()
}
})

this.scrubArea.addEventListener("mousedown", (event) => {
this.scrubStart(event)
})
Expand Down Expand Up @@ -438,7 +486,10 @@ const ThumbnailPreviewHook = {

const preferredSrc = this.el.dataset.previewSrc || ""
const fallbackSrc = this.el.dataset.fallbackSrc || ""
const nextSrc = this.selectPlayableSource(preferredSrc, fallbackSrc)
const nextSrc = this.selectPlayableSource(
preferredSrc === this.failedPreferredSrc ? "" : preferredSrc,
fallbackSrc,
)

if (!nextSrc || this.currentSource === nextSrc) {
return
Expand All @@ -458,7 +509,7 @@ const ThumbnailPreviewHook = {
},

selectPlayableSource(preferredSrc, fallbackSrc) {
if (preferredSrc && !preferredSrc.endsWith(".m3u8")) {
if (preferredSrc && !new URL(preferredSrc, document.baseURI).pathname.endsWith(".m3u8")) {
return preferredSrc
}

Expand Down
49 changes: 49 additions & 0 deletions assets/test/thumbnail_preview.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import vm from 'node:vm'

const source = readFileSync(new URL('../js/app.js', import.meta.url), 'utf8')
const hookSource = source.slice(
source.indexOf('const ThumbnailPreviewHook = {'),
source.indexOf('const AutoOpenFilePickerHook = {'),
)
const hook = vm.runInNewContext(`${hookSource}; ThumbnailPreviewHook`, {
URL,
document: { baseURI: 'https://dashboard.example/' },
window: { addEventListener() {} },
})
const playlist = 'https://media.example/playlist.m3u8?token=example'
const original = 'https://media.example/original?signature=example'

test('signed playlists use the original when native HLS is unavailable', () => {
for (const supported of [false, true]) {
const context = { video: { canPlayType: () => supported ? 'maybe' : '' } }
assert.equal(hook.selectPlayableSource.call(context, playlist, original), supported ? playlist : original)
assert.equal(hook.selectPlayableSource.call(context, original, ''), original)
}
})

test('failed native HLS falls back once and stays on the original during updates', () => {
let onError
let loads = 0
const context = {
...hook,
el: { dataset: { previewSrc: playlist, fallbackSrc: original } },
video: {
canPlayType: () => 'maybe',
addEventListener(event, handler) { if (event === 'error') onError = handler },
load() { loads++ },
},
scrubArea: { addEventListener() {} },
videoContainer: {},
}
context.bindEvents()
context.loadVideoSource()
assert.equal(context.video.src, playlist)
onError()
assert.equal(context.video.src, original)
context.loadVideoSource()
onError()
assert.equal(loads, 2)
})
8 changes: 8 additions & 0 deletions config/dev.exs
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import Config

config :mave_core, :playback_adapter, MaveCore.Playback.Minio
config :mave_core, :bucket_cors_syncer, MaveCore.Playback.Minio
config :mave_core, :playback_storage_adapter, MaveCore.Playback.Minio

config :mave_core,
:playback_public_storage_endpoint,
System.get_env("MAVE_PLAYBACK_STORAGE_ENDPOINT", "http://localhost:9000")

# Configure your database
config :mave_core, MaveCore.Repo,
username: System.get_env("POSTGRES_USER", "postgres"),
Expand Down
15 changes: 15 additions & 0 deletions config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,21 @@ if external_uri do
|> String.trim_trailing("/")
end

# Optional per-space media origin; an unset value retains the API playback route.
if playback_origin = System.get_env("MAVE_PLAYBACK_ORIGIN") do
uri = playback_origin |> String.trim() |> URI.parse()

unless uri.scheme in ["http", "https"] and is_binary(uri.host) and uri.host != "" and
uri.path in [nil, "", "/"] and is_nil(uri.query) and is_nil(uri.fragment) and
is_nil(uri.userinfo) do
raise "MAVE_PLAYBACK_ORIGIN must be an HTTP(S) origin without a path or credentials"
end

config :mave_core,
:playback_origin,
URI.to_string(%{uri | host: String.downcase(uri.host), path: nil})
end

if public_cdn_host = System.get_env("MAVE_PUBLIC_CDN_HOST") do
trimmed = String.trim(public_cdn_host)

Expand Down
4 changes: 4 additions & 0 deletions deploy/self-hosted/compose.development.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,17 @@ x-development: &development
POSTGRES_USER: mave_core
CLICKHOUSE_HOST: clickhouse
MAVE_MEDIA_SANDBOX: required
MAVE_PLAYBACK_STORAGE_ENDPOINT: http://localhost:${MAVE_MINIO_API_PORT:-9000}
volumes:
- ../..:/app
- dev_deps:/app/deps
- dev_build:/app/_build
- dev_node_modules:/app/assets/node_modules

services:
minio:
ports:
- "127.0.0.1:${MAVE_MINIO_API_PORT:-9000}:9000"
welcome:
environment:
MAVE_DEVELOPMENT: "true"
Expand Down
63 changes: 63 additions & 0 deletions docs/private_playback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Private playback

Videos are public by default. A configured playback adapter enables the dashboard
access menu and the API `visibility` field (`"public"` or `"private"`). The response
reports the last visibility confirmed by storage synchronization. Changing access
runs in the background: `PUT /api/v1/videos/:id` returns `202 Accepted` while the
change is pending. Poll `GET /api/v1/videos/:id` until it returns `200 OK` with the
requested visibility; it also returns `202` while pending, including on retries.
Creation, ordinary updates and already confirmed visibility retain `200 OK`.

## Using tokens

Create or update a video with `visibility: "private"` using a write-capable API key.
Sign playback JWTs on your server with a read-only space API key, using the complete
key copied from the dashboard. `sub` scopes access to a space, collection or video.
Use `exp` to set an expiry in Unix seconds; `iat` and `aud` are not required.
Without `exp`, the JWT remains valid until its signing key is revoked.

```html
<mave-player embed="YOUR_EMBED_ID" token="YOUR_PLAYBACK_TOKEN"></mave-player>
```

For a custom player, use
`/api/v1/playback/media/{public_embed_id}/playlist.m3u8?token=YOUR_PLAYBACK_TOKEN`,
or the optional media hostname below. Other media files use the same base path.
Media routes also accept `Authorization: Bearer YOUR_PLAYBACK_TOKEN`.
Playlists, captions, storyboards and images use the same authorization. Child
playlists retain the JWT; media files use temporary signed storage URLs, valid
for at most 24 hours and no longer than the token's remaining lifetime.

Authorize viewers before issuing tokens. Keep API keys on the server and exclude
tokens and signed URLs from logs. Issued storage URLs remain usable until expiry.

Dashboard previews receive an automatic 24-hour token and refresh it before expiry.
Overview thumbnails also use signed URLs, with five minutes of private browser
caching. Private iframe snippets are omitted because they cannot pass a token.

## Storage adapters

Configure `:mave_core, :playback_adapter` with a module implementing
`MaveCore.Playback.Adapter`. It must protect existing and future objects, purge
cached public copies and verify access before reporting success. Domain/CORS
synchronization must preserve these policies. The optional `token_endpoint/0`
callback selects the Phoenix endpoint used to sign dashboard tokens.

Development includes a MinIO adapter without a CDN. The standalone development
Compose stack exposes its API on loopback port 9000 (`MAVE_MINIO_API_PORT` can
change this). For other local setups, set `MAVE_PLAYBACK_STORAGE_ENDPOINT` to the
browser-reachable MinIO endpoint; signatures must use that exact endpoint.

## Optional media hostname

Set `MAVE_PLAYBACK_ORIGIN=https://signed.example.com` to serve private media at:

```text
https://space-{space_hash}.signed.example.com/{embed_hash}/playlist.m3u8?token=JWT
```

Point wildcard DNS at the application and use TLS for `*.signed.example.com`.
This host serves only media GET/HEAD and CORS preflight requests. The API’s existing
`poster_image` field and component `cdn.playback_endpoint` settings use it automatically. Leaving it unset
uses `/api/v1/playback/media/{public_embed_id}/...`; those routes remain available
when a media hostname is configured. Do not enable shared caching on these routes.
47 changes: 32 additions & 15 deletions lib/mave_core/embeds.ex
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ defmodule MaveCore.Embeds do
alias MaveCore.LegacyShortUUID
alias MaveCore.Media.RenditionSizing
alias MaveCore.Media.Storage
alias MaveCore.Playback.Media, as: PlaybackMedia
alias MaveCore.Repo
alias MaveCore.Spaces
alias MaveCore.Spaces.Space
Expand Down Expand Up @@ -83,12 +84,14 @@ defmodule MaveCore.Embeds do
end

name = Map.get(attrs, :name) || Map.get(attrs, "name")
visibility = Map.get(attrs, :visibility, Map.get(attrs, "visibility", :public))

parent_folder_id = parent_folder_id(attrs)

Repo.transaction(fn ->
with {:ok, current_space} <- lock_active_space(space.id),
:ok <- UsageLimits.can_create_video_embed?(current_space),
:ok <- MaveCore.Playback.validate_visibility(current_space, visibility),
{:ok, asset} <-
%Asset{}
|> Asset.changeset(%{space_id: current_space.id, name: name})
Expand All @@ -104,6 +107,7 @@ defmodule MaveCore.Embeds do
archived: archived
})
|> Repo.insert(),
{:ok, embed} <- MaveCore.Playback.initialize_visibility(embed, visibility),
:ok <- maybe_attach_to_parent(embed, current_space.id, parent_folder_id) do
preload_embed(embed)
else
Expand Down Expand Up @@ -1435,9 +1439,13 @@ defmodule MaveCore.Embeds do
cache_buster
)
when is_binary(space_hash) and is_binary(embed_hash) do
space
|> snippet_object_url(embed, "thumbnail.jpg")
|> with_cache_buster(cache_buster)
url = snippet_object_url(space, embed, "thumbnail.jpg")

if is_binary(url) and MaveCore.Playback.protected?(embed) do
PlaybackMedia.dashboard_thumbnail_url(space, embed, cache_buster)
else
with_cache_buster(url, cache_buster)
end
end

defp thumbnail_url(%Space{}, %Embed{}, _cache_buster) do
Expand Down Expand Up @@ -1574,13 +1582,13 @@ defmodule MaveCore.Embeds do
"#{embed.hash}/original"
end

SettingsSerializer.storage_object_url(bucket, key)
preview_storage_url(space, embed, bucket, key)
end

defp hls_preview_src(
%Video{id: video_id},
%Space{} = space,
%Embed{},
%Embed{} = embed,
flow_run
) do
bucket = Storage.bucket_for_space(space.hash, space.region)
Expand All @@ -1599,15 +1607,15 @@ defmodule MaveCore.Embeds do
|> Repo.one()

if is_binary(stable_key) and stable_key != "" do
SettingsSerializer.storage_object_url(bucket, stable_key)
preview_storage_url(space, embed, bucket, stable_key)
else
processing_hls_preview_src(space, flow_run)
processing_hls_preview_src(space, embed, flow_run)
end
end

defp hls_preview_src(_video, _space, _embed, _flow_run), do: nil

defp processing_hls_preview_src(%Space{} = space, %Run{status: status} = run)
defp processing_hls_preview_src(%Space{} = space, %Embed{} = embed, %Run{status: status} = run)
when status in ["queued", "running"] do
bucket = Storage.bucket_for_space(space.hash, space.region)

Expand All @@ -1619,14 +1627,14 @@ defmodule MaveCore.Embeds do
output: %{"playlist_key" => key, "rendition" => %{"size" => "sd"}}
}
when is_binary(key) and key != "" ->
SettingsSerializer.storage_object_url(bucket, key)
preview_storage_url(space, embed, bucket, key)

_ ->
nil
end)
end

defp processing_hls_preview_src(_space, _flow_run), do: nil
defp processing_hls_preview_src(_space, _embed, _flow_run), do: nil

defp preview_frame_srcs(%Space{} = space, %Embed{} = embed, duration, version) do
frame_count = 6
Expand All @@ -1643,20 +1651,29 @@ defmodule MaveCore.Embeds do

%{
time: timestamp,
src: preview_thumbnail_src(bucket, embed.hash, version, index)
src: preview_thumbnail_src(space, embed, bucket, version, index)
}
end)
end

defp preview_thumbnail_src(bucket, embed_hash, version, index) do
defp preview_thumbnail_src(space, embed, bucket, version, index) do
key =
if version > 0 do
"#{embed_hash}/v#{version}/thumbnail_#{index}.jpg"
"#{embed.hash}/v#{version}/thumbnail_#{index}.jpg"
else
"#{embed_hash}/thumbnail_#{index}.jpg"
"#{embed.hash}/thumbnail_#{index}.jpg"
end

SettingsSerializer.storage_object_url(bucket, key)
preview_storage_url(space, embed, bucket, key)
end

defp preview_storage_url(space, embed, bucket, key) do
if MaveCore.Playback.protected?(embed) do
path = String.replace_prefix(key, embed.hash <> "/", "")
PlaybackMedia.dashboard_preview_url(space, embed, path)
else
SettingsSerializer.storage_object_url(bucket, key)
end
end

defp resolution_label(video, audio_only? \\ false)
Expand Down
Loading