build: bundle playback and YouTube dependencies
Build pinned LGPL playback libraries and package standalone YouTube helpers inside the signed native app. Keep the CLI inside the app and relocate private library links for installation without runtime Homebrew dependencies. Record adaptive fixture completion in its EXIT trap: cancellation can exit with status 143 before the TERM trap writes its marker, while native pause and quality preservation remain correct.
This commit is contained in:
@@ -4,17 +4,20 @@ A personal native macOS player whose playback code is small enough to own and mo
|
||||
|
||||
## Build and run
|
||||
|
||||
Requires macOS 15+, Xcode's command-line tools with Swift 6, and Homebrew FFmpeg/pkg-config:
|
||||
Requires macOS 15+, Xcode's command-line tools with Swift 6, Python 3.9+, and the build tools `pkg-config`, Meson and Ninja. Native Intel builds also require NASM:
|
||||
|
||||
The first build fetches pinned [TOMLDecoder 0.4.5](https://github.com/dduan/TOMLDecoder/tree/0.4.5) through SwiftPM. It is statically linked into the CLI/player; running the installed app needs no separate TOML runtime. Its MIT notice is included in the app's Resources.
|
||||
|
||||
```sh
|
||||
brew install ffmpeg pkg-config
|
||||
brew install pkg-config meson ninja
|
||||
# Intel Macs: brew install nasm
|
||||
make
|
||||
make run
|
||||
```
|
||||
|
||||
The developer app is `.build/terebi.app`. It links the installed FFmpeg libraries; it is not a standalone distributable bundle. After upgrading FFmpeg, rebuild with `make -B` or reinstall with `make -B install`.
|
||||
The developer app is `.build/terebi.app`; release builds produce `.build/release/terebi.app`. Both are self-contained for macOS 15+ on the build machine's CPU architecture. Copy the whole app to another compatible Mac; no Homebrew, FFmpeg installation, Python installation, yt-dlp installation, or shell PATH setup is needed to run it. Build natively on Apple Silicon or Intel; cross-architecture and Rosetta builds are rejected, and these are not universal or cross-platform binaries.
|
||||
|
||||
`Dependencies.lock.json` pins SHA256-verified FFmpeg 9.0.1, dav1d 1.5.4, liblzma 5.8.4, standalone yt-dlp 2026.08.19 and Deno 2.9.6 inputs. The first build downloads them into ignored `.build/dependencies/<architecture>`; later builds reuse completed preparations and verified downloads. FFmpeg is built as shared LGPL playback libraries, without programs, encoders, muxers, filters or unrelated autodetected packages; dav1d supplies software AV1 and liblzma retains compressed-media support. Native HTTPS uses macOS SecureTransport, not an external OpenSSL installation. The repo carries recipes and checksums, not generated runtime binaries.
|
||||
|
||||
Open a local file with **⌘O**, drop it onto the window, or use Finder's **Open With**. Successfully opened files start playback automatically, including when replacing the current file. **⇧⌘W** closes the file; closing the window quits and joins the worker.
|
||||
|
||||
@@ -28,11 +31,13 @@ export PATH="$HOME/.local/bin:$PATH"
|
||||
terebi --help
|
||||
```
|
||||
|
||||
Installation puts the CLI at `~/.local/lib/terebi/terebi` and the app beside it at `~/.local/lib/terebi/terebi.app`; `~/.local/bin/terebi` is a symlink to the CLI. A custom `PREFIX` uses the same `lib/terebi` and `bin` layout. The default needs no sudo. Release artifacts are separate from developer builds, and reinstall replaces the installed app bundle without touching unrelated prefix contents. Close a running player and relaunch after installing an update.
|
||||
Installation copies the complete app to `~/.local/lib/terebi/terebi.app`; `~/.local/bin/terebi` links to its `Contents/MacOS/terebi-cli`. A custom `PREFIX` uses the same `lib/terebi` and `bin` layout. The default needs no sudo. Release artifacts are separate from developer builds, and reinstall replaces only the installed app and obsolete standalone CLI from the previous layout. Close a running player and relaunch after installing an update.
|
||||
|
||||
FFmpeg is still external. Builds and installs—including cached release installs—check `pkg-config` and all five FFmpeg development packages before compiling or copying. Missing dependencies report `brew install ffmpeg pkg-config`.
|
||||
The app contains its CLI, bootstrap, player library, all seven non-system playback dylibs, standalone yt-dlp with Python/EJS, and Deno. Library references are rewritten relative to the app before nested code and the app are signed; signing is ad-hoc by default for personal use, not notarization. Moving the app does not require rebuilding; update any external CLI symlink to its new location. Build/install still require the development tools listed above.
|
||||
|
||||
At runtime, both the CLI and the app's lightweight bootstrap check the exact FFmpeg dylibs linked by the player, including their transitive dependencies. Missing, incompatible, or broken libraries report `brew install ffmpeg` and explain that library-version changes require rebuilding/reinstalling terebi. The CLI exits **69**; Finder launches show a native error dialog instead of failing in dyld before the UI starts. Missing or invalid dependency metadata also reports rebuild/reinstall. CLI help remains available without FFmpeg.
|
||||
Both the CLI and the lightweight bootstrap preflight the five bundled FFmpeg libraries and their transitive dependencies. Missing, incompatible or broken libraries identify the damaged bundle and request rebuilding/reinstalling the complete app—not installing Homebrew packages. The CLI exits **69**; Finder launches show a native error dialog instead of failing in dyld before the UI starts. Missing or invalid metadata also requests rebuild/reinstall. CLI help and default-config output remain available without the playback libraries.
|
||||
|
||||
`Contents/Resources/licenses` includes upstream notices, pinned provenance, native build flags and the dependency recipe; `sources/native` contains the exact unmodified FFmpeg/dav1d/liblzma archives. Shared libraries can be replaced with ABI-compatible modified versions; re-sign modified code and the app afterward. The standalone yt-dlp combined executable is **GPLv3+**, not solely the source project's Unlicense. Helper source archives and upstream Deno/V8 notices provide provenance, not a complete public-redistribution compliance or corresponding-source offer. This recipe is for personal use; public binary distribution requires auditing all bundled helper dependencies and applicable source obligations.
|
||||
|
||||
The declared app executable stays running after preflight and loads `Contents/Frameworks/libterebi-player.dylib` in-process. It does not `exec` another executable: LaunchServices retains the real app PID and identity, so third-party Now Playing source lists such as Vorssaint can discover it. Missing/unloadable player libraries or an invalid entry point also show the native rebuild/reinstall error dialog.
|
||||
|
||||
@@ -55,7 +60,7 @@ The declared app executable stays running after preflight and loads `Contents/Fr
|
||||
|
||||
One local file (including a local `file://` URL) or YouTube video URL is accepted. Paths are relative to the shell's current directory; quote spaces and URL query strings. `--` treats the following argument as a filename. With no arguments, the CLI opens or activates the player without replacing its media. Put **`--new-instance` before the media arguments** to launch a separate player process/window instead: it works without media, with a local file or positional YouTube URL, and with `--url` or `--`. Existing playback stays untouched. To use `terebi` by name, add the build directory to your shell's PATH: `export PATH="$PWD/.build:$PATH"`.
|
||||
|
||||
The launcher finds `terebi.app` beside its own executable, following symlinks. By default, macOS application/URL delivery reuses a running player and its existing window. With `--new-instance`, one fresh application launch carries any media URL as a startup argument, so it reaches that process without a second app launch or changing another player's media. Keep the executable and app bundle together; this is not a standalone distribution. Media opens use the same autoplay and saved-progress behavior as the UI. Instances share saved progress, but configuration and control overrides belong to each session. Quit all running players after installing this update so every process uses the updated configuration/startup handling.
|
||||
The launcher locates its enclosing `terebi.app` through its actual executable path, following symlinks. By default, macOS application/URL delivery reuses a running player and its existing window. With `--new-instance`, one fresh application launch carries any media URL as a startup argument, so it reaches that process without a second app launch or changing another player's media. The CLI travels inside the app; `.build/terebi` is a convenience symlink. Media opens use the same autoplay and saved-progress behavior as the UI. Instances share saved progress, but configuration and control overrides belong to each session. Quit running players after installing an update.
|
||||
|
||||
Help and `--default-config` exit with status **0** without opening the GUI, reading configuration, or checking/loading FFmpeg. The latter prints complete built-in TOML to stdout, including every named key action and explicit `[]` entries for unbound actions; it uses the player's authoritative defaults rather than your current settings. It accepts no media argument and never creates or writes a config file. Invalid arguments, unsupported URLs, missing media files, and directories report to stderr and exit **64**; unavailable FFmpeg libraries and application-launch failures exit **69**; unreadable or invalid configuration exits **78** with the file path and diagnostic. A successful launch exits **0** immediately after macOS accepts the request, not when playback finishes. Later extraction/decoding failures appear in the player window; this is a GUI launcher, not a headless player or remote playback-control interface.
|
||||
|
||||
@@ -121,13 +126,9 @@ Put **`--no-config` before the media arguments** to skip reading the file entire
|
||||
|
||||
## YouTube
|
||||
|
||||
YouTube playback optionally requires [yt-dlp](https://github.com/yt-dlp/yt-dlp), its EJS components, and a supported JavaScript runtime. Homebrew provides the extractor and its components; Deno is recommended:
|
||||
The app bundles [yt-dlp](https://github.com/yt-dlp/yt-dlp), its EJS components, Python/default dependencies, and Deno. YouTube playback does not search Homebrew or fall back to external PATH executables; a missing bundled helper reports a damaged installation.
|
||||
|
||||
```sh
|
||||
brew install yt-dlp deno
|
||||
```
|
||||
|
||||
Use **⇧⌘O → Open YouTube URL…**, the playback-actions menu, or drop a YouTube video link onto the window. Watch, short-link, Shorts, live, and embed URLs identify one video; playlists and channels are not opened. Resolution is cancellable by closing or replacing the media. Finder launches also search the usual Homebrew executable locations.
|
||||
Use **⇧⌘O → Open YouTube URL…**, the playback-actions menu, or drop a YouTube video link onto the window. Watch, short-link, Shorts, live, and embed URLs identify one video; playlists and channels are not opened. Resolution is cancellable by closing or replacing the media.
|
||||
|
||||
yt-dlp defaults to the highest available SDR resolution at or below **1080p**, falling down to a lower resolution when 1080p is unavailable, with the best available audio. FFmpeg plays the selected HTTP(S) streams or native HLS manifests directly, including separate video/audio inputs or a combined stream: no embedded browser, temporary full-video download, or muxing step. Playback, coordinated seeking, frame stepping, volume, and same-window compact mode use the existing native player. **Buffering…** freezes the playback clock without disabling pause, close, or replacement; live/non-seekable inputs disable seeking and frame stepping, and live sources show **LIVE**.
|
||||
|
||||
@@ -141,7 +142,7 @@ While playing YouTube, choose **gear → Quality** in either normal or compact m
|
||||
|
||||
**gear → Quality → Automatic Quality (up to 1080p)** is **disabled by default**, configurable with `[playback].automatic_quality`, and its menu toggle is session-only. Choosing any manual/default resolution, even the current one, disables it for that session. Auto moves down one available rung after reserve stays below four seconds for six seconds or playback stalls for three seconds. It moves up one rung after at least twenty seconds of reserve remains healthy for sixty seconds, never above 1080p; live/unknown reserve uses 120 seconds of stable playback instead. Switches have a thirty-second cooldown. Paused/opening/seeking media and the final fifteen seconds of a recorded video do not trigger changes. Failed automatic extraction leaves the previous playback running and backs off for 120 seconds. The old stream continues while automatic URLs resolve; applying a new quality still briefly reopens the stream, preserving the latest seekable position and play state rather than providing a seamless segment-level switch.
|
||||
|
||||
The app ignores yt-dlp configuration and plugins, does not read browser cookies, and does not update or fetch extractor components automatically. Private, age-restricted, region-blocked, or sign-in-required videos can fail. If YouTube extraction changes, run `brew upgrade yt-dlp deno`; local playback needs neither dependency.
|
||||
The app ignores yt-dlp configuration and plugins, does not read browser cookies, and does not update or fetch extractor components automatically. Private, age-restricted, region-blocked, or sign-in-required videos can fail. If YouTube extraction changes, update the pinned helper versions/checksums and rebuild/reinstall terebi; upgrading an external Homebrew yt-dlp does not change the bundled extractor.
|
||||
|
||||
## Resume playback
|
||||
|
||||
@@ -219,14 +220,16 @@ Playback terms are defined in [CONTEXT.md](CONTEXT.md).
|
||||
- `Sources/App/PlaybackProgress.swift`: local per-video position persistence and canonical file identities, using injectable UserDefaults. The player model checkpoints authoritative positions, supplies saved YouTube positions as backend open intent, and queues local restoration against the ready source.
|
||||
- `Sources/App/SystemPlaybackControls.swift`: native remote-command registration, main-thread dispatch, Now Playing publication, seekability gating, and target cleanup.
|
||||
- `Sources/App/CompactWindow.swift`: same-window compact geometry, saved normal window state, asynchronous fullscreen transitions and failure recovery, all-Spaces/floating behavior, and magnetic edge snapping with a session-only toggle.
|
||||
- `Sources/App/YouTubeResolver.swift`: YouTube URL and metadata validation, external yt-dlp/runtime discovery, capped-default or explicit-resolution SDR selection, playable resolution discovery and headers, bounded nonblocking process output, sanitized extraction errors, and request-local cancellation.
|
||||
- `Sources/App/YouTubeResolver.swift`: YouTube URL and metadata validation, strict bundled yt-dlp/Deno discovery, capped-default or explicit-resolution SDR selection, playable resolution discovery and headers, bounded nonblocking process output, sanitized extraction errors, and request-local cancellation.
|
||||
- `Sources/CLI/PlayerCommand.swift` and `Sources/CLI/TerebiCLI.swift`: command-line validation/help and native application discovery/launching. Fresh instances receive media/config/reporting startup arguments in one launch; default reuse delivers the selected URL to an existing app. The app owns Apple events to prevent SwiftUI's default external-document routing from replacing its singleton window.
|
||||
- `Sources/Launcher/FFmpegRuntime.swift` and `Sources/App/Bootstrap.swift`: shared native dependency preflight and the app's FFmpeg-independent entrypoint. The bootstrap loads the player library and calls its exported `terebi_app_main` entry point without replacing the executable, consuming initial launch events, or losing macOS app identity. `Contents/Resources/ffmpeg-libraries.txt` records the library's actual linked FFmpeg ABI paths.
|
||||
- `Sources/Launcher/FFmpegRuntime.swift` and `Sources/App/Bootstrap.swift`: shared bundled-library preflight and the app's FFmpeg-independent entrypoint. The bootstrap loads the player library and calls its exported `terebi_app_main` entry point without replacing the executable, consuming initial launch events, or losing macOS app identity. `Contents/Resources/ffmpeg-libraries.txt` records the five versioned library filenames relative to `Contents/Frameworks`.
|
||||
- `Sources/Configuration/PlayerConfiguration.swift`: built-in settings, nested behavior/UI/key schema, XDG path resolution, read-only strict TOML loading, and startup bypass. `PlayerAction.swift` defines explicit supported actions/inverse pairs; `KeyBindings.swift` parses chords and validates/indexes the complete binding map. `Package.swift`/`Package.resolved` pin the statically linked decoder.
|
||||
- `Makefile`: compiler/linker commands, SwiftPM configuration-library build, dependency preflight, generated runtime manifest, developer/release bundles, installation, and checks. No generated Xcode project.
|
||||
- `Makefile`, `Scripts/dependencies.py`, `Scripts/bundle.py` and `Dependencies.lock.json`: pinned/checksummed native library and helper preparation, compiler/linker commands, SwiftPM configuration-library build, private library relocation/signing, developer/release bundles, installation, and checks. No generated Xcode project.
|
||||
|
||||
## Runnable check
|
||||
|
||||
The full suite generates media and reference PCM using the development-only `ffmpeg`/`ffprobe` command-line tools (`brew install ffmpeg`). They are not required to build, install, or run the app.
|
||||
|
||||
```sh
|
||||
make check
|
||||
```
|
||||
@@ -239,7 +242,7 @@ Generates short fixtures and exercises the real playback interface and native ou
|
||||
|
||||
`make check-config` checks isolated XDG/fallback paths, missing-file defaults without creation, partial/quoted/commented nested TOML, invalid syntax/types/keys/UTF-8, shortcut replacement/unbinding, chord normalization, inverse-pair sharing and unrelated conflicts, invalid action/quality names, duplicate keys, read errors, explicit path delivery, `--no-config` bypass, and unchanged file bytes/session reset. It needs neither FFmpeg nor a graphical session and never reads the user's configuration.
|
||||
|
||||
`make check-youtube` checks URL/metadata/header trust boundaries, resolution choices and the default cap, and real subprocess output, bounded pipe draining, cancellation, and request isolation using isolated extractor fixtures. If yt-dlp is installed, it also runs the actual selector against offline metadata to verify 1080p priority over extractor preferences, combined-stream selection, lower-resolution fallback, and explicit 4K. It requires neither Deno nor external network access; the actual-selector portion is skipped when yt-dlp is absent.
|
||||
`make check-youtube` checks URL/metadata/header trust boundaries, resolution choices and the default cap, real subprocess output, bounded pipe draining, cancellation, request isolation, and damaged/moved helper discovery using isolated fixtures. It also runs the actual bundled extractor against offline metadata to verify 1080p priority over extractor preferences, combined-stream selection, lower-resolution fallback, and explicit 4K; no external network access is required.
|
||||
|
||||
`make check-network` uses a range-capable loopback HTTP server and actual native playback. It checks combined/separate streams, identical stream indices across inputs, selected headers, coordinated seeks, exact VFR/B-frame stepping, asymmetric EOF and audio-tail seeks, recorded native HLS seeks, live/non-range seek rejection, buffering before the entire file arrives, permanently stalled primary/secondary reads, cancellation/replacement/destruction, HTTP failures, and recovery. A CDN fixture withholds oversized/open-ended responses: combined and split playback must build a minute of reserve using bounded continuation, retain required headers, seek successfully, and consume the final partial range to EOF. The combined case also verifies one persistent connection across sequential responses; split inputs exercise servers that close connections. Retained-range checks cover a minute of read-ahead, a thirty-second rewind window, eviction, cached bidirectional seeks without new HTTP requests while a future response stays gated, cache-miss refill, FFprobe-derived supplied-packet timestamp bounds/B-frame dependencies, required-track constraints, gate-release refill, shorter-track EOF tails, and clearing on close/error/local replacement. It is included in `make check`.
|
||||
|
||||
@@ -253,7 +256,9 @@ Adaptive transition checks also gate a real extraction, cancel it by pausing, an
|
||||
|
||||
`make check-cli` checks relative/escaped paths, option-like filenames, YouTube identity, default reuse versus explicit `--new-instance`, `--no-config` alone and combined in either order, independent empty/local/YouTube launches, help without launch, argument cardinality, unknown/duplicate options, missing files/directories, non-local file URLs, and unsupported stream URLs. It is included in `make check`.
|
||||
|
||||
`make check-runtime` exercises missing/invalid dependency metadata, unavailable ABI libraries, existing but unloadable dylibs, omitted library families, and successful native library loading using isolated app metadata. It is included in `make check`; no installed FFmpeg files are modified.
|
||||
`make check-runtime` exercises missing/invalid dependency metadata, unsafe paths/versions, unavailable and unloadable dylibs, omitted library families, and successful native loading from an isolated relocated copy of the bundled Frameworks. It is included in `make check`; no installed Homebrew files are modified.
|
||||
|
||||
`make check-bundle` copies the complete app to a path with spaces and exercises its symlinked CLI, native media reading, software AV1 availability, actual standalone extractor/Python/EJS and Deno with Homebrew, original app and dependency-cache access denied by the macOS sandbox. Removing a transitive bundled library must block launch with exit 69 without breaking help output. `Scripts.dependencies.self_check()` additionally checks archive traversal/symlink rejection with isolated files.
|
||||
|
||||
`make check-media` exercises actual playback through the app model, idempotent play/pause, immediate and rapid opposite playback intents, held-key suppression, paused seeking and start-boundary clamping, non-seekable state transitions, immediate hiding of a stale buffered snapshot on accepted close, and Now Playing cleanup on close/error/shutdown. It also checks default-off/configured settings, session-only overrides, and retained-range independence from the display preference. It is included in `make check` and uses isolated playback history with muted audio. Native MediaPlayer command dispatch was also smoke-checked; physical media-key routing and macOS's choice among competing media apps still need a keyboard check in the running app.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user