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
18 changes: 16 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,21 @@ match the tag exactly.

## Unreleased

- **Desktop and web controls fit their content.** Native Browse uses the global
search field; local library forms, playback menus and torrent file dialogs
keep their actions visible. Web artwork has bounded dimensions and useful
fallbacks, and compact search controls stay readable.
- **macOS playback controls reclaim title space.** The native title bar carries
the media name; bottom controls focus on transport and playback options.
- **Idle macOS windows close cleanly from the terminal.** A bounded event pump
lets Ctrl+C and process termination reach normal cleanup.
- **Playback stays in Opal.** External VLC launch controls and their launch
service have been removed.
- **Small text is readable in every native theme.** Captions and secondary
labels retain contrast on raised controls and cards.
- **CJK media titles render correctly.** Search and playback titles select a
bundled CJK face when needed, preserving the regular UI typography.

- **Audio tracks finish and advance correctly.** Files no longer repeat forever
by default. Audiobooks support whole-book resume across files and automatic
track advancement, including headless playback.
Expand Down Expand Up @@ -115,8 +130,7 @@ match the tag exactly.
cancels pending reads and clears its last frame before removing downloaded
files; the empty player offers a local file picker or universal search.
- **Windows playback controls fit scaled displays.** The native titlebar
hitboxes follow DPI scaling, compact navigation stays accessible, and an
optional VLC handoff is available under Playback settings.
hitboxes follow DPI scaling and compact navigation stays accessible.
- **Source installation explains progress and failures.** Its current stage,
final status, redacted logs, and recovery steps are visible in the catalog.
- **Cinemeta poster artwork loads from full image URLs.** Movies & TV no
Expand Down
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,8 +209,7 @@ Hover toolbar icons for labels, or scroll the toolbar in narrow windows.
Click a result or **Play** to watch; **+** queues a torrent and **Details** opens title info.
**×** on Now Playing stops playback. Find controls in **More → Playback options**.

Install VLC to use **Settings → Playback → Open in VLC**. Source installation
errors include recovery steps in redacted **Logs**.
Source installation errors include recovery steps in redacted **Logs**.

For temporary torrents, enable **Settings → Network → Stream torrents in memory**.
Choose 128, 256 (default), or 512 MiB per new torrent. Seeking may redownload pieces;
Expand Down
90 changes: 90 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,46 @@ pub fn build(b: *std.Build) void {
msys_path_prefix, b.graph.environ_map.get("PATH") orelse "",
}));
b.step("test-browse", "Test production catalog parsing and asynchronous publication").dependOn(&run_browse_tests.step);
const gallery_tests = b.addTest(.{ .root_module = exe.root_module, .filters = &.{"Native Search gallery offline SDL pixel capture"} });
const run_gallery_tests = b.addRunArtifact(gallery_tests);
if (is_windows) run_gallery_tests.setEnvironmentVariable("PATH", b.fmt("{s};{s}", .{ msys_path_prefix, b.graph.environ_map.get("PATH") orelse "" }));
b.step("test-search-gallery", "Render the native search gallery in an isolated hidden SDL window").dependOn(&run_gallery_tests.step);
const media_tests = b.addTest(.{ .root_module = exe.root_module, .filters = &.{"Native media offline SDL fixture"} });
const run_media_tests = b.addRunArtifact(media_tests);
if (is_windows) run_media_tests.setEnvironmentVariable("PATH", b.fmt("{s};{s}", .{ msys_path_prefix, b.graph.environ_map.get("PATH") orelse "" }));
b.step("test-native-media", "Render native playback controls in an isolated hidden SDL window").dependOn(&run_media_tests.step);
const shell_tests = b.addTest(.{ .root_module = exe.root_module, .filters = &.{"Native shell offline route pixel capture"} });
const run_shell_tests = b.addRunArtifact(shell_tests);
if (is_windows) run_shell_tests.setEnvironmentVariable("PATH", b.fmt("{s};{s}", .{ msys_path_prefix, b.graph.environ_map.get("PATH") orelse "" }));
b.step("test-native-shell", "Capture native shell routes with isolated offline fixtures").dependOn(&run_shell_tests.step);
const modal_tests = b.addTest(.{ .root_module = exe.root_module, .filters = &.{"Native global modals offline SDL pixel capture"} });
const run_modal_tests = b.addRunArtifact(modal_tests);
if (is_windows) run_modal_tests.setEnvironmentVariable("PATH", b.fmt("{s};{s}", .{ msys_path_prefix, b.graph.environ_map.get("PATH") orelse "" }));
b.step("test-native-modals", "Capture native onboarding and dialogs in an isolated hidden SDL window").dependOn(&run_modal_tests.step);
const activity_tests = b.addTest(.{ .root_module = exe.root_module, .filters = &.{"Native activity offline SDL pixel capture"} });
const run_activity_tests = b.addRunArtifact(activity_tests);
if (is_windows) run_activity_tests.setEnvironmentVariable("PATH", b.fmt("{s};{s}", .{ msys_path_prefix, b.graph.environ_map.get("PATH") orelse "" }));
b.step("test-native-activity", "Capture native queue and transfers with isolated offline fixtures").dependOn(&run_activity_tests.step);
// Fixture rendering may synchronously inspect plugin/profile directories.
// Isolate the child process even when callers run these targets directly.
const native_profile_root = b.cache_root.join(b.allocator, &.{"native-fixture-profiles"}) catch @panic("native fixture profile path");
const native_capture_runs = [_]struct { run: *std.Build.Step.Run, name: []const u8 }{
.{ .run = run_gallery_tests, .name = "gallery" },
.{ .run = run_media_tests, .name = "media" },
.{ .run = run_shell_tests, .name = "shell" },
.{ .run = run_modal_tests, .name = "modals" },
.{ .run = run_activity_tests, .name = "activity" },
};
for (native_capture_runs) |fixture| {
const profile = std.fs.path.resolve(b.allocator, &.{ b.build_root.path orelse ".", native_profile_root, fixture.name }) catch @panic("native fixture profile path");
fixture.run.setEnvironmentVariable("HOME", profile);
fixture.run.setEnvironmentVariable("USERPROFILE", profile);
fixture.run.setEnvironmentVariable("XDG_CONFIG_HOME", b.fmt("{s}/config", .{profile}));
fixture.run.setEnvironmentVariable("XDG_CACHE_HOME", b.fmt("{s}/cache", .{profile}));
fixture.run.setEnvironmentVariable("XDG_DATA_HOME", b.fmt("{s}/data", .{profile}));
fixture.run.setEnvironmentVariable("APPDATA", b.fmt("{s}/config", .{profile}));
fixture.run.setEnvironmentVariable("LOCALAPPDATA", b.fmt("{s}/cache", .{profile}));
}

// DPI-bypass sidecar (debpalash/zig-bypassdpi): a cross-platform userspace
// proxy that fragments the TLS ClientHello so ISP DPI can't read the SNI.
Expand Down Expand Up @@ -834,6 +874,31 @@ pub fn build(b: *std.Build) void {
// throttle, the volume ramp, the transport state machine, and the
// width-based collapse order. All of it is wired into footer.zig — a test
// there asserts no export goes unreachable.
const test_search_reading_pure = b.addTest(.{ .root_module = b.createModule(.{
.root_source_file = b.path("src/services/search_reading_pure.zig"),
.target = target,
.optimize = optimize,
}) });
test_step.dependOn(&b.addRunArtifact(test_search_reading_pure).step);
const test_search_content_pure = b.addTest(.{ .root_module = b.createModule(.{
.root_source_file = b.path("src/services/search_content_pure.zig"),
.target = target,
.optimize = optimize,
}) });
test_step.dependOn(&b.addRunArtifact(test_search_content_pure).step);
const test_search_preview_pure = b.addTest(.{ .root_module = b.createModule(.{
.root_source_file = b.path("src/services/search_preview_pure.zig"),
.target = target,
.optimize = optimize,
}) });
test_step.dependOn(&b.addRunArtifact(test_search_preview_pure).step);
const test_search_gallery_pure = b.addTest(.{ .root_module = b.createModule(.{
.root_source_file = b.path("src/ui/search_gallery_pure.zig"),
.target = target,
.optimize = optimize,
}) });
test_step.dependOn(&b.addRunArtifact(test_search_gallery_pure).step);

const test_search_view_pure = b.addTest(.{ .root_module = b.createModule(.{
.root_source_file = b.path("src/services/search_view_pure.zig"),
.target = target,
Expand Down Expand Up @@ -1400,6 +1465,15 @@ pub fn build(b: *std.Build) void {
});
test_step.dependOn(&b.addRunArtifact(test_scale_pure).step);

const test_event_loop_pure = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("src/core/event_loop_pure.zig"),
.target = target,
.optimize = optimize,
}),
});
test_step.dependOn(&b.addRunArtifact(test_event_loop_pure).step);

// Windows title-bar control hitboxes must follow the scaled player
// overlay without pushing buttons outside a laptop-sized viewport.
const test_titlebar_geometry = b.addTest(.{
Expand Down Expand Up @@ -1642,6 +1716,22 @@ pub fn build(b: *std.Build) void {
}),
});
test_step.dependOn(&b.addRunArtifact(test_tv_layout).step);
const test_local_library_layout = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("src/ui/local_library_layout_pure.zig"),
.target = target,
.optimize = optimize,
}),
});
test_step.dependOn(&b.addRunArtifact(test_local_library_layout).step);
const test_browse_layout = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("src/ui/browse_layout_pure.zig"),
.target = target,
.optimize = optimize,
}),
});
test_step.dependOn(&b.addRunArtifact(test_browse_layout).step);
const test_episode_art = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("src/services/episode_art_pure.zig"),
Expand Down
82 changes: 82 additions & 0 deletions docs/browse-sources.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,3 +226,85 @@ audio advancing across two files, and whole-book resume into the second file.
Radio actions now use stable station UUIDs, and comics actions copy the selected
reader identity. These fixtures establish those contracts; they do not establish
external provider uptime or configured personal-server availability.

## Universal content search coverage (2026-10-03)

Universal search projects content works separately from their source options.
Positive typed catalog identities and matching IMDb identities retain their media
kind. Movie releases attach only with an exact normalized title and matching
positive year. Explicit `Sxx` / `SxxExx` releases can attach to a uniquely matching
TV title without a year; known same-title remakes remain ambiguous even when a
facet hides one. Ambiguous releases remain independently actionable.

The shared wave retains at most 192 rows. At capacity, an underrepresented
category can reclaim rows from categories containing more than eight rows;
within represented categories, relevance selects replacements. This protects
late catalog arrivals from torrent saturation without promising every provider
result will fit. Searches fetch bounded first pages; universal pagination across
all providers is not implemented.

| Content | Independent universal search providers | Conditions and limits |
| --- | --- | --- |
| Movies and shows | TMDB or keyless Cinemeta; installed Nova2 torrent engines, YTS, EZTV, configured Torznab; configured Stremio, local files, Jellyfin and Plex | Catalog search runs independently of playable-source toggles. Release quality and swarm health rank source options, rather than substituting for content identity. Personal services require configuration. |
| Anime | Jikan, with AniList fallback; installed AllAnime; anime torrent engines through the shared torrent search | Metadata search retains real title, cover, synopsis, year and rating. Metadata opens Anime discovery; it is not a verified playable stream. Up to twelve metadata rows are admitted per adapter. |
| Comics and manga | Keyless MangaDex; installed ReadAllComics | MangaDex retains its real cover and stable reader identity, and opens Comics discovery. The other Comics Browse adapters are not yet universal workers. |
| Books and novels | Wikisource, Internet Archive English DjVuTXT works, Gutenberg, public-access Open Library works, installed Royal Road and NovelFire; configured OPDS | Royal Road and NovelFire use the existing Browse listing parser with owned query/endpoint buffers and at most twelve works each. Challenge pages are parser failures, not successful empty listings. No Browse query or results are overwritten. Restricted Open Library lending items are excluded. |
| Visual novels | VNDB | Metadata discovery only; no game download or playback claim. |
| Audiobooks | Configured Audiobookshelf; Internet Archive audio | Audiobookshelf uses allowed libraries and real file metadata. Actual service availability depends on the configured server. |
| Music | Public JioSaavn; installed Audius; configured Subsonic; connected Jellyfin Audio and Plex tracks | Independent queries do not replace Music Browse state. A shared **16-result** budget is divided fairly among configured providers. Requests are bounded to four seconds and 512 KiB each. Private actions retain provider IDs and bind the searched server/library namespace; playback obtains fresh credentials. Private artwork URLs are withheld. |
| Podcasts | Apple Podcasts and gpodder; installed RSS feeds | Independent searches retain exact feed identities and share a **16-result** budget. At most four installed feeds are inspected per query, with two-second requests; additional installed feeds produce a partial-source status. The two public directories share the remaining budget, with four-second requests. Open a show in Podcasts and choose an episode to play. |
| Radio and live TV | Radio Browser; installed/configured IPTV | Each adapter contributes up to 16 results and retains actual station/channel stream identities. |
| Video and previews | YouTube search, Internet Archive video, NASA video, Wikimedia Commons video; explicit official TMDB trailer lookup | Preview lookup requires a current opaque result key and generation. TMDB previews require credentials and a verified official YouTube video; unavailable credentials or trailers remain explicit outcomes. Preview playback does not stand in for the full work. |
| Images and articles | Cover/backdrop metadata only; no standalone universal image or article adapter | NASA and Commons workers currently request video. Existing RSS records expose torrent magnets, rather than an article reader contract. |
| Installed executable plugins | Trusted installed plugin search workers | Coverage depends on each plugin's actual search contract and installation. An installed source configuration alone does not imply a universal worker exists. |

Reading cards carry covers only when supplied by the actual listing/provider;
authors and summaries are not invented for HTML listings that omit them. Other
installed novel Browse adapters (Madara, LightNovelWP, ReadWN and ReadNovelFull)
are not yet universal workers.

Search cache identity includes schema, source mask and an opaque fingerprint of
installed source configuration. Queries are hashed into bounded keys; endpoint
URLs and credentials are not emitted as cache-key text. A wave captures its
initial scope and skips cache writes when that scope changes while workers run.
Connected personal-library configuration outside the installed-source table is
not represented by that fingerprint. Account-bound Jellyfin, Plex, OPDS and
Audiobookshelf results, private music identities, installed executable plugin
results and unclassified Stremio session transports are therefore excluded from
the persisted search cache. They remain live search results and are fetched
afresh for each query. Public catalog/artwork and explicitly public Archive,
NASA, Commons, JioSaavn and Audius rows remain eligible. The cache policy version
invalidates earlier cached account rows. External provider uptime, personal
account access and full interactive playback are separate validation requirements.

### Visual search behavior and checks

The desktop omnibox submits a global query from every Browse page. All-content
results use artwork shelves; selecting a content category expands its results
into a wrapping grid. Cards reveal contextual details, saved-title controls,
and source comparisons. Unmatched releases remain separately actionable.
Saved titles reopen through global search rather than an expired stream URL.

Official trailer playback starts only on request, muted. Desktop uses a separate
libmpv context; the web client uses a validated YouTube privacy-enhanced embed.
Changing the query, selection, route, or closing the preview stops it. Trailer
lookup requires a TMDB key. Audio samples are not fabricated when a provider
does not supply a verified sample.

Reproducible checks:

```sh
zig build test
node --test tests/test_web_lifecycle.mjs
zig build test-search-gallery
python3 tests/test_features.py --database /tmp/opal-feature-fixture.sqlite --results /tmp/opal-feature-results.json
```

The optional native gallery check renders a hidden SDL window with generated
offline artwork; `OPAL_GALLERY_ART_DIR` can supply local poster fixtures and
`OPAL_GALLERY_CAPTURE_DIR` can retain PNG captures. It exercises shelves, filtered
grids, and scrolling at 1360×1000 and 640×800. No provider request or playback
occurs. Web regressions cover grouping, stale actions, filters, saving, and
verified preview lifecycle. Isolated API cases in
`tests/test_content_maturity_live.py` additionally cover preview/cancel generation
validation and saved-title persistence.
Loading
Loading