From 6aa711e5f8dcf214e0c301d412c982f8fe8faf0a Mon Sep 17 00:00:00 2001 From: debpalash <4178343+debpalash@users.noreply.github.com> Date: Sat, 3 Oct 2026 04:25:05 +0530 Subject: [PATCH 1/2] feat(search): introduce visual content discovery and verified previews --- build.zig | 29 + docs/browse-sources.md | 82 +++ src/core/source_config.zig | 27 + src/core/source_config_pure.zig | 51 ++ src/headless.zig | 2 + src/main.zig | 6 + src/player/player.zig | 12 + src/services/browser.zig | 10 +- src/services/library_pure.zig | 5 + src/services/music_subsonic.zig | 162 ++++++ src/services/music_subsonic_pure.zig | 97 ++++ src/services/podcasts_pure.zig | 20 +- src/services/remote.zig | 101 +++- src/services/remote_limits_pure.zig | 3 + src/services/remote_static.zig | 2 +- src/services/resolver.zig | 572 +++++++++++++++----- src/services/search.zig | 435 +++++++++++++-- src/services/search_content_pure.zig | 452 ++++++++++++++++ src/services/search_gallery_native_test.zig | 146 +++++ src/services/search_preview.zig | 382 +++++++++++++ src/services/search_preview_pure.zig | 163 ++++++ src/services/search_reading_pure.zig | 80 +++ src/services/tmdb_api.zig | 34 +- src/ui/components.zig | 19 +- src/ui/home.zig | 5 + src/ui/search_gallery_pure.zig | 107 ++++ src/ui/shell.zig | 6 +- tests/features/test_library.py | 2 +- tests/features/test_search_v2.py | 44 +- tests/features/test_source_layer.py | 3 +- tests/features/test_universal_search.py | 19 +- tests/features/test_web_shell.py | 2 +- tests/features/test_web_ui.py | 2 +- tests/test_content_maturity_live.py | 24 + tests/test_web_lifecycle.mjs | 218 ++++++++ web/index.html | 4 +- web/js/core.js | 1 + web/js/search.js | 265 ++++++++- web/styles/app.css | 91 ++++ 39 files changed, 3470 insertions(+), 215 deletions(-) create mode 100644 src/services/search_content_pure.zig create mode 100644 src/services/search_gallery_native_test.zig create mode 100644 src/services/search_preview.zig create mode 100644 src/services/search_preview_pure.zig create mode 100644 src/services/search_reading_pure.zig create mode 100644 src/ui/search_gallery_pure.zig diff --git a/build.zig b/build.zig index eef93ac0..1c9a92a3 100644 --- a/build.zig +++ b/build.zig @@ -213,6 +213,10 @@ 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); // DPI-bypass sidecar (debpalash/zig-bypassdpi): a cross-platform userspace // proxy that fragments the TLS ClientHello so ISP DPI can't read the SNI. @@ -834,6 +838,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, diff --git a/docs/browse-sources.md b/docs/browse-sources.md index c4a0d4c5..1ff2f0a1 100644 --- a/docs/browse-sources.md +++ b/docs/browse-sources.md @@ -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. diff --git a/src/core/source_config.zig b/src/core/source_config.zig index fe4c9d8a..d561afa1 100644 --- a/src/core/source_config.zig +++ b/src/core/source_config.zig @@ -35,6 +35,33 @@ const Entry = struct { var entries: [MAX_ENTRIES]Entry = undefined; var entry_count: usize = 0; var mutex: @import("sync.zig").Mutex = .{}; +pub const EntryFieldSnapshot = pure.FieldSnapshot; + +/// Owned atomic multi-field source view. Null means absent or insufficient +/// output capacity; callers must never use partially copied credentials. +pub fn copyEntry(id: []const u8, out: []EntryFieldSnapshot) ?usize { + mutex.lock(); + defer mutex.unlock(); + return pure.copyEntryFields(entries[0..entry_count], id, out); +} + +pub fn copyValue(id: []const u8, field: []const u8, out: []u8) ?[]const u8 { + mutex.lock(); + defer mutex.unlock(); + for (entries[0..entry_count]) |entry| { + if (!std.mem.eql(u8, entry.id[0..entry.id_len], id) or !std.mem.eql(u8, entry.field[0..entry.field_len], field)) continue; + if (entry.val_len > out.len) return null; + @memcpy(out[0..entry.val_len], entry.val[0..entry.val_len]); + return out[0..entry.val_len]; + } + return null; +} + +pub fn fingerprint() u64 { + mutex.lock(); + defer mutex.unlock(); + return pure.fingerprintEntries(entries[0..entry_count]); +} const Protection = union(enum) { unchanged, diff --git a/src/core/source_config_pure.zig b/src/core/source_config_pure.zig index 69818142..4482e3fc 100644 --- a/src/core/source_config_pure.zig +++ b/src/core/source_config_pure.zig @@ -30,6 +30,57 @@ pub const MAX_ENTRIES = 512; pub const MAX_ID_LEN = 32; pub const MAX_FIELD_LEN = 24; pub const MAX_VAL_LEN = 512; +pub const FieldSnapshot = struct { + field: [MAX_FIELD_LEN]u8 = @splat(0), + field_len: usize = 0, + val: [MAX_VAL_LEN]u8 = @splat(0), + val_len: usize = 0, + pub fn value(self: *const FieldSnapshot, name: []const u8) ?[]const u8 { + return if (std.mem.eql(u8, name, self.field[0..self.field_len])) self.val[0..self.val_len] else null; + } +}; +/// Order independent configuration identity. Only used inside encrypted cache +/// keys; callers must not expose the digest or source values to remote clients. +pub fn fingerprintEntries(entries: anytype) u64 { + var result: u64 = 0; + for (entries) |entry| { + var hash = std.hash.Wyhash.init(0); + inline for (.{ entry.id[0..entry.id_len], entry.field[0..entry.field_len], entry.val[0..entry.val_len] }) |part| { + const length: u64 = @intCast(part.len); + hash.update(std.mem.asBytes(&length)); + hash.update(part); + } + result +%= hash.final(); + } + return result; +} +pub fn copyEntryFields(entries: anytype, id: []const u8, out: []FieldSnapshot) ?usize { + var count: usize = 0; + for (entries) |entry| { + if (!std.mem.eql(u8, id, entry.id[0..entry.id_len])) continue; + if (count >= out.len) return null; // Never publish partial credentials. + out[count] = .{ .field_len = entry.field_len, .val_len = entry.val_len }; + @memcpy(out[count].field[0..entry.field_len], entry.field[0..entry.field_len]); + @memcpy(out[count].val[0..entry.val_len], entry.val[0..entry.val_len]); + count += 1; + } + return if (count > 0) count else null; +} + +test "source snapshots own values and configuration identity ignores enumeration order" { + const Entry = struct { id: []const u8, id_len: usize, field: []const u8, field_len: usize, val: []const u8, val_len: usize }; + var value = [_]u8{'a'}; + const a = Entry{ .id = "source", .id_len = 6, .field = "base", .field_len = 4, .val = &value, .val_len = 1 }; + const b = Entry{ .id = "source", .id_len = 6, .field = "user", .field_len = 4, .val = "b", .val_len = 1 }; + var fields: [2]FieldSnapshot = undefined; + try std.testing.expectEqual(@as(?usize, 2), copyEntryFields(&[_]Entry{ a, b }, "source", &fields)); + const before = fingerprintEntries(&[_]Entry{ a, b }); + try std.testing.expectEqual(before, fingerprintEntries(&[_]Entry{ b, a })); + value[0] = 'c'; + try std.testing.expectEqualStrings("a", fields[0].value("base").?); + try std.testing.expect(before != fingerprintEntries(&[_]Entry{ a, b })); + try std.testing.expectEqual(@as(?usize, null), copyEntryFields(&[_]Entry{ a, b }, "source", fields[0..1])); +} /// Whether a source id is safe to use as a filename. /// diff --git a/src/headless.zig b/src/headless.zig index 8109f27c..1be39e78 100644 --- a/src/headless.zig +++ b/src/headless.zig @@ -66,6 +66,8 @@ pub fn headlessMain() !void { } io.sleep(10 * std.time.ns_per_ms); } + const app_state = &@import("core/state.zig").app; + @import("services/search_preview.zig").publishCredentials(app_state.tmdb.api_key[0..app_state.tmdb.api_key_len]); @import("core/state.zig").app.web_remote_enabled = true; const remote = @import("services/remote.zig"); remote.start(); diff --git a/src/main.zig b/src/main.zig index fa4fd094..ea67fefd 100644 --- a/src/main.zig +++ b/src/main.zig @@ -14,6 +14,9 @@ const std = @import("std"); test "TV detail module" { _ = @import("services/tmdb.zig"); } +test "Native Search gallery offline SDL pixel capture" { + if (!@import("build_options").headless) _ = @import("services/search_gallery_native_test.zig"); +} const builtin = @import("builtin"); const dvui = @import("dvui"); const c = @import("core/c.zig"); @@ -832,6 +835,7 @@ pub fn appDeinit() void { // allocator it may publish into are released. 800 ms is the diagnostic // threshold, not permission for a worker to outlive shared state. workers.beginShutdownAndDrain(800); + search.deinitGallery(); @import("player/drop_ingest.zig").deinit(); @import("services/queue.zig").deinit(); @@ -1113,11 +1117,13 @@ fn appFrame() !dvui.App.Result { // Apply any theme change requested off the UI thread (config.load runs // theme.setPreset on the background worker, which can't touch dvui directly). theme.reapplyIfPending(); + if (state.app.config_loaded.load(.acquire)) @import("services/search_preview.zig").publishCredentials(state.app.tmdb.api_key[0..state.app.tmdb.api_key_len]); // Queue producers include playlist/AI/remote worker threads. Drain their // bounded commands here so live queue and player state stay UI-thread-owned. @import("services/queue.zig").drainUi(); @import("services/resolver.zig").drainRemoteAction(); + @import("services/search.zig").updateGalleryRoute(); @import("services/search.zig").drainMemorySearch(); @import("services/auto_subs.zig").pollAttach(); @import("player/watch_history.zig").drainUi(); diff --git a/src/player/player.zig b/src/player/player.zig index 1916ca0c..e12f11e4 100644 --- a/src/player/player.zig +++ b/src/player/player.zig @@ -21,6 +21,7 @@ const MAX_LOAD_URL = 8192; /// drives the same two-method interface against an in-memory fake in tests. const MpvPlaybackSink = struct { ctx: *c.mpv.mpv_handle, + last_result: c_int = 0, fn stringNode(value: [*:0]const u8) c.mpv.mpv_node { return .{ @@ -119,6 +120,7 @@ const MpvPlaybackSink = struct { .format = c.mpv.MPV_FORMAT_NODE_ARRAY, }; const rc = c.mpv.mpv_command_node(self.ctx, &command_node, null); + self.last_result = rc; if (rc < 0) { // A rejected load used to be silent: mpv wrote one line to stdout // and the UI showed "Opening stream" forever. Say what happened, @@ -145,6 +147,16 @@ const MpvPlaybackSink = struct { } }; +/// Reuse the typed transport boundary for an isolated preview context. This +/// command has no MediaPlayer, navigation, progress, or playback history side +/// effects, and cannot target a context registered as a main player. +pub fn loadDetached(ctx: *c.mpv.mpv_handle, request: LoadRequest) bool { + for (state.app.players.items) |registered| if (registered.mpv_ctx == ctx) return false; + var sink: MpvPlaybackSink = .{ .ctx = ctx }; + if (!playback_load.dispatch(&sink, request)) return false; + return sink.last_result >= 0; +} + pub const video_w = 1920; pub const video_h = 1080; diff --git a/src/services/browser.zig b/src/services/browser.zig index ca8bf8bf..98245ae9 100644 --- a/src/services/browser.zig +++ b/src/services/browser.zig @@ -2690,6 +2690,10 @@ pub fn loadContentDirectMetaHeaders( /// Load content with automatic provider routing pub fn loadContent(url: []const u8) void { + if (std.mem.startsWith(u8, url, "opal://music/")) { + _ = @import("music_subsonic.zig").playUniversalRoute(url, "Music", ""); + return; + } const extractors = @import("extractors.zig"); // Normalize URL @@ -2756,10 +2760,14 @@ pub fn loadContent(url: []const u8) void { /// launch Resume prompt are *known playback*, so this forces the player: magnets /// go through the torrent engine, comics to the reader, everything else into mpv. pub fn resumePlayback(url: []const u8) void { + if (std.mem.startsWith(u8, url, "opal://music/")) { + _ = @import("music_subsonic.zig").playUniversalRoute(url, "Music", ""); + return; + } if (std.mem.startsWith(u8, url, "opal://search/")) { const query = url["opal://search/".len..]; if (query.len > 0) { - @import("search.zig").triggerSearch(query); + @import("search.zig").submitQuery(query); state.app.router.navigate(.search); } return; diff --git a/src/services/library_pure.zig b/src/services/library_pure.zig index 23cb528b..ddcb814d 100644 --- a/src/services/library_pure.zig +++ b/src/services/library_pure.zig @@ -15,6 +15,7 @@ pub const ContentKind = enum { iptv, comics, novels, + search, other, pub fn id(self: ContentKind) []const u8 { @@ -93,3 +94,7 @@ test "content kind keeps persisted adapters typed" { try std.testing.expectEqual(ContentKind.other, parseKind("future-adapter")); try std.testing.expectEqualStrings("audiobook", ContentKind.audiobook.id()); } + +test "saved search works reopen through search instead of a media URL" { + try std.testing.expectEqual(ContentKind.search, parseKind("search")); +} diff --git a/src/services/music_subsonic.zig b/src/services/music_subsonic.zig index 2eb2eefd..73d0fae7 100644 --- a/src/services/music_subsonic.zig +++ b/src/services/music_subsonic.zig @@ -101,6 +101,168 @@ pub const SRC_JELLYFIN: u8 = 2; pub const SRC_PLEX: u8 = 3; pub const SRC_AUDIUS: u8 = 4; +pub const UniversalStatus = @import("resolver_lifecycle_pure.zig").SourceStatus; +pub const UniversalReply = struct { count: usize = 0, status: UniversalStatus = .unavailable, connection: u64 = 0 }; +const UniversalConnection = struct { + base: [512]u8 = @splat(0), + base_len: usize = 0, + auth: [512]u8 = @splat(0), + auth_len: usize = 0, + identity: u64 = 0, +}; + +pub fn universalProviderName(source: u8) []const u8 { + return switch (source) { + SRC_JIOSAAVN => "JioSaavn", + SRC_SUBSONIC => "Subsonic", + SRC_JELLYFIN => "Jellyfin music", + SRC_PLEX => "Plex music", + SRC_AUDIUS => "Audius", + else => "Music", + }; +} + +/// Writer-shared snapshots only. Never read mutable Browse/settings arrays. +fn universalConnection(source: u8) ?UniversalConnection { + var out: UniversalConnection = .{}; + switch (source) { + SRC_JIOSAAVN => return out, + SRC_AUDIUS => { + const base = source_config.copyValue("audius", "base", &out.base) orelse return null; + if (!pure.isValidBase(base)) return null; + out.base_len = base.len; + }, + SRC_SUBSONIC => { + var fields: [16]source_config.EntryFieldSnapshot = undefined; + const count = source_config.copyEntry("subsonic", &fields) orelse return null; + var base: []const u8 = ""; + var user: []const u8 = ""; + var pass: []const u8 = ""; + defer for (fields[0..count]) |*field| @memset(&field.val, 0); + for (fields[0..count]) |*field| { + if (field.value("base")) |v| base = v; + if (field.value("user")) |v| user = v; + if (field.value("pass")) |v| pass = v; + } + if (!pure.isValidBase(base) or user.len == 0 or pass.len == 0) return null; + copyField(&out.base, &out.base_len, base); + // Owned, request-local salt; no shared lazy-initialized salt race. + var salt: [24]u8 = undefined; + const salt_text = std.fmt.bufPrint(&salt, "{x}", .{@as(u64, @bitCast(io.milliTimestamp()))}) catch return null; + var token: [32]u8 = undefined; + pure.authToken(pass, salt_text, &token); + const auth = pure.buildAuthQuery(user, &token, salt_text, &out.auth); + if (auth.len == 0) return null; + out.auth_len = auth.len; + out.identity = pure.connectionIdentity(base, user); + }, + SRC_JELLYFIN => { + var snap = @import("jellyfin.zig").connectionSnapshot(); + defer @memset(&snap.token, 0); + if (!snap.connected or snap.server_len == 0 or snap.token_len == 0) return null; + copyField(&out.base, &out.base_len, snap.server[0..snap.server_len]); + copyField(&out.auth, &out.auth_len, snap.token[0..snap.token_len]); + out.identity = pure.connectionIdentity(out.base[0..out.base_len], snap.user_id[0..snap.user_id_len]); + }, + SRC_PLEX => { + var snap = @import("plex.zig").connectionSnapshot(); + defer { + @memset(&snap.account_token, 0); + @memset(&snap.server_token, 0); + } + const token = if (snap.server_token_len > 0) snap.server_token[0..snap.server_token_len] else snap.account_token[0..snap.account_token_len]; + if (snap.server_len == 0 or token.len == 0) return null; + copyField(&out.base, &out.base_len, snap.server[0..snap.server_len]); + copyField(&out.auth, &out.auth_len, token); + }, + else => return null, + } + if (source != SRC_JIOSAAVN and !pure.isValidBase(out.base[0..out.base_len])) return null; + if (out.identity == 0) out.identity = pure.connectionIdentity(out.base[0..out.base_len], ""); + return out; +} + +pub fn universalConfigured(source: u8) bool { + var connection = universalConnection(source) orelse return false; + defer @memset(&connection.auth, 0); + return true; +} + +/// Independent first-page query. Output owns all metadata and safe action URLs; +/// Browse source, rows, paging, and request generation are never changed. +pub fn searchInto(source: u8, query: []const u8, out: []pure.MusicSong) UniversalReply { + if (query.len == 0 or out.len == 0) return .{ .status = .no_results }; + var connection = universalConnection(source) orelse return .{}; + defer @memset(&connection.auth, 0); + const limit = @min(out.len, 16); + var url_buf: [2048]u8 = undefined; + const base = connection.base[0..connection.base_len]; + const auth = connection.auth[0..connection.auth_len]; + const url = switch (source) { + SRC_JIOSAAVN => js_pure.buildSearchUrl(&url_buf, query, @intCast(limit)), + SRC_AUDIUS => au_pure.searchUrl(&url_buf, base, query, limit, 0), + SRC_SUBSONIC => pure.buildSearchUrl(&url_buf, base, auth, query, @intCast(limit)), + SRC_JELLYFIN => jf_pure.buildSearchUrl(&url_buf, base, auth, query, @intCast(limit)), + SRC_PLEX => px_pure.buildSearchUrl(&url_buf, base, auth, query, @intCast(limit)), + else => null, + } orelse return .{ .status = .failed }; + const buffer = alloc.alloc(u8, 512 * 1024) catch return .{ .status = .failed }; + defer alloc.free(buffer); + const headers = [_]std.http.Header{.{ .name = "Accept", .value = "application/json" }}; + const body = @import("../core/http.zig").fetch(url, buffer, .{ .timeout_secs = 4, .extra_headers = &headers }) orelse return .{ .status = .transport_failed }; + var parsed = std.json.parseFromSlice(std.json.Value, alloc, body, .{}) catch return .{ .status = .parse_failed }; + defer parsed.deinit(); + const response_kind: pure.SearchResponseKind = switch (source) { + SRC_JIOSAAVN => .jiosaavn, + SRC_SUBSONIC => .subsonic, + SRC_JELLYFIN => .jellyfin, + SRC_PLEX => .plex, + SRC_AUDIUS => .audius, + else => return .{ .status = .failed }, + }; + const rows = pure.pageRows(parsed.value, response_kind) orelse return .{ .status = .parse_failed }; + var count: usize = 0; + for (rows) |value| { + if (count >= limit) break; + var row = switch (source) { + SRC_JIOSAAVN => js_pure.parseSongValue(value), + SRC_SUBSONIC => pure.parseSongValue(value), + SRC_JELLYFIN => jf_pure.parseSongValue(value), + SRC_PLEX => px_pure.parseSongValue(value), + SRC_AUDIUS => au_pure.parseTrack(value, base), + else => null, + } orelse continue; + if (source == SRC_SUBSONIC or source == SRC_JELLYFIN or source == SRC_PLEX) { + // Private artwork remains private; public search cannot expose keys. + row.cover_len = 0; + } + if ((source == SRC_JIOSAAVN or source == SRC_AUDIUS) and row.play_url_len == 0) continue; + out[count] = row; + count += 1; + } + return .{ .count = count, .status = if (count > 0) .done else .no_results, .connection = connection.identity }; +} + +/// Resolve private action against fresh credentials and searched connection. +pub fn playUniversalRoute(route_url: []const u8, title: []const u8, artist: []const u8) bool { + const route = pure.parseUniversalRoute(route_url) orelse return false; + var connection = universalConnection(route.source) orelse return false; + defer @memset(&connection.auth, 0); + if (connection.identity != route.connection) return false; + const base = connection.base[0..connection.base_len]; + const auth = connection.auth[0..connection.auth_len]; + const id = route.id[0..route.id_len]; + var url_buf: [2048]u8 = undefined; + const stream = switch (route.source) { + SRC_SUBSONIC => pure.buildStreamUrl(&url_buf, base, auth, id), + SRC_JELLYFIN => jf_pure.buildStreamUrl(&url_buf, base, auth, id, "opal"), + SRC_PLEX => px_pure.buildStreamUrl(&url_buf, base, auth, route.part[0..route.part_len]), + else => null, + } orelse return false; + @import("browser.zig").playDirect(.{ .url = stream, .history_identity = route_url, .restore_target = route_url, .title = title, .subtitle = artist }); + return true; +} + pub fn selectSource(src: u8) bool { if (src > SRC_AUDIUS) return false; search_request.cancel(&state.app.music.is_loading); diff --git a/src/services/music_subsonic_pure.zig b/src/services/music_subsonic_pure.zig index 2eeb33fd..6a45d7b1 100644 --- a/src/services/music_subsonic_pure.zig +++ b/src/services/music_subsonic_pure.zig @@ -201,6 +201,103 @@ pub fn songIdFromRoute(url: []const u8) ?[]const u8 { return id; } +/// Credential-free universal action bound to the searched server identity. +pub const UniversalRoute = struct { + source: u8, + connection: u64, + id: [128]u8 = @splat(0), + id_len: usize = 0, + part: [256]u8 = @splat(0), + part_len: usize = 0, +}; +pub fn providerQuota(total: usize, providers: usize, index: usize) usize { + if (providers == 0 or index >= providers) return 0; + return total / providers + @as(usize, if (index < total % providers) 1 else 0); +} +pub fn connectionIdentity(base: []const u8, namespace: []const u8) u64 { + var hash = std.hash.Wyhash.init(0x6d75736963); + for ([_][]const u8{ std.mem.trimEnd(u8, base, "/"), namespace }) |part| { + const len: u64 = @intCast(part.len); + hash.update(std.mem.asBytes(&len)); + hash.update(part); + } + return hash.final(); +} +pub fn universalRoute(out: []u8, source: u8, connection: u64, id: []const u8, part: []const u8) ?[]const u8 { + if (source < 1 or source > 3 or id.len == 0 or id.len > 128 or part.len > 256) return null; + var a: [384]u8 = undefined; + var b: [768]u8 = undefined; + const an = percentEncode(id, &a); + const bn = percentEncode(part, &b); + return std.fmt.bufPrint(out, "opal://music/{d}/{x}/{s}/{s}", .{ source, connection, a[0..an], b[0..bn] }) catch null; +} +fn decodeRoutePart(text: []const u8, out: []u8) ?usize { + var i: usize = 0; + var n: usize = 0; + while (i < text.len) { + if (n >= out.len) return null; + const ch = if (text[i] == '%') blk: { + if (i + 2 >= text.len) return null; + const value = std.fmt.parseInt(u8, text[i + 1 .. i + 3], 16) catch return null; + i += 3; + break :blk value; + } else blk: { + const value = text[i]; + i += 1; + break :blk value; + }; + if (ch < 32 or ch == 127) return null; + out[n] = ch; + n += 1; + } + if (!std.unicode.utf8ValidateSlice(out[0..n])) return null; + return n; +} +pub fn parseUniversalRoute(url: []const u8) ?UniversalRoute { + const prefix = "opal://music/"; + if (!std.mem.startsWith(u8, url, prefix)) return null; + var parts = std.mem.splitScalar(u8, url[prefix.len..], '/'); + const source = std.fmt.parseInt(u8, parts.next() orelse return null, 10) catch return null; + if (source < 1 or source > 3) return null; + const connection = std.fmt.parseInt(u64, parts.next() orelse return null, 16) catch return null; + var route = UniversalRoute{ .source = source, .connection = connection }; + route.id_len = decodeRoutePart(parts.next() orelse return null, &route.id) orelse return null; + route.part_len = decodeRoutePart(parts.next() orelse return null, &route.part) orelse return null; + if (route.id_len == 0 or parts.next() != null) return null; + return route; +} + +test "universal music identity roundtrip does not carry credentials or confuse provider" { + var out: [1600]u8 = undefined; + const url = universalRoute(&out, 3, 0x1234, "track a/b", "/library/parts/42/file.mp3").?; + const row = parseUniversalRoute(url).?; + try std.testing.expectEqual(@as(u8, 3), row.source); + try std.testing.expectEqual(@as(u64, 0x1234), row.connection); + try std.testing.expectEqualStrings("track a/b", row.id[0..row.id_len]); + try std.testing.expectEqualStrings("/library/parts/42/file.mp3", row.part[0..row.part_len]); + try std.testing.expect(parseUniversalRoute("opal://music/3/1234/%00/") == null); + try std.testing.expect(parseUniversalRoute("opal://music/4/1234/id/") == null); + try std.testing.expect(parseUniversalRoute("opal://music/1/1234/id//extra") == null); +} + +test "universal music quota stays bounded and fair for all five providers" { + for (1..6) |providers| { + var total: usize = 0; + for (0..providers) |index| total += providerQuota(16, providers, index); + try std.testing.expectEqual(@as(usize, 16), total); + try std.testing.expect(providerQuota(16, providers, 0) - providerQuota(16, providers, providers - 1) <= 1); + } + try std.testing.expectEqual(@as(usize, 0), providerQuota(16, 0, 0)); +} + +test "universal music connection binding rejects another server or library namespace" { + const a = connectionIdentity("https://music.local/", "user"); + try std.testing.expectEqual(a, connectionIdentity("https://music.local", "user")); + try std.testing.expect(a != connectionIdentity("https://other.local", "user")); + try std.testing.expect(a != connectionIdentity("https://music.local", "other")); + try std.testing.expect(connectionIdentity("https://music.local/a", "b") != connectionIdentity("https://music.local/", "ab")); +} + // ── JSON extraction ── /// Read a JSON string field `"key":"…"` from `scope` into `dst` (bytes written, diff --git a/src/services/podcasts_pure.zig b/src/services/podcasts_pure.zig index 07d17dc3..2dcebedc 100644 --- a/src/services/podcasts_pure.zig +++ b/src/services/podcasts_pure.zig @@ -677,9 +677,15 @@ test "podcast deep link: an episode title containing '|' survives" { pub fn parseGpodder(a: std.mem.Allocator, json: []const u8, out: []Podcast) usize { const doc = std.json.parseFromSlice(std.json.Value, a, json, .{}) catch return 0; defer doc.deinit(); - if (doc.value != .array) return 0; + return parseGpodderValue(doc.value, out) orelse 0; +} + +/// Independent directory adapter distinguishes a valid empty page from an +/// error/malformed top-level response, matching the Apple parser contract. +pub fn parseGpodderValue(root: std.json.Value, out: []Podcast) ?usize { + if (root != .array) return null; var n: usize = 0; - for (doc.value.array.items) |row| { + for (root.array.items) |row| { if (n == out.len) break; if (row != .object) continue; const title = jsonValueString(row.object.get("title")); @@ -695,6 +701,16 @@ pub fn parseGpodder(a: std.mem.Allocator, json: []const u8, out: []Podcast) usiz return n; } +test "gpodder universal search distinguishes empty directory from error JSON" { + var rows: [2]Podcast = undefined; + const empty = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, "[]", .{}); + defer empty.deinit(); + try std.testing.expectEqual(@as(?usize, 0), parseGpodderValue(empty.value, &rows)); + const failure = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, "{\"error\":\"unavailable\"}", .{}); + defer failure.deinit(); + try std.testing.expect(parseGpodderValue(failure.value, &rows) == null); +} + fn jsonValueString(value: ?std.json.Value) []const u8 { const v = value orelse return ""; return if (v == .string) v.string else ""; diff --git a/src/services/remote.zig b/src/services/remote.zig index f8732110..1a16794a 100644 --- a/src/services/remote.zig +++ b/src/services/remote.zig @@ -1527,6 +1527,16 @@ fn handleApi(stream: std.Io.net.Stream, api_path: []const u8, query: []const u8, apiUnifiedResolverSearch(stream, query); return; } + if (std.mem.eql(u8, api_path, "/unified_search/preview")) { + if (!requireMethod(stream, method, "GET")) return; + apiUnifiedSearchPreview(stream, query); + return; + } + if (std.mem.eql(u8, api_path, "/unified_search/cancel")) { + if (!requireMethod(stream, method, "POST")) return; + apiUnifiedSearchCancel(stream, query); + return; + } if (std.mem.eql(u8, api_path, "/unified_search/play")) { if (!requireMethod(stream, method, "POST")) return; apiUnifiedSearchAction(stream, query, .play); @@ -4302,15 +4312,15 @@ fn apiUnifiedResolverSearch(stream: std.Io.net.Stream, query: []const u8) void { .{ .name = "torrent", .enabled = resolver.sourceOn(.torrent), .status = resolver.combineManySourceStatuses(&.{ resolver.status_torrent.load(.acquire), resolver.status_yts.load(.acquire), resolver.status_torznab.load(.acquire), resolver.status_eztv.load(.acquire) }) }, .{ .name = "jellyfin", .enabled = resolver.sourceOn(.jellyfin), .status = resolver.status_jf.load(.acquire) }, .{ .name = "youtube", .enabled = resolver.sourceOn(.youtube), .status = resolver.status_yt.load(.acquire) }, - .{ .name = "anime", .enabled = resolver.sourceOn(.anime), .status = resolver.status_anime.load(.acquire) }, - .{ .name = "comics", .enabled = resolver.sourceOn(.comics), .status = resolver.status_comics.load(.acquire) }, + .{ .name = "anime", .enabled = resolver.sourceOn(.anime), .status = resolver.combineSourceStatuses(resolver.status_anime.load(.acquire), resolver.status_anime_catalog.load(.acquire)) }, + .{ .name = "comics", .enabled = resolver.sourceOn(.comics), .status = resolver.combineSourceStatuses(resolver.status_comics.load(.acquire), resolver.status_manga_catalog.load(.acquire)) }, .{ .name = "stremio", .enabled = resolver.sourceOn(.stremio), .status = resolver.combineManySourceStatuses(&.{ resolver.status_stremio.load(.acquire), resolver.status_archive.load(.acquire), resolver.status_nasa.load(.acquire), resolver.status_commons.load(.acquire) }) }, .{ .name = "rss", .enabled = resolver.sourceOn(.rss), .status = resolver.status_rss.load(.acquire) }, .{ .name = "livetv", .enabled = resolver.sourceOn(.livetv), .status = resolver.status_livetv.load(.acquire) }, .{ .name = "music", .enabled = resolver.sourceOn(.music), .status = resolver.status_music.load(.acquire) }, .{ .name = "radio", .enabled = resolver.sourceOn(.radio), .status = resolver.status_radio.load(.acquire) }, .{ .name = "podcast", .enabled = resolver.sourceOn(.podcast), .status = resolver.status_podcast.load(.acquire) }, - .{ .name = "novels", .enabled = resolver.sourceOn(.novels), .status = resolver.combineSourceStatuses(resolver.status_novels.load(.acquire), resolver.status_novel_archive.load(.acquire)) }, + .{ .name = "novels", .enabled = resolver.sourceOn(.novels), .status = resolver.combineManySourceStatuses(&.{ resolver.status_novels.load(.acquire), resolver.status_novel_archive.load(.acquire), resolver.status_public_books.load(.acquire) }) }, .{ .name = "audiobooks", .enabled = resolver.sourceOn(.audiobooks), .status = resolver.status_audiobooks.load(.acquire) }, .{ .name = "opds", .enabled = resolver.sourceOn(.opds), .status = resolver.status_opds.load(.acquire) }, .{ .name = "vndb", .enabled = resolver.sourceOn(.vndb), .status = resolver.status_vndb.load(.acquire) }, @@ -4320,9 +4330,22 @@ fn apiUnifiedResolverSearch(stream: std.Io.net.Stream, query: []const u8) void { }; resolver.unlockRemoteSnapshot(); + const groups = allocator.create(resolver.content.Projection) catch { + sendJsonStatus(stream, "503 Service Unavailable", "{\"error\":\"search grouping unavailable\"}"); + return; + }; + defer allocator.destroy(groups); + var indexes: [resolver.MAX_RESULTS]usize = undefined; + for (indexes[0..count], 0..) |*index, i| index.* = i; + resolver.content.projectInto(snapshot[0..count], indexes[0..count], groups); + var membership: [resolver.MAX_RESULTS]usize = undefined; + for (groups.groups[0..groups.count], 0..) |group, g| { + for (group.indexes[0..group.count]) |index| membership[index] = g; + } + // Each bounded row fits even if every string byte becomes a six-byte JSON // escape. Encoding stays off the connection stack and never drops rows. - const json_buf = allocator.alloc(u8, @as(usize, count) * 12000 + 8192) catch { + const json_buf = allocator.alloc(u8, @as(usize, count) * 16000 + 8192) catch { sendJsonStatus(stream, "503 Service Unavailable", "{\"error\":\"search response unavailable\"}"); return; }; @@ -4348,10 +4371,14 @@ fn apiUnifiedResolverSearch(stream: std.Io.net.Stream, query: []const u8) void { escJsonWrite(&w, txt.safeUtf8(item.author[0..@min(item.author_len, item.author.len)])); w.writeAll("\",\"poster_url\":\"") catch return; escJsonWrite(&w, txt.safeUtf8(item.poster_url[0..@min(item.poster_url_len, item.poster_url.len)])); + w.writeAll("\",\"backdrop_url\":\"") catch return; + escJsonWrite(&w, txt.safeUtf8(item.backdrop_url[0..@min(item.backdrop_url_len, item.backdrop_url.len)])); w.writeAll("\",\"summary\":\"") catch return; escJsonWrite(&w, txt.safeUtf8(item.summary[0..@min(item.summary_len, item.summary.len)])); const rating = if (std.math.isFinite(item.rating) and item.rating > 0 and item.rating <= 10) item.rating else 0; w.print("\",\"rating\":{d:.1}", .{rating}) catch return; + const group = &groups.groups[membership[idx]]; + w.print(",\"year\":{d},\"work_key\":\"{x}\",\"work_category\":\"{s}\",\"work_representative\":{s},\"offer_count\":{d},\"verified_work_match\":{s}", .{ item.year, group.identity, @tagName(group.category), if (group.representative == idx) "true" else "false", group.offer_count, if (item.source == .torrent and group.category != .releases) "true" else "false" }) catch return; // The native and web views share the same typed facet projection. Only // public metadata is serialized; action keys retain private transport. const view = resolver.searchView(item); @@ -4377,6 +4404,72 @@ fn apiUnifiedResolverSearch(stream: std.Io.net.Stream, query: []const u8) void { sendJson(stream, json_buf[0..w.end]); } +fn apiUnifiedSearchPreview(stream: std.Io.net.Stream, query: []const u8) void { + const resolver = @import("resolver.zig"); + const preview = @import("search_preview.zig"); + const generation = std.fmt.parseInt(u32, getQueryParam(query, "generation") orelse "", 10) catch { + sendJsonStatus(stream, "400 Bad Request", "{\"error\":\"invalid generation\"}"); + return; + }; + const key = std.fmt.parseInt(u64, getQueryParam(query, "key") orelse "", 16) catch { + sendJsonStatus(stream, "400 Bad Request", "{\"error\":\"invalid result key\"}"); + return; + }; + var kind: preview.Kind = .movie; + var id: i32 = 0; + const current = resolver.lockRemoteSnapshot(); + if (current != generation) { + resolver.unlockRemoteSnapshot(); + sendJsonStatus(stream, "409 Conflict", "{\"error\":\"search changed; refresh and retry\"}"); + return; + } + for (resolver.results[0..resolver.result_count]) |*row| { + if (resolver.actionKey(row) != key) continue; + const media = row.catalog_kind[0..row.catalog_kind_len]; + if (row.source == .tmdb and row.catalog_id > 0 and + (std.mem.eql(u8, media, "movie") or std.mem.eql(u8, media, "tv"))) + { + id = row.catalog_id; + kind = if (std.mem.eql(u8, media, "tv")) .tv else .movie; + } + break; + } + resolver.unlockRemoteSnapshot(); + if (id == 0) { + sendJsonStatus(stream, "404 Not Found", "{\"error\":\"preview unavailable for this result\"}"); + return; + } + const credentials = preview.copyCredentials(); + const reply = preview.lookupMetadata(kind, id, credentials.key[0..credentials.key_len]); + // Fetching occurs without resolver locks. Reject a response whose search + // wave was superseded during the network lookup, preserving UI selection. + if (!resolver.generationIsCurrent(generation)) { + sendJsonStatus(stream, "409 Conflict", "{\"error\":\"search changed; refresh and retry\"}"); + return; + } + var buf: [8192]u8 = undefined; + var w = std.Io.Writer.fixed(&buf); + w.print("{{\"generation\":{d},\"key\":\"{x}\",\"status\":\"{s}\",\"failure\":\"{s}\",\"title\":\"", .{ generation, key, @tagName(reply.status), @tagName(reply.failure) }) catch return; + escJsonWrite(&w, reply.metadata.title[0..reply.metadata.title_len]); + w.writeAll("\",\"trailer_url\":\"") catch return; + escJsonWrite(&w, reply.metadata.trailer_url[0..reply.metadata.trailer_url_len]); + w.writeAll("\",\"backdrop_url\":\"") catch return; + escJsonWrite(&w, reply.metadata.backdrop_url[0..reply.metadata.backdrop_url_len]); + w.print("\",\"catalog_id\":{d}}}", .{reply.metadata.catalog_id}) catch return; + sendJson(stream, buf[0..w.end]); +} + +fn apiUnifiedSearchCancel(stream: std.Io.net.Stream, query: []const u8) void { + const generation = std.fmt.parseInt(u32, getQueryParam(query, "generation") orelse "", 10) catch { + sendJsonStatus(stream, "400 Bad Request", "{\"error\":\"invalid generation\"}"); + return; + }; + if (@import("resolver.zig").cancelExpected(generation)) + sendJson(stream, "{\"ok\":true}") + else + sendJsonStatus(stream, "409 Conflict", "{\"error\":\"search changed; refresh and retry\"}"); +} + fn apiUnifiedSearchAction( stream: std.Io.net.Stream, query: []const u8, diff --git a/src/services/remote_limits_pure.zig b/src/services/remote_limits_pure.zig index 743f7e1f..346d02a4 100644 --- a/src/services/remote_limits_pure.zig +++ b/src/services/remote_limits_pure.zig @@ -134,6 +134,7 @@ fn queryEquals(query: []const u8, wanted: []const u8, value: []const u8) bool { /// charged. Playback/status/media polling is intentionally absent. pub fn expensiveCost(path: []const u8, query: []const u8) u16 { if (std.mem.eql(u8, path, "/api/scrape")) return 4; + if (std.mem.eql(u8, path, "/api/unified_search/preview")) return 1; if (std.mem.eql(u8, path, "/api/search") or std.mem.eql(u8, path, "/api/unified_search") or std.mem.endsWith(u8, path, "/search")) return if (hasQueryValue(query, "q")) 1 else 0; @@ -193,6 +194,8 @@ test "weighted scrape cost and expensive route allowlist exclude polling" { try std.testing.expectEqual(@as(u16, 4), expensiveCost("/api/scrape", "")); try std.testing.expectEqual(@as(u16, 1), expensiveCost("/api/tmdb/search", "q=opal")); try std.testing.expectEqual(@as(u16, 1), expensiveCost("/api/unified_search", "q=opal")); + try std.testing.expectEqual(@as(u16, 1), expensiveCost("/api/unified_search/preview", "generation=2&key=ab")); + try std.testing.expectEqual(@as(u16, 0), expensiveCost("/api/unified_search/cancel", "generation=2")); try std.testing.expectEqual(@as(u16, 1), expensiveCost("/api/recommendations", "refresh=1")); for ([_]struct { path: []const u8, query: []const u8 }{ .{ .path = "/api/search", .query = "" }, diff --git a/src/services/remote_static.zig b/src/services/remote_static.zig index 1b765bce..cc90c8ef 100644 --- a/src/services/remote_static.zig +++ b/src/services/remote_static.zig @@ -149,7 +149,7 @@ fn serveFile(stream: std.Io.net.Stream, path: []const u8, asset: Asset, index: u .immutable => "Cache-Control: public, max-age=31536000, immutable\r\n", }; const privacy_header: []const u8 = if (std.mem.eql(u8, asset.content_type, "text/html")) - "Referrer-Policy: no-referrer\r\nContent-Security-Policy: default-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; media-src 'self' blob:; connect-src 'self'\r\n" + "Referrer-Policy: no-referrer\r\nContent-Security-Policy: default-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; media-src 'self' blob:; connect-src 'self'; frame-src 'self' https://www.youtube-nocookie.com\r\n" else ""; const validator: []const u8 = if (etag.len > 0) "ETag: " else ""; diff --git a/src/services/resolver.zig b/src/services/resolver.zig index d942d065..efd8b080 100644 --- a/src/services/resolver.zig +++ b/src/services/resolver.zig @@ -52,6 +52,9 @@ pub const ResolvedItem = struct { poster_url_len: usize = 0, summary: [512]u8 = std.mem.zeroes([512]u8), summary_len: usize = 0, + year: u16 = 0, + backdrop_url: [512]u8 = std.mem.zeroes([512]u8), + backdrop_url_len: usize = 0, rating: f32 = 0, // Provider rating out of ten; zero means unknown. author: [160]u8 = std.mem.zeroes([160]u8), author_len: usize = 0, @@ -110,7 +113,8 @@ pub const ResolvedItem = struct { // Shared result buffer. The cap is a hard ceiling on a single search wave — // with the audio/live verticals fanning out alongside video, 64 starved the // late finishers, so it is sized to give every source room to land. -pub const MAX_RESULTS: usize = 96; +pub const content = @import("search_content_pure.zig"); +pub const MAX_RESULTS: usize = content.MAX_ROWS; pub var results: [MAX_RESULTS]ResolvedItem = std.mem.zeroes([MAX_RESULTS]ResolvedItem); pub var result_count: usize = 0; pub var results_mutex = @import("../core/sync.zig").Mutex{}; @@ -151,6 +155,9 @@ pub var status_livetv = std.atomic.Value(SourceStatus).init(.idle); pub var status_music = std.atomic.Value(SourceStatus).init(.idle); pub var status_radio = std.atomic.Value(SourceStatus).init(.idle); pub var status_podcast = std.atomic.Value(SourceStatus).init(.idle); +pub var status_anime_catalog = std.atomic.Value(SourceStatus).init(.idle); +pub var status_manga_catalog = std.atomic.Value(SourceStatus).init(.idle); +pub var status_public_books = std.atomic.Value(SourceStatus).init(.idle); pub var status_catalog = std.atomic.Value(SourceStatus).init(.idle); pub var status_plex = std.atomic.Value(SourceStatus).init(.idle); pub var status_plugins = std.atomic.Value(SourceStatus).init(.idle); @@ -177,6 +184,12 @@ pub const SourceBit = enum(u4) { local, torrent, jellyfin, youtube, anime, comic /// vertical can't silently leave its pill off (or make `all on` mis-detect). pub const ALL_SOURCE_BITS: u16 = @intCast((@as(u32, 1) << @typeInfo(SourceBit).@"enum".fields.len) - 1); pub var source_mask: std.atomic.Value(u16) = std.atomic.Value(u16).init(ALL_SOURCE_BITS); +const CacheScope = struct { mask: u16 = ALL_SOURCE_BITS, fingerprint: u64 = 0 }; +var resolver_cache_scope: CacheScope = .{}; // lifecycle_mutex +fn currentCacheScope() CacheScope { + const mask = source_mask.load(.acquire); + return .{ .mask = if (mask == 0) ALL_SOURCE_BITS else mask, .fingerprint = @import("../core/source_config.zig").fingerprint() }; +} pub fn sourceOn(bit: SourceBit) bool { const m = source_mask.load(.acquire); @@ -397,6 +410,7 @@ pub fn resolveTracked(query: []const u8, intent: []const u8) u32 { // the generation orphans the in-flight wave: its pushResult calls no-op // (worker_gen mismatch) while this wave's workers own the fresh statuses. const this_run = run_gen.fetchAdd(1, .acq_rel) +% 1; + resolver_cache_scope = currentCacheScope(); // Save query — normalize "season X episode Y" → "SXXEYY" var norm_buf: [256]u8 = undefined; @@ -458,6 +472,9 @@ pub fn resolveTracked(query: []const u8, intent: []const u8) u32 { Pre.set(&status_podcast, .podcast); Pre.set(&status_novels, .novels); Pre.set(&status_novel_archive, .novels); + Pre.set(&status_anime_catalog, .anime); + Pre.set(&status_manga_catalog, .comics); + Pre.set(&status_public_books, .novels); Pre.set(&status_vndb, .vndb); Pre.set(&status_audiobooks, .audiobooks); Pre.set(&status_opds, .opds); @@ -490,6 +507,9 @@ pub fn resolveTracked(query: []const u8, intent: []const u8) u32 { } }; if (sourceOn(.local)) Spawn.go(resolveLocalFiles, &status_local); // instant — already on disk + if (sourceOn(.anime)) Spawn.go(resolveAnimeCatalog, &status_anime_catalog); + if (sourceOn(.comics)) Spawn.go(resolveMangaCatalog, &status_manga_catalog); + if (sourceOn(.novels)) Spawn.go(resolvePublicBooks, &status_public_books); Spawn.go(resolveCatalog, &status_catalog); // typed movie/show metadata, always available keylessly Spawn.go(resolvePlex, &status_plex); // connected personal library; unavailable is immediate Spawn.go(resolveInstalledPlugins, &status_plugins); // trusted executable content plugins @@ -658,6 +678,7 @@ pub fn searchView(item: *const ResolvedItem) search_view.Item { .torrent = torrent, .library = switch (item.source) { .local, .jellyfin, .plex, .audiobooks, .opds => true, + .music => std.mem.startsWith(u8, url, "opal://music/"), else => false, }, .quality = item.quality, @@ -754,6 +775,15 @@ pub fn cancel() void { } /// Admission and invalidation share the lifecycle mutex with worker publish. +/// Remote cancellation binds to the visible wave and cannot stop a successor. +pub fn cancelExpected(expected: u32) bool { + lifecycle_mutex.lock(); + defer lifecycle_mutex.unlock(); + if (run_gen.load(.acquire) != expected) return false; + cancelLocked(); + return true; +} + fn cancelLocked() void { _ = run_gen.fetchAdd(1, .acq_rel); is_resolving.store(false, .release); @@ -781,6 +811,9 @@ fn resetSearchingStatuses() void { &status_music, &status_radio, &status_podcast, + &status_anime_catalog, + &status_manga_catalog, + &status_public_books, &status_catalog, &status_plex, &status_plugins, @@ -851,6 +884,7 @@ threadlocal var thread_sink: ?*Sink = null; pub fn warmQuery(query: []const u8) void { if (!state.app.content_cache_enabled) return; if (query.len == 0 or query.len > 255) return; + const cache_scope = currentCacheScope(); const rows = alloc.alloc(ResolvedItem, MAX_RESULTS) catch return; defer alloc.free(rows); @@ -886,7 +920,9 @@ pub fn warmQuery(query: []const u8) void { defer alloc.free(buf); const blob = serializeRows(sink.items[0..sink.count], buf) orelse return; var key_buf: [288]u8 = undefined; - content_cache.put(cacheKey(&key_buf, query), blob, SEARCH_TTL_S); + const current_scope = currentCacheScope(); + if (current_scope.mask != cache_scope.mask or current_scope.fingerprint != cache_scope.fingerprint) return; + content_cache.put(cacheKey(&key_buf, query, cache_scope), blob, SEARCH_TTL_S); var lb: [96]u8 = undefined; logs.pushLog("info", "resolver", std.fmt.bufPrint( @@ -1005,8 +1041,12 @@ fn pushInto( // Legacy callers can reorder the live list; restore its relevance invariant // before selecting the worst bounded candidate or inserting a streamed row. std.sort.insertion(ResolvedItem, items[0..count.*], {}, relevanceBefore); - if (!search_view.admitTopK(count.*, items.len, .{ .score = scored_item.score, .key = actionKey(&scored_item) }, if (count.* > 0) .{ .score = items[count.* - 1].score, .key = actionKey(&items[count.* - 1]) } else .{})) return false; - if (count.* >= items.len) count.* -= 1; + if (count.* >= items.len) { + const victim = content.evictionIndex(items[0..count.*], scored_item) orelse return false; + var shift = victim; + while (shift + 1 < count.*) : (shift += 1) items[shift] = items[shift + 1]; + count.* -= 1; + } // Insert sorted by relevance with the same stable identity tie policy. var insert_at: usize = count.*; @@ -1125,10 +1165,11 @@ fn resolveCatalog(query_buf: [256]u8, qlen: usize) void { if (std.mem.startsWith(u8, poster, "https://") or std.mem.startsWith(u8, poster, "http://")) { if (poster.len <= item.poster_url.len) copyField(&item.poster_url, &item.poster_url_len, poster); } else if (std.mem.startsWith(u8, poster, "/")) { - const address = std.fmt.bufPrint(&item.poster_url, "https://image.tmdb.org/t/p/w185{s}", .{poster}) catch ""; + const address = std.fmt.bufPrint(&item.poster_url, "https://image.tmdb.org/t/p/w342{s}", .{poster}) catch ""; item.poster_url_len = address.len; } const year = entry.year[0..@min(entry.year_len, entry.year.len)]; + item.year = std.fmt.parseInt(u16, year, 10) catch 0; const label = if (std.mem.eql(u8, kind, "tv")) "TV details" else "Movie details"; const detail = if (year.len > 0) std.fmt.bufPrint(&item.detail, "{s} · {s}", .{ year, label }) catch label @@ -1341,8 +1382,8 @@ fn attrValue(html: []const u8, name: []const u8, limit: usize) ?[]const u8 { // Serialization routes through content_cache_pure.Writer/Reader (tested). // ══════════════════════════════════════════════════════════ -fn cacheKey(buf: []u8, query: []const u8) []const u8 { - return std.fmt.bufPrint(buf, "search:v8:{s}", .{query}) catch "search:v8:"; +fn cacheKey(buf: []u8, query: []const u8, scope: CacheScope) []const u8 { + return content.cacheIdentity(buf, query, scope.mask, scope.fingerprint) orelse ""; } /// Serialize the current `results` (under caller's lock) into `out`. @@ -1356,11 +1397,15 @@ fn serializeResults(out: []u8) ?[]u8 { /// cache the live path reads back. fn serializeRows(rows: []const ResolvedItem, out: []u8) ?[]u8 { var w = ccp.Writer.init(out); - const n: u16 = @intCast(@min(rows.len, MAX_RESULTS)); + var n: u16 = 0; + for (rows[0..@min(rows.len, MAX_RESULTS)]) |*row| { + if (content.cacheEligible(.{ .source = @tagName(row.source), .provider = row.provider.name(), .url = row.url[0..row.url_len] })) n += 1; + } w.u16v(n); var i: usize = 0; - while (i < n) : (i += 1) { + while (i < @min(rows.len, MAX_RESULTS)) : (i += 1) { const it = rows[i]; + if (!content.cacheEligible(.{ .source = @tagName(it.source), .provider = it.provider.name(), .url = it.url[0..it.url_len] })) continue; w.blob(it.name[0..@min(it.name_len, it.name.len)]); w.blob(it.detail[0..@min(it.detail_len, it.detail.len)]); w.blob(it.provider.name()); @@ -1390,6 +1435,8 @@ fn serializeRows(rows: []const ResolvedItem, out: []u8) ?[]u8 { w.blob(it.poster_url[0..@min(it.poster_url_len, it.poster_url.len)]); w.blob(it.summary[0..@min(it.summary_len, it.summary.len)]); w.f32v(it.rating); + w.u16v(it.year); + w.blob(it.backdrop_url[0..@min(it.backdrop_url_len, it.backdrop_url.len)]); w.blob(it.author[0..@min(it.author_len, it.author.len)]); if (it.source == .opds) { w.blob(it.opds_entry.title[0..@min(it.opds_entry.title_len, it.opds_entry.title.len)]); @@ -1461,6 +1508,8 @@ fn deserializeInto(bytes: []const u8) usize { copyField(&it.poster_url, &it.poster_url_len, r.blob() orelse break); copyField(&it.summary, &it.summary_len, r.blob() orelse break); it.rating = r.f32v() orelse break; + it.year = r.u16v() orelse break; + copyField(&it.backdrop_url, &it.backdrop_url_len, r.blob() orelse break); copyField(&it.author, &it.author_len, r.blob() orelse break); if (it.source == .opds) { copyField(&it.opds_entry.title, &it.opds_entry.title_len, r.blob() orelse break); @@ -1489,7 +1538,7 @@ fn populateFromCache(query: []const u8) void { const buf = alloc.alloc(u8, SEARCH_BLOB_CAP) catch return; defer alloc.free(buf); var key_buf: [288]u8 = undefined; - const key = cacheKey(&key_buf, query); + const key = cacheKey(&key_buf, query, resolver_cache_scope); const hit = content_cache.get(key, buf) orelse return; results_mutex.lock(); defer results_mutex.unlock(); @@ -1506,6 +1555,8 @@ fn populateFromCache(query: []const u8) void { /// cache-seeded placeholder (that would reset the TTL without a network fetch). fn storeToCache() void { if (!state.app.content_cache_enabled) return; + const current_scope = currentCacheScope(); + if (current_scope.mask != resolver_cache_scope.mask or current_scope.fingerprint != resolver_cache_scope.fingerprint) return; results_mutex.lock(); if (result_count == 0 or results_from_cache) { results_mutex.unlock(); @@ -1523,7 +1574,9 @@ fn storeToCache() void { results_mutex.unlock(); if (blob) |b| { var key_buf: [288]u8 = undefined; - const key = cacheKey(&key_buf, qbuf[0..qn]); + // The wave retains its initial configuration scope. A reload while + // workers drain must not write old results under the new credentials. + const key = cacheKey(&key_buf, qbuf[0..qn], resolver_cache_scope); content_cache.put(key, b, SEARCH_TTL_S); } } @@ -1565,6 +1618,7 @@ fn checkAllDoneLocked(run: u32) void { status_nasa.load(.acquire) != .searching and status_commons.load(.acquire) != .searching and status_music.load(.acquire) != .searching and status_radio.load(.acquire) != .searching and status_podcast.load(.acquire) != .searching and status_livetv.load(.acquire) != .searching and + status_anime_catalog.load(.acquire) != .searching and status_manga_catalog.load(.acquire) != .searching and status_public_books.load(.acquire) != .searching and status_catalog.load(.acquire) != .searching and status_plex.load(.acquire) != .searching and status_plugins.load(.acquire) != .searching and status_novels.load(.acquire) != .searching and status_novel_archive.load(.acquire) != .searching and @@ -2964,8 +3018,8 @@ fn resolveArchive(query_buf: [256]u8, qlen: usize) void { var item = std.mem.zeroes(ResolvedItem); item.source = .stremio; // HTTP-direct stream — plays via mpv load_file - const nlen = @min(raw_title.len, 255); + item.provider = search_view.Provider.init("archive"); @memcpy(item.name[0..nlen], raw_title[0..nlen]); item.name_len = nlen; @@ -3118,8 +3172,8 @@ fn resolveNasa(query_buf: [256]u8, qlen: usize) void { var item = std.mem.zeroes(ResolvedItem); item.source = .stremio; // HTTP-direct stream — plays via mpv load_file - const nlen = @min(hit.title.len, 255); + item.provider = search_view.Provider.init("nasa"); @memcpy(item.name[0..nlen], hit.title[0..nlen]); item.name_len = nlen; @@ -3216,8 +3270,8 @@ fn resolveCommons(query_buf: [256]u8, qlen: usize) void { var item = std.mem.zeroes(ResolvedItem); item.source = .stremio; // HTTP-direct stream — plays via mpv load_file - const nlen = @min(pg.title.len, 255); + item.provider = search_view.Provider.init("commons"); @memcpy(item.name[0..nlen], pg.title[0..nlen]); item.name_len = nlen; @@ -3482,7 +3536,7 @@ fn resolveYouTube(query_buf: [256]u8, qlen: usize) void { /// Cap per audio source — the fan-out already competes for MAX_RESULTS slots, /// and a music query returning 30 rows would bury every video result. -const AUDIO_MAX: usize = 6; +const AUDIO_MAX: usize = 16; fn resolveLiveTv(query_buf: [256]u8, qlen: usize) void { defer finishWorker(&status_livetv, .done); @@ -3533,51 +3587,47 @@ fn resolveMusic(query_buf: [256]u8, qlen: usize) void { defer finishWorker(&status_music, .done); const query = query_buf[0..qlen]; if (query.len < 2) return; - - const jp = @import("music_jiosaavn_pure.zig"); - var url_buf: [640]u8 = undefined; - const url = jp.buildSearchUrl(&url_buf, query, 20) orelse { - noteWorkerOutcome(.failed); + const music = @import("music_subsonic.zig"); + const mp = @import("music_subsonic_pure.zig"); + var enabled: [5]bool = @splat(false); + var providers: usize = 0; + for (&enabled, 0..) |*on, index| { + on.* = music.universalConfigured(@intCast(index)); + if (on.*) providers += 1; + } + if (providers == 0) { + noteWorkerOutcome(.unavailable); return; - }; - - // JioSaavn search payloads run large — heap, not the worker stack. - const buf = alloc.alloc(u8, 512 * 1024) catch { + } + const songs = alloc.alloc(mp.MusicSong, AUDIO_MAX) catch { noteWorkerOutcome(.failed); return; }; - defer alloc.free(buf); - const body = @import("../core/http.zig").fetch(url, buf, .{ .timeout_secs = 8 }) orelse { - noteWorkerOutcome(.transport_failed); - return; - }; - - var parsed = std.json.parseFromSlice(std.json.Value, alloc, body, .{}) catch { - noteWorkerOutcome(.parse_failed); - return; - }; - defer parsed.deinit(); - const rows = @import("music_subsonic_pure.zig").pageRows(parsed.value, .jiosaavn) orelse { - noteWorkerOutcome(.parse_failed); - return; - }; - var found: usize = 0; - for (rows) |obj| { - if (found >= AUDIO_MAX) break; - const song = jp.parseSongValue(obj) orelse continue; - - var item = std.mem.zeroes(ResolvedItem); - item.source = .music; - copyField(&item.name, &item.name_len, song.title[0..song.title_len]); - copyField(&item.url, &item.url_len, song.play_url[0..song.play_url_len]); - copyField(&item.poster_url, &item.poster_url_len, song.cover[0..song.cover_len]); - var d: [128]u8 = undefined; - const detail = if (song.artist_len > 0) - std.fmt.bufPrint(&d, "Music - {s}", .{song.artist[0..song.artist_len]}) catch "Music" - else - "Music"; - copyField(&item.detail, &item.detail_len, detail); - if (pushResult(item)) found += 1; + defer alloc.free(songs); + var visited: usize = 0; + for (enabled, 0..) |on, index| { + if (!on) continue; + if (thread_sink == null and run_gen.load(.acquire) != worker_gen) return; + const quota = mp.providerQuota(AUDIO_MAX, providers, visited); + visited += 1; + const source: u8 = @intCast(index); + const reply = music.searchInto(source, query, songs[0..quota]); + if (reply.status != .done and reply.status != .no_results) noteWorkerOutcome(reply.status); + for (songs[0..reply.count]) |song| { + var item: ResolvedItem = .{ .source = .music }; + item.provider = search_view.Provider.init(music.universalProviderName(source)); + copyField(&item.name, &item.name_len, song.title[0..song.title_len]); + copyField(&item.author, &item.author_len, song.artist[0..song.artist_len]); + copyField(&item.poster_url, &item.poster_url_len, song.cover[0..song.cover_len]); + if (source == music.SRC_SUBSONIC or source == music.SRC_JELLYFIN or source == music.SRC_PLEX) { + const route = mp.universalRoute(&item.url, source, reply.connection, song.id[0..song.id_len], if (source == music.SRC_PLEX) song.play_url[0..song.play_url_len] else "") orelse continue; + item.url_len = route.len; + } else copyField(&item.url, &item.url_len, song.play_url[0..song.play_url_len]); + var detail_buf: [128]u8 = undefined; + const detail = std.fmt.bufPrint(&detail_buf, "{s} · {s}", .{ music.universalProviderName(source), song.artist[0..song.artist_len] }) catch music.universalProviderName(source); + copyField(&item.detail, &item.detail_len, detail); + _ = pushResult(item); + } } } @@ -3606,9 +3656,12 @@ fn resolveRadio(query_buf: [256]u8, qlen: usize) void { return; }; - // Station is ~1.3KB; AUDIO_MAX of them is well under the stack budget. - var stations: [AUDIO_MAX]rp.Station = undefined; - const page = rp.parsePage(alloc, body, &stations) orelse { + const stations = alloc.alloc(rp.Station, AUDIO_MAX) catch { + noteWorkerOutcome(.failed); + return; + }; + defer alloc.free(stations); + const page = rp.parsePage(alloc, body, stations) orelse { noteWorkerOutcome(.parse_failed); return; }; @@ -3626,6 +3679,7 @@ fn resolveRadio(query_buf: [256]u8, qlen: usize) void { var item = std.mem.zeroes(ResolvedItem); item.source = .radio; + item.provider = search_view.Provider.init("Radio Browser"); copyField(&item.name, &item.name_len, s.name[0..s.name_len]); copyField(&item.url, &item.url_len, stream); copyField(&item.poster_url, &item.poster_url_len, s.favicon[0..s.favicon_len]); @@ -3640,77 +3694,94 @@ fn resolveRadio(query_buf: [256]u8, qlen: usize) void { } } +fn publishPodcastRows(rows: []const @import("podcasts_pure.zig").Podcast, provider: []const u8) void { + for (rows) |show| { + if (show.feed_url_len == 0 or show.name_len == 0) continue; + var item: ResolvedItem = .{ .source = .podcast }; + item.provider = search_view.Provider.init(provider); + copyField(&item.name, &item.name_len, show.name[0..show.name_len]); + copyField(&item.author, &item.author_len, show.artist[0..show.artist_len]); + copyField(&item.poster_url, &item.poster_url_len, show.artwork[0..show.artwork_len]); + copyField(&item.url, &item.url_len, show.feed_url[0..show.feed_url_len]); + var detail_buf: [128]u8 = undefined; + const detail = std.fmt.bufPrint(&detail_buf, "{s} · {s}", .{ provider, show.artist[0..show.artist_len] }) catch provider; + copyField(&item.detail, &item.detail_len, detail); + _ = pushResult(item); + } +} + fn resolvePodcasts(query_buf: [256]u8, qlen: usize) void { defer finishWorker(&status_podcast, .done); const query = query_buf[0..qlen]; if (query.len < 2) return; - const pp = @import("podcasts_pure.zig"); - var enc_buf: [512]u8 = undefined; - const enc = @import("../core/http.zig").urlEncode(query, &enc_buf); - var url_buf: [640]u8 = undefined; - const url = std.fmt.bufPrint( - &url_buf, - "https://itunes.apple.com/search?media=podcast&limit=20&term={s}", - .{enc}, - ) catch { - noteWorkerOutcome(.failed); - return; - }; - - const buf = alloc.alloc(u8, 512 * 1024) catch { + const cfg = @import("../core/source_config.zig"); + const http = @import("../core/http.zig"); + const buffer = alloc.alloc(u8, 512 * 1024) catch { noteWorkerOutcome(.failed); return; }; - defer alloc.free(buf); - const body = @import("../core/http.zig").fetch(url, buf, .{ .timeout_secs = 8 }) orelse { - noteWorkerOutcome(.transport_failed); - return; - }; - - var parsed = std.json.parseFromSlice(std.json.Value, alloc, body, .{}) catch { - noteWorkerOutcome(.parse_failed); - return; - }; - defer parsed.deinit(); - if (parsed.value != .object) { - noteWorkerOutcome(.parse_failed); - return; - } - const entries = parsed.value.object.get("results") orelse { - noteWorkerOutcome(.parse_failed); - return; - }; - if (entries != .array) { - noteWorkerOutcome(.parse_failed); - return; - } + defer alloc.free(buffer); const shows = alloc.alloc(pp.Podcast, AUDIO_MAX) catch { noteWorkerOutcome(.failed); return; }; defer alloc.free(shows); - const n = pp.parseItunesValue(parsed.value, shows) orelse { - noteWorkerOutcome(.parse_failed); + const feeds = alloc.alloc(cfg.FieldSnapshot, 256) catch { + noteWorkerOutcome(.failed); return; }; - var i: usize = 0; - while (i < n) : (i += 1) { - const s = shows[i]; - if (s.feed_url_len == 0 or s.name_len == 0) continue; - var item = std.mem.zeroes(ResolvedItem); - item.source = .podcast; - copyField(&item.name, &item.name_len, s.name[0..s.name_len]); - copyField(&item.poster_url, &item.poster_url_len, s.artwork[0..s.artwork_len]); - // The feed URL is the identity — playItem opens the Podcasts tab on it. - copyField(&item.url, &item.url_len, s.feed_url[0..s.feed_url_len]); - var d: [128]u8 = undefined; - const detail = if (s.artist_len > 0) - std.fmt.bufPrint(&d, "Podcast - {s}", .{s.artist[0..s.artist_len]}) catch "Podcast" + defer alloc.free(feeds); + const feed_count = cfg.copyFields("feed", feeds); + // Bound total network work. An explicit partial state exposes skipped + // installed feeds rather than reporting a complete search of every feed. + const feed_limit: usize = 4; + if (feed_count > feed_limit) noteWorkerOutcome(.partial); + var installed_count: usize = 0; + for (feeds[0..@min(feed_count, feed_limit)]) |feed| { + if (thread_sink == null and run_gen.load(.acquire) != worker_gen) return; + const url = feed.value[0..feed.value_len]; + if (!pp.isFeedUrl(url)) continue; + const body = http.fetch(url, buffer, .{ .timeout_secs = 2 }) orelse { + noteWorkerOutcome(.transport_failed); + continue; + }; + // General RSS/torrent feeds are not podcast directories. + if (std.mem.indexOf(u8, body, "itunes:") == null and std.mem.indexOf(u8, body, "type=\"audio/") == null and std.mem.indexOf(u8, body, "type='audio/") == null) continue; + const show = pp.parseFeedShow(body, url) orelse { + noteWorkerOutcome(.parse_failed); + continue; + }; + if (std.ascii.indexOfIgnoreCase(show.name[0..show.name_len], query) == null and std.ascii.indexOfIgnoreCase(show.artist[0..show.artist_len], query) == null) continue; + shows[installed_count] = show; + installed_count += 1; + } + publishPodcastRows(shows[0..installed_count], "Installed RSS"); + var enc_buf: [768]u8 = undefined; + const encoded = http.urlEncode(query, &enc_buf); + var url_buf: [1200]u8 = undefined; + // Divide remaining audio quota fairly across independent public catalogs. + const directory_quota = (AUDIO_MAX - installed_count) / 2; + for (0..2) |directory| { + if (thread_sink == null and run_gen.load(.acquire) != worker_gen) return; + const url = if (directory == 0) + std.fmt.bufPrint(&url_buf, "https://gpodder.net/search.json?q={s}", .{encoded}) catch continue else - "Podcast"; - copyField(&item.detail, &item.detail_len, detail); - _ = pushResult(item); + std.fmt.bufPrint(&url_buf, "https://itunes.apple.com/search?media=podcast&limit={d}&term={s}", .{ directory_quota, encoded }) catch continue; + const body = http.fetch(url, buffer, .{ .timeout_secs = 4 }) orelse { + noteWorkerOutcome(.transport_failed); + continue; + }; + var parsed = std.json.parseFromSlice(std.json.Value, alloc, body, .{}) catch { + noteWorkerOutcome(.parse_failed); + continue; + }; + defer parsed.deinit(); + const count = (if (directory == 0) pp.parseGpodderValue(parsed.value, shows[0..directory_quota]) else pp.parseItunesValue(parsed.value, shows[0..directory_quota])) orelse { + noteWorkerOutcome(.parse_failed); + continue; + }; + publishPodcastRows(shows[0..count], if (directory == 0) "gpodder" else "Apple Podcasts"); } } @@ -4145,7 +4216,10 @@ pub fn playResolvedItem(item: *const ResolvedItem) void { // Load the issue and reveal the Browse › Comics reader (comics read // inside the Comics tab now, not the player route). const comics = @import("comics.zig"); - comics.loadComic(item.url[0..item.url_len]); + if (std.mem.eql(u8, item.provider.name(), "mangadex")) + comics.searchComics(item.name[0..item.name_len]) + else + comics.loadComic(item.url[0..item.url_len]); state.navigateToTab(.Comics); }, .tmdb => { @@ -4158,7 +4232,17 @@ pub fn playResolvedItem(item: *const ResolvedItem) void { "", ); } else { - @import("search.zig").submitQuery(item.name[0..item.name_len]); + // Movie metadata opens its real catalog page; never re-submit + // the same universal query in a navigation loop. + var address: [256]u8 = undefined; + const imdb_id = item.catalog_imdb[0..item.catalog_imdb_len]; + const url: []const u8 = if (imdb_id.len > 2 and std.mem.startsWith(u8, imdb_id, "tt")) + std.fmt.bufPrint(&address, "https://www.imdb.com/title/{s}/", .{imdb_id}) catch "" + else if (item.catalog_id > 0) + std.fmt.bufPrint(&address, "https://www.themoviedb.org/movie/{d}", .{item.catalog_id}) catch "" + else + ""; + if (url.len > 0) @import("../ui/settings.zig").openExternal(url) else state.showToast("Catalog details unavailable"); } }, .opds => @import("opds.zig").openCatalogEntry(item.opds_entry, item.opds_connection_identity), @@ -4176,11 +4260,10 @@ pub fn playResolvedItem(item: *const ResolvedItem) void { @import("vndb.zig").openCatalogIdentity(url[prefix.len..]); }, .podcast => { - // No open-by-feed entry point exists yet, so hand the show title to - // the Podcasts tab's own search and reveal it — the user lands on - // the show with its episode list, one click from playing. + // Resolve this exact feed in Podcasts rather than searching the + // display title, which can select a different show. const podcasts = @import("podcasts.zig"); - podcasts.searchPodcasts(item.name[0..item.name_len]); + podcasts.searchPodcasts(item.url[0..item.url_len]); state.navigateToTab(.Podcasts); }, .livetv => { @@ -4208,7 +4291,14 @@ pub fn playResolvedItem(item: *const ResolvedItem) void { &hdrs, ); }, - .youtube, .stremio, .local, .music, .radio => { + .music => { + const url = item.url[0..item.url_len]; + if (std.mem.startsWith(u8, url, "opal://music/")) { + if (!@import("music_subsonic.zig").playUniversalRoute(url, item.name[0..item.name_len], item.author[0..item.author_len])) + state.showToast("Music connection changed or track is unavailable; search again"); + } else @import("browser.zig").playDirect(.{ .url = url, .title = item.name[0..item.name_len], .subtitle = item.author[0..item.author_len], .art_url = item.poster_url[0..item.poster_url_len] }); + }, + .youtube, .stremio, .local, .radio => { @import("browser.zig").playDirect(.{ .url = item.url[0..item.url_len], .fallback_url = item.fallback_url[0..item.fallback_url_len], @@ -4272,3 +4362,243 @@ fn findObjEnd(data: []const u8, start: usize) usize { } return data.len; } + +// Independent metadata searches never mutate the visible Browse page state. +fn fetchCatalogJson(url: []const u8, provider: []const u8) ?[]u8 { + @import("../core/rate_limit.zig").acquire(provider, 1.0); + const buf = alloc.alloc(u8, 1024 * 1024) catch { + noteWorkerOutcome(.failed); + return null; + }; + const body = @import("../core/http.zig").fetch(url, buf, .{ .timeout_secs = 10, .user_agent = @import("../core/app_meta.zig").user_agent }) orelse { + alloc.free(buf); + noteWorkerOutcome(.transport_failed); + return null; + }; + // Preserve the actual allocation for cleanup while returning a body-sized copy. + const owned = alloc.dupe(u8, body) catch { + alloc.free(buf); + noteWorkerOutcome(.failed); + return null; + }; + alloc.free(buf); + return owned; +} +fn resolveAnimeCatalog(q: [256]u8, qlen: usize) void { + defer { + if (!worker_produced and generationIsCurrent(worker_gen)) resolveAniListCatalog(q[0..qlen]); + finishWorker(&status_anime_catalog, .done); + } + var enc: [1024]u8 = undefined; + const query = @import("../core/http.zig").urlEncode(q[0..qlen], &enc); + var address: [1400]u8 = undefined; + const url = std.fmt.bufPrint(&address, "https://api.jikan.moe/v4/anime?q={s}&limit=12{s}", .{ query, @import("anime_pure.zig").sfwSuffix(state.app.nsfw_filter_enabled) }) catch return; + const body = fetchCatalogJson(url, "jikan") orelse return; + defer alloc.free(body); + const parsed = std.json.parseFromSlice(std.json.Value, alloc, body, .{}) catch { + noteWorkerOutcome(.parse_failed); + return; + }; + defer parsed.deinit(); + if (parsed.value != .object) { + noteWorkerOutcome(.parse_failed); + return; + } + const records = parsed.value.object.get("data") orelse { + noteWorkerOutcome(.parse_failed); + return; + }; + if (records != .array) { + noteWorkerOutcome(.parse_failed); + return; + } + for (records.array.items) |record| { + if (record != .object) continue; + const id = record.object.get("mal_id") orelse continue; + if (id != .integer or id.integer <= 0 or id.integer > std.math.maxInt(i32)) continue; + var item: ResolvedItem = .{ .source = .anime, .catalog_id = @intCast(id.integer), .provider = search_view.Provider.init("jikan") }; + var name = catalogString(record, "title_english"); + if (name.len == 0) name = catalogString(record, "title"); + if (name.len == 0) continue; + copyField(&item.name, &item.name_len, name); + copyField(&item.catalog_kind, &item.catalog_kind_len, "anime"); + copyField(&item.summary, &item.summary_len, catalogString(record, "synopsis")); + copyField(&item.detail, &item.detail_len, "Anime details · MyAnimeList"); + if (record.object.get("year")) |year| if (year == .integer and year.integer > 0 and year.integer <= 9999) { + item.year = @intCast(year.integer); + }; + if (record.object.get("score")) |score| { + if (score == .float) item.rating = @floatCast(score.float) else if (score == .integer) item.rating = @floatFromInt(score.integer); + } + if (record.object.get("images")) |images| if (images == .object) { + if (images.object.get("jpg")) |jpg| copyField(&item.poster_url, &item.poster_url_len, catalogString(jpg, "image_url")); + }; + const identity_url: []const u8 = std.fmt.bufPrint(&item.url, "opal://anime/{d}", .{item.catalog_id}) catch ""; + item.url_len = identity_url.len; + _ = pushResult(item); + } +} +fn resolveMangaCatalog(q: [256]u8, qlen: usize) void { + defer finishWorker(&status_manga_catalog, .done); + const pure = @import("comics_pure.zig"); + var address: [1400]u8 = undefined; + const url = pure.buildSearchUrl(&address, q[0..qlen], 12, 0) orelse return; + const body = fetchCatalogJson(url, "mangadex") orelse return; + defer alloc.free(body); + const data = pure.findJsonNode(body, "\"data\"") orelse { + noteWorkerOutcome(.parse_failed); + return; + }; + var iter = pure.ObjIter{ .buf = data }; + var n: usize = 0; + while (iter.next()) |obj| { + if (n >= 12) break; + const manga = pure.parseMangaEntry(obj) orelse continue; + var item: ResolvedItem = .{ .source = .comics, .provider = search_view.Provider.init("mangadex") }; + item.name_len = pure.jsonUnescape(manga.title, &item.name); + const identity_url: []const u8 = std.fmt.bufPrint(&item.url, "mangadex:{s}", .{manga.id}) catch ""; + item.url_len = identity_url.len; + if (pure.buildCoverUrl(&item.poster_url, manga.id, manga.cover_file)) |cover| item.poster_url_len = cover.len; + copyField(&item.detail, &item.detail_len, "MangaDex · Read manga"); + if (item.name_len > 0 and item.url_len > 0) { + _ = pushResult(item); + n += 1; + } + } +} +fn resolvePublicBooks(q: [256]u8, qlen: usize) void { + defer finishWorker(&status_public_books, .done); + defer { + if (generationIsCurrent(worker_gen)) resolveInstalledReading(q[0..qlen]); + } + const books = @import("public_books_pure.zig"); + const novels = @import("novels_pure.zig"); + var address: [2048]u8 = undefined; + if (books.openLibrarySearch(&address, q[0..qlen], 1)) |url| { + if (fetchCatalogJson(url, "openlibrary")) |body| { + defer alloc.free(body); + if (std.json.parseFromSlice(std.json.Value, alloc, body, .{})) |parsed| { + defer parsed.deinit(); + if (parsed.value == .object) { + if (parsed.value.object.get("docs")) |docs| if (docs == .array) { + for (docs.array.items[0..@min(docs.array.items.len, 12)]) |doc| { + const book = books.openLibraryItem(doc) orelse continue; + var item: ResolvedItem = .{ .source = .novels, .year = book.year, .provider = search_view.Provider.init("openlibrary") }; + copyField(&item.name, &item.name_len, book.title); + copyField(&item.author, &item.author_len, book.author); + item.url_len = novels.formatDeepLink(&item.url, "openlibrary", book.id, book.title).len; + if (books.openLibraryCover(&item.poster_url, book.cover_id)) |cover| item.poster_url_len = cover.len; + copyField(&item.detail, &item.detail_len, "Open Library · Public text"); + if (item.url_len > 0) _ = pushResult(item); + } + }; + } + } else |_| noteWorkerOutcome(.parse_failed); + } + } + if (books.gutenbergSearch(&address, q[0..qlen], 1)) |url| { + if (fetchCatalogJson(url, "gutenberg")) |body| { + defer alloc.free(body); + var iter = books.GutenbergIter{ .body = body }; + var n: usize = 0; + while (iter.next()) |book| { + if (n >= 12) break; + var item: ResolvedItem = .{ .source = .novels, .provider = search_view.Provider.init("gutenberg") }; + item.name_len = novels.htmlToText(book.title, &item.name); + item.author_len = novels.htmlToText(book.author, &item.author); + item.url_len = novels.formatDeepLink(&item.url, "gutenberg", book.id, book.title).len; + if (std.mem.startsWith(u8, book.cover, "/")) { + const cover_url: []const u8 = std.fmt.bufPrint(&item.poster_url, "https://www.gutenberg.org{s}", .{book.cover}) catch ""; + item.poster_url_len = cover_url.len; + } else copyField(&item.poster_url, &item.poster_url_len, book.cover); + copyField(&item.detail, &item.detail_len, "Project Gutenberg · Read work"); + if (item.url_len > 0 and item.name_len > 0) { + _ = pushResult(item); + n += 1; + } + } + } + } +} + +/// Installed reading adapters use owned endpoint/query values and Browse's +/// pure parsing seam, without publishing to its mutable query/list state. +fn resolveInstalledReading(query: []const u8) void { + const reading = @import("search_reading_pure.zig"); + const sc = @import("../core/source_config.zig"); + for ([_]reading.Source{ .royalroad, .novelfire }) |source| { + if (!generationIsCurrent(worker_gen)) return; + var base_buf: [512]u8 = undefined; + const base = sc.copyValue(@tagName(source), "base", &base_buf) orelse continue; + var address: [1600]u8 = undefined; + const url = @import("expanded_reading_pure.zig").novelSearchUrl(&address, base, @tagName(source), query, 1) orelse continue; + @import("../core/rate_limit.zig").acquire(@tagName(source), 1.0); + const buf = alloc.alloc(u8, 2 * 1024 * 1024) catch { + noteWorkerOutcome(.failed); + continue; + }; + defer alloc.free(buf); + const body = @import("../core/http.zig").fetch(url, buf, .{ .timeout_secs = 10, .user_agent = "Mozilla/5.0" }) orelse { + noteWorkerOutcome(.transport_failed); + continue; + }; + if (!generationIsCurrent(worker_gen)) return; + // Twelve owned records total ~22 KiB; heap avoids adding their storage + // to the already substantial resolver worker stack. + const rows = alloc.alloc(reading.Item, reading.MAX_ITEMS) catch { + noteWorkerOutcome(.failed); + continue; + }; + defer alloc.free(rows); + const page = reading.parseInto(body, base, source, rows); + if (!page.valid_listing) { + noteWorkerOutcome(.parse_failed); + continue; + } + for (rows[0..page.count]) |*row| { + var item: ResolvedItem = .{ .source = .novels, .provider = search_view.Provider.init(@tagName(source)) }; + copyField(&item.name, &item.name_len, row.title[0..row.title_len]); + copyField(&item.poster_url, &item.poster_url_len, row.cover[0..row.cover_len]); + item.url_len = @import("novels_pure.zig").formatDeepLink(&item.url, @tagName(source), row.url[0..row.url_len], row.title[0..row.title_len]).len; + copyField(&item.detail, &item.detail_len, if (source == .royalroad) "Royal Road · Read work" else "NovelFire · Read work"); + if (item.url_len > 0) _ = pushResult(item); + } + } +} + +fn resolveAniListCatalog(query: []const u8) void { + const buf = alloc.alloc(u8, 512 * 1024) catch { + noteWorkerOutcome(.failed); + return; + }; + defer alloc.free(buf); + const n = @import("anilist.zig").fetchSearch(query, state.app.nsfw_filter_enabled, buf); + if (n == 0) { + noteWorkerOutcome(.transport_failed); + return; + } + const pure = @import("anilist_pure.zig"); + const body = @import("anime_pure.zig").anilistCatalogBody(alloc, buf[0..n]) orelse { + noteWorkerOutcome(.parse_failed); + return; + }; + var iter = pure.Iter{ .json = body }; + var count: usize = 0; + while (iter.next()) |media| { + if (count >= 12) break; + if (media.id <= 0 or media.id > std.math.maxInt(i32)) continue; + var item: ResolvedItem = .{ .source = .anime, .catalog_id = @intCast(media.id), .year = media.year, .rating = media.score10, .provider = search_view.Provider.init("anilist") }; + const name = if (media.title_english.len > 0) media.title_english else media.title_romaji; + item.name_len = @import("comics_pure.zig").jsonUnescape(name, &item.name); + item.summary_len = @import("comics_pure.zig").jsonUnescape(media.description, &item.summary); + item.poster_url_len = @import("comics_pure.zig").jsonUnescape(media.cover, &item.poster_url); + copyField(&item.catalog_kind, &item.catalog_kind_len, "anilist"); + copyField(&item.detail, &item.detail_len, "Anime details · AniList"); + const identity_url: []const u8 = std.fmt.bufPrint(&item.url, "opal://anilist/{d}", .{media.id}) catch ""; + item.url_len = identity_url.len; + if (item.name_len > 0) { + _ = pushResult(item); + count += 1; + } + } +} diff --git a/src/services/search.zig b/src/services/search.zig index 46713869..02ee2fd8 100644 --- a/src/services/search.zig +++ b/src/services/search.zig @@ -854,6 +854,7 @@ pub fn submitQuery(query_text: []const u8) void { // Own the bytes before cancelling work or clearing either visible buffer. @memcpy(owned[0..n], query_text[0..n]); cancelPendingMemorySearch(); + @import("search_preview.zig").stop(); @import("activity.zig").record(.search, owned[0..n], .{}); setUniversalQuery(owned[0..n]); @import("resolver.zig").resolve(owned[0..n], "auto"); @@ -882,6 +883,7 @@ pub fn cancelPendingMemorySearch() void { /// Unified Clear supersedes both searches and pending memory publication. pub fn clearShellSearch() void { + @import("search_preview.zig").stop(); cancelPendingMemorySearch(); search_abort.store(true, .release); _ = search_generation.fetchAdd(1, .acq_rel); @@ -1018,6 +1020,7 @@ pub fn reapWorkers() void { /// first would deadlock shutdown behind a child process that has not yet been /// told to exit. pub fn shutdown() void { + @import("search_preview.zig").stop(); search_abort.store(true, .release); _ = search_generation.fetchAdd(1, .acq_rel); if (nova_may_exist.load(.acquire)) reapWorkers(); @@ -1401,6 +1404,11 @@ fn renderUniversalCapabilities() void { .{ .icon = icons.tvg.lucide.image, .name = "Comics" }, .{ .icon = icons.tvg.lucide.clapperboard, .name = "Stremio" }, .{ .icon = icons.tvg.lucide.rss, .name = "RSS" }, + .{ .icon = icons.tvg.lucide.book, .name = "Books & audiobooks" }, + .{ .icon = icons.tvg.lucide.headphones, .name = "Music & podcasts" }, + .{ .icon = icons.tvg.lucide.radio, .name = "Radio & live TV" }, + .{ .icon = icons.tvg.lucide.server, .name = "Plex & connected catalogs" }, + .{ .icon = icons.tvg.lucide.book, .name = "Visual novels" }, }; var flow = dvui.flexbox(@src(), .{ .justify_content = .center }, .{ .expand = .horizontal, .padding = .{ .x = 0, .y = theme.spacing.md, .w = 0, .h = 0 } }); defer flow.deinit(); @@ -1436,15 +1444,15 @@ fn renderSourceStatusCluster() void { .{ .icon = icons.tvg.lucide.magnet, .name = "Torrents", .bit = .torrent, .st = combinedTorrentStatus() }, .{ .icon = icons.tvg.lucide.server, .name = "Jellyfin", .bit = .jellyfin, .st = resolver.status_jf.load(.acquire) }, .{ .icon = icons.tvg.lucide.youtube, .name = "YouTube", .bit = .youtube, .st = resolver.status_yt.load(.acquire) }, - .{ .icon = icons.tvg.lucide.tv, .name = "Anime", .bit = .anime, .st = resolver.status_anime.load(.acquire) }, - .{ .icon = icons.tvg.lucide.image, .name = "Comics", .bit = .comics, .st = resolver.status_comics.load(.acquire) }, + .{ .icon = icons.tvg.lucide.tv, .name = "Anime", .bit = .anime, .st = resolver.combineSourceStatuses(resolver.status_anime.load(.acquire), resolver.status_anime_catalog.load(.acquire)) }, + .{ .icon = icons.tvg.lucide.image, .name = "Comics", .bit = .comics, .st = resolver.combineSourceStatuses(resolver.status_comics.load(.acquire), resolver.status_manga_catalog.load(.acquire)) }, .{ .icon = icons.tvg.lucide.clapperboard, .name = "Streams", .bit = .stremio, .st = combinedStreamStatus() }, .{ .icon = icons.tvg.lucide.rss, .name = "RSS", .bit = .rss, .st = resolver.status_rss.load(.acquire) }, .{ .icon = icons.tvg.lucide.tv, .name = "Live TV", .bit = .livetv, .st = resolver.status_livetv.load(.acquire) }, .{ .icon = icons.tvg.lucide.music, .name = "Music", .bit = .music, .st = resolver.status_music.load(.acquire) }, .{ .icon = icons.tvg.lucide.radio, .name = "Radio", .bit = .radio, .st = resolver.status_radio.load(.acquire) }, .{ .icon = icons.tvg.lucide.podcast, .name = "Podcasts", .bit = .podcast, .st = resolver.status_podcast.load(.acquire) }, - .{ .icon = icons.tvg.lucide.book, .name = "Novels", .bit = .novels, .st = resolver.combineSourceStatuses(resolver.status_novels.load(.acquire), resolver.status_novel_archive.load(.acquire)) }, + .{ .icon = icons.tvg.lucide.book, .name = "Novels", .bit = .novels, .st = resolver.combineSourceStatuses(resolver.combineSourceStatuses(resolver.status_novels.load(.acquire), resolver.status_novel_archive.load(.acquire)), resolver.status_public_books.load(.acquire)) }, .{ .icon = icons.tvg.lucide.book, .name = "Visual novels", .bit = .vndb, .st = resolver.status_vndb.load(.acquire) }, .{ .icon = icons.tvg.lucide.headphones, .name = "Audiobooks", .bit = .audiobooks, .st = resolver.status_audiobooks.load(.acquire) }, .{ .icon = icons.tvg.lucide.book, .name = "OPDS", .bit = .opds, .st = resolver.status_opds.load(.acquire) }, @@ -1609,9 +1617,17 @@ fn renderUniversalResults() void { const resolver = @import("resolver.zig"); // Facet changes from this frame update counts and rows together. if (view_dirty) refreshSearchView(); + if (view_cache.rows == null or view_cache.loaded == 0 or view_cache.count == 0) @import("search_preview.zig").stop(); + if (view_cache.rows != null) ensureContentProjection(); if (view_cache.query_len > 0 or view_cache.loaded > 0) { - var count_buf: [96]u8 = undefined; - const count = std.fmt.bufPrint(&count_buf, "{d} shown of {d} loaded{s}", .{ view_cache.count, view_cache.loaded, if (view_cache.loading) " · searching…" else "" }) catch "Results"; + var titles: usize = 0; + var releases: usize = 0; + for (content_projection.groups[0..content_projection.count]) |*group| { + if (!galleryGroupVisible(group)) continue; + if (group.category == .releases) releases += 1 else titles += 1; + } + var count_buf: [128]u8 = undefined; + const count = std.fmt.bufPrint(&count_buf, "{d} titles · {d} other releases{s}", .{ titles, releases, if (view_cache.loading) " · searching…" else "" }) catch "Results"; _ = dvui.label(@src(), "{s}", .{count}, .{ .color_text = theme.colors.text_secondary, .padding = dvui.Rect.all(theme.spacing.sm) }); } if (view_cache.rows == null) { @@ -1626,33 +1642,8 @@ fn renderUniversalResults() void { components.emptyState(icons.tvg.lucide.@"search-x", "No matches for these filters", "Remove a filter to see more of the loaded results."); return; } - if (view_dirty) refreshSearchView(); - var scroll = dvui.scrollArea(@src(), .{ .scroll_info = &result_scroll }, .{ .expand = .both, .background = true, .color_fill = theme.colors.bg_surface }); - defer scroll.deinit(); - var list = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal }); - defer list.deinit(); - var tab_pressed = false; - var pointer_pressed = false; - for (dvui.events()) |event| { - if (event.evt == .key and event.evt.key.code == .tab and event.evt.key.action == .down) tab_pressed = true; - if (event.evt == .mouse and event.evt.mouse.action == .press) pointer_pressed = true; - } - result_keyboard_layout = @import("queue_layout_pure.zig").keyboardLayoutMode(result_keyboard_layout, tab_pressed, pointer_pressed); - const row_height = dvui.themeGet().font_body.textHeight() * 4 + 14; - const range = if (result_keyboard_layout) search_view.Range{ .start = 0, .end = view_cache.count } else search_view.visibleRange(view_cache.count, result_scroll.viewport.y, result_scroll.viewport.h, row_height); - if (range.start > 0) { - var spacer = dvui.box(@src(), .{}, .{ .id_extra = 91400, .min_size_content = .{ .w = 1, .h = row_height * @as(f32, @floatFromInt(range.start)) } }); - spacer.deinit(); - } var pending: ?ResultAction = null; - for (range.start..range.end) |position| { - const idx = view_cache.order[position]; - renderCompactRow(idx, &view_cache.rows.?[idx], &pending); - } - if (range.end < view_cache.count) { - var spacer = dvui.box(@src(), .{}, .{ .id_extra = 91401, .min_size_content = .{ .w = 1, .h = row_height * @as(f32, @floatFromInt(view_cache.count - range.end)) } }); - spacer.deinit(); - } + renderContentGallery(&pending); if (pending) |action| { // The selected row is owned by this frame's immutable snapshot. const item = &view_cache.rows.?[action.idx]; @@ -1673,6 +1664,378 @@ fn renderUniversalResults() void { } } +const search_content = @import("search_content_pure.zig"); +const gallery_layout = @import("../ui/search_gallery_pure.zig"); +var content_projection: search_content.Projection = .{}; +var gallery_selection: u64 = 0; +var gallery_selected_manually = false; +var gallery_generation: u32 = 0; +var gallery_show_sources = false; +var gallery_release_limit: usize = 12; +const GalleryCover = struct { identity: u64 = 0, used: u64 = 0, slot: components.CoverSlot = .{} }; +// Stable addresses: poster workers publish into these slots. Never move or +// recycle a slot while its worker is fetching. +var gallery_covers: [196]GalleryCover = @splat(.{}); +var gallery_cover_clock: u64 = 0; +var gallery_backdrop: components.CoverSlot = .{}; + +fn galleryCover(identity: u64) ?*components.CoverSlot { + gallery_cover_clock +%= 1; + var oldest: ?usize = null; + for (&gallery_covers, 0..) |*entry, index| { + if (entry.identity == identity) { + entry.used = gallery_cover_clock; + return &entry.slot; + } + if (!entry.slot.fetching and (oldest == null or entry.used < gallery_covers[oldest.?].used)) oldest = index; + } + if (oldest) |index| { + const entry = &gallery_covers[index]; + entry.slot.reset(); + entry.identity = identity; + entry.used = gallery_cover_clock; + return &entry.slot; + } + return null; +} + +/// Called after the owned-worker barrier, while the graphics context exists. +pub fn deinitGallery() void { + for (&gallery_covers) |*entry| entry.slot.reset(); + gallery_backdrop.reset(); + @import("search_preview.zig").deinit(); +} + +pub fn updateGalleryRoute() void { + if (state.app.router.current != .search) @import("search_preview.zig").stop(); +} + +/// Native test harness only: immutable provider fixtures, no resolver network. +pub fn setGalleryFixtureForTest(rows: []const @import("resolver.zig").ResolvedItem, query: []const u8) void { + if (!@import("builtin").is_test) @compileError("gallery fixture API is test-only"); + const resolver = @import("resolver.zig"); + if (view_cache.rows == null) view_cache.rows = @import("../core/alloc.zig").allocator.alloc(resolver.ResolvedItem, resolver.MAX_RESULTS) catch return; + view_cache.loaded = @min(rows.len, resolver.MAX_RESULTS); + view_cache.count = view_cache.loaded; + @memcpy(view_cache.rows.?[0..view_cache.loaded], rows[0..view_cache.loaded]); + for (rows[0..view_cache.loaded], 0..) |*item, index| { + view_cache.projected[index] = resolver.searchView(item); + view_cache.order[index] = index; + } + view_cache.query_len = @min(query.len, view_cache.query.len); + @memcpy(view_cache.query[0..view_cache.query_len], query[0..view_cache.query_len]); + view_cache.loading = false; + view_cache.nsfw = false; + view_cache.revision +%= 1; + view_cache.generation +%= 1; + view_filters = .{}; + view_sort = .relevance; + view_dirty = false; + state.app.router.current = .search; + result_scroll.viewport.y = 0; +} +pub fn setGalleryTextureForTest(identity: u64, url: []const u8, texture: dvui.Texture, width: u32, height: u32) void { + if (!@import("builtin").is_test) @compileError("gallery texture API is test-only"); + const slot = galleryCover(identity) orelse return; + components.syncCoverSlot(slot, url); + slot.tex = texture; + slot.w = width; + slot.h = height; + slot.attempted = true; +} +pub fn setGalleryContentFilterForTest(filter: search_view.ContentKind) void { + if (!@import("builtin").is_test) @compileError("gallery filter API is test-only"); + view_filters.content = filter; + view_dirty = false; +} +pub fn renderGalleryForTest() void { + if (!@import("builtin").is_test) @compileError("gallery renderer API is test-only"); + renderUniversalResults(); +} + +fn galleryCategoryLabel(category: search_content.Category) []const u8 { + return switch (category) { + .movies => "Movies", + .shows => "Shows", + .anime => "Anime", + .comics => "Comics & manga", + .books => "Books & audiobooks", + .music => "Music", + .podcasts => "Podcasts", + .radio => "Radio", + .live_tv => "Live TV", + .visual_novels => "Visual novels", + .videos => "Videos", + .releases => "Other releases", + }; +} +fn galleryCategoryFilter(category: search_content.Category) search_view.ContentKind { + return switch (category) { + .movies => .movies, + .shows => .shows, + .anime => .anime, + .comics => .comics, + .books => .books, + .music => .music, + .podcasts => .podcasts, + .radio => .radio, + .live_tv => .live_tv, + .visual_novels => .visual_novels, + .videos, .releases => .video, + }; +} +fn galleryShape(category: search_content.Category) gallery_layout.Shape { + return switch (category) { + .movies, .shows, .anime, .comics, .books, .visual_novels => .portrait, + .music, .podcasts, .radio => .square, + .videos, .live_tv, .releases => .landscape, + }; +} +fn galleryIcon(category: search_content.Category) []const u8 { + return switch (category) { + .books, .comics, .visual_novels => icons.tvg.lucide.book, + .music, .podcasts => icons.tvg.lucide.headphones, + .radio => icons.tvg.lucide.radio, + .releases => icons.tvg.lucide.magnet, + else => icons.tvg.lucide.film, + }; +} +fn galleryGroupVisible(group: *const search_content.Group) bool { + for (group.indexes[0..group.count]) |index| if (search_view.matches(view_cache.projected[index], view_filters)) return true; + return false; +} + +fn ensureContentProjection() void { + const rows = view_cache.rows.?[0..view_cache.loaded]; + var indexes: [@import("resolver.zig").MAX_RESULTS]usize = undefined; + var count: usize = 0; + for (rows, 0..) |item, index| { + if (item.name_len == 0 or (view_cache.nsfw and item.is_nsfw)) continue; + indexes[count] = index; + count += 1; + } + // Projection is rebuilt only when the immutable row revision/filter changes. + const ProjectionCache = struct { + var revision: u64 = std.math.maxInt(u64); + var generation: u32 = 0; + var nsfw = false; + }; + if (ProjectionCache.revision != view_cache.revision or ProjectionCache.generation != view_cache.generation or ProjectionCache.nsfw != view_cache.nsfw) { + search_content.projectInto(rows, indexes[0..count], &content_projection); + ProjectionCache.revision = view_cache.revision; + ProjectionCache.generation = view_cache.generation; + ProjectionCache.nsfw = view_cache.nsfw; + } +} + +fn renderContentGallery(pending: *?ResultAction) void { + const rows = view_cache.rows.?[0..view_cache.loaded]; + ensureContentProjection(); + if (gallery_generation != view_cache.generation) { + gallery_selection = 0; + gallery_selected_manually = false; + gallery_show_sources = false; + gallery_release_limit = 12; + gallery_generation = view_cache.generation; + @import("search_preview.zig").stop(); + } + var visible: [search_content.MAX_ROWS]usize = undefined; + var identities: [search_content.MAX_ROWS]u64 = undefined; + var visible_count: usize = 0; + for (content_projection.groups[0..content_projection.count], 0..) |*group, index| { + if (!galleryGroupVisible(group)) continue; + visible[visible_count] = index; + identities[visible_count] = group.identity; + visible_count += 1; + } + std.sort.insertion(usize, visible[0..visible_count], {}, struct { + fn less(_: void, a: usize, b: usize) bool { + return search_view.lessThan(view_sort, view_cache.projected[content_projection.groups[a].representative], view_cache.projected[content_projection.groups[b].representative]); + } + }.less); + for (visible[0..visible_count], 0..) |index, position| identities[position] = content_projection.groups[index].identity; + const selected_position = gallery_layout.retainSelection(identities[0..visible_count], gallery_selection) orelse { + @import("search_preview.zig").stop(); + return; + }; + const selected = &content_projection.groups[visible[selected_position]]; + gallery_selection = selected.identity; + var scroll = dvui.scrollArea(@src(), .{ .scroll_info = &result_scroll }, .{ .expand = .both, .background = true, .color_fill = theme.colors.bg_app }); + defer scroll.deinit(); + var content = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal, .padding = dvui.Rect.all(theme.spacing.md) }); + defer content.deinit(); + const width = content.data().contentRect().w; + // A raw release has no dependable feature artwork. It stays in the compact + // source comparison until a matching catalog or library record arrives. + const featured_item = &rows[selected.representative]; + if (selected.category != .releases and gallery_layout.shouldFeature(view_cache.query[0..view_cache.query_len], featured_item.name[0..featured_item.name_len], featured_item.poster_url_len > 0 or featured_item.backdrop_url_len > 0, gallery_selected_manually)) renderGalleryHero(selected, width, pending) else @import("search_preview.zig").stop(); + if (gallery_show_sources) { + renderResultGroupHeading("Available sources", 94500); + var source_indexes: [search_content.MAX_ROWS]u16 = undefined; + @memcpy(source_indexes[0..selected.count], selected.indexes[0..selected.count]); + std.sort.insertion(u16, source_indexes[0..selected.count], {}, struct { + fn less(_: void, a: u16, b: u16) bool { + return search_view.lessThan(view_sort, view_cache.projected[a], view_cache.projected[b]); + } + }.less); + for (source_indexes[0..selected.count]) |index| { + if (rows[index].source == .tmdb and selected.count > 1) continue; + renderCompactRow(index, &rows[index], pending); + } + } + inline for (std.meta.tags(search_content.Category)) |category| { + var category_count: usize = 0; + for (visible[0..visible_count]) |index| if (content_projection.groups[index].category == category) { + category_count += 1; + }; + if (category_count > 0) { + var heading = dvui.box(@src(), .{ .dir = .horizontal }, .{ .id_extra = 94600 + @as(usize, @intFromEnum(category)), .expand = .horizontal, .padding = .{ .x = 0, .y = 16, .w = 0, .h = 8 } }); + _ = dvui.label(@src(), "{s}", .{galleryCategoryLabel(category)}, .{ .font = dvui.themeGet().font_heading, .color_text = theme.colors.text_primary, .gravity_y = 0.5 }); + var tally: [32]u8 = undefined; + const tally_text = std.fmt.bufPrint(&tally, "{d}", .{category_count}) catch ""; + _ = dvui.label(@src(), "{s}", .{tally_text}, .{ .color_text = theme.colors.text_tertiary, .gravity_y = 0.5, .margin = dvui.Rect.all(6) }); + var spacer = dvui.box(@src(), .{}, .{ .expand = .horizontal }); + spacer.deinit(); + if (category != .releases and view_filters.content == .all and components.actionButton(@src(), "See all", .secondary, 94650 + @as(usize, @intFromEnum(category)))) { + view_filters.content = galleryCategoryFilter(category); + view_dirty = true; + result_scroll.viewport.y = 0; + } + heading.deinit(); + if (category == .releases) { + var shown: usize = 0; + for (visible[0..visible_count]) |index| { + const group = &content_projection.groups[index]; + if (group.category != category or shown >= gallery_release_limit) continue; + renderCompactRow(group.representative, &rows[group.representative], pending); + shown += 1; + } + if (shown < category_count and components.actionButton(@src(), "Show more releases", .secondary, 94700)) gallery_release_limit += 24; + } else { + const art = gallery_layout.cardSize(galleryShape(category), width); + if (view_filters.content != .all) { + var grid = dvui.flexbox(@src(), .{ .justify_content = .start }, .{ .id_extra = 94950 + @as(usize, @intFromEnum(category)), .expand = .horizontal }); + for (visible[0..visible_count]) |index| { + const group = &content_projection.groups[index]; + if (group.category == category) renderGalleryCard(group, if (rows[group.representative].source == .audiobooks) gallery_layout.cardSize(.square, width) else art); + } + grid.deinit(); + } else { + const rail_height = gallery_layout.shelfHeight(art.h, dvui.themeGet().font_body.textHeight()); + var rail = dvui.scrollArea(@src(), .{ .horizontal = .auto, .horizontal_bar = .auto_overlay, .vertical = .none }, .{ .id_extra = 94800 + @as(usize, @intFromEnum(category)), .expand = .horizontal, .min_size_content = .{ .w = 1, .h = rail_height }, .max_size_content = .{ .w = std.math.floatMax(f32), .h = rail_height }, .background = false, .color_fill = theme.transparent }); + var strip = dvui.box(@src(), .{ .dir = .horizontal }, .{ .id_extra = 94900 + @as(usize, @intFromEnum(category)) }); + for (visible[0..visible_count]) |index| { + const group = &content_projection.groups[index]; + if (group.category == category) renderGalleryCard(group, if (rows[group.representative].source == .audiobooks) gallery_layout.cardSize(.square, width) else art); + } + strip.deinit(); + rail.deinit(); + } + } + } + } +} + +fn renderGalleryCard(group: *const search_content.Group, size: gallery_layout.Size) void { + const item = &view_cache.rows.?[group.representative]; + const key: usize = @truncate(group.identity); + var card = dvui.box(@src(), .{ .dir = .vertical }, .{ .id_extra = key, .min_size_content = .{ .w = size.w, .h = 0 }, .max_size_content = .{ .w = size.w, .h = std.math.floatMax(f32) }, .margin = .{ .x = 0, .y = 0, .w = 12, .h = if (view_filters.content == .all) 0 else 16 }, .background = false, .color_fill = theme.transparent }); + defer card.deinit(); + var art = dvui.overlay(@src(), .{ .id_extra = key +% 1, .min_size_content = .{ .w = size.w, .h = size.h }, .max_size_content = .{ .w = size.w, .h = size.h }, .background = true, .color_fill = theme.colors.bg_surface, .corner_radius = dvui.Rect.all(theme.radius.md), .border = dvui.Rect.all(if (gallery_selection == group.identity) 2 else 0), .color_border = theme.colors.accent }); + if (art.data().visible()) { + if (galleryCover(group.identity)) |slot| components.galleryCoverArt(@src(), key +% 2, slot, item.poster_url[0..item.poster_url_len], galleryIcon(group.category), theme.radius.md, size.w, size.h); + } + var hovered = false; + if (dvui.clicked(art.data(), .{ .hovered = &hovered })) { + gallery_selection = group.identity; + gallery_selected_manually = true; + gallery_show_sources = false; + result_scroll.viewport.y = 0; + } + art.deinit(); + const body = dvui.themeGet().font_body; + var title: [520]u8 = undefined; + _ = dvui.label(@src(), "{s}", .{searchRowTitle(item.name[0..item.name_len], body, size.w, &title)}, .{ .id_extra = key +% 3, .font = body, .color_text = theme.colors.text_primary, .min_size_content = .{ .w = size.w, .h = body.textHeight() * 2 }, .max_size_content = .{ .w = size.w, .h = body.textHeight() * 2 }, .margin = .{ .x = 0, .y = 7, .w = 0, .h = 0 } }); + var metadata: [128]u8 = undefined; + const line = if (group.offer_count > 0) std.fmt.bufPrint(&metadata, "{d} source{s}", .{ group.offer_count, if (group.offer_count == 1) "" else "s" }) catch "" else if (item.author_len > 0) item.author[0..item.author_len] else item.detail[0..item.detail_len]; + searchRowLine(key +% 4, safeUtf8(line), body.withSize(theme.font_size.small), size.w, theme.colors.text_secondary); + if (components.actionButton(@src(), "Details", .secondary, key +% 5)) { + gallery_selection = group.identity; + gallery_selected_manually = true; + gallery_show_sources = false; + result_scroll.viewport.y = 0; + } + components.tipId(@src(), card.data().*, safeUtf8(item.name[0..item.name_len]), key); + renderSearchResultContext(key, card.data().borderRectScale().r, item); +} + +fn renderGalleryHero(group: *const search_content.Group, width: f32, pending: *?ResultAction) void { + const item = &view_cache.rows.?[group.representative]; + const key: usize = @truncate(group.identity); + var hero = dvui.box(@src(), .{ .dir = if (gallery_layout.stackedHero(width)) .vertical else .horizontal }, .{ .id_extra = 95000, .expand = .horizontal, .background = true, .color_fill = theme.colors.bg_surface, .corner_radius = dvui.Rect.all(theme.radius.lg), .padding = dvui.Rect.all(12), .margin = .{ .x = 0, .y = 0, .w = 0, .h = 12 } }); + defer hero.deinit(); + const preview = @import("search_preview.zig"); + if (item.source == .tmdb and item.catalog_id > 0) { + preview.select(group.identity, if (std.mem.eql(u8, item.catalog_kind[0..item.catalog_kind_len], "tv")) .tv else .movie, item.catalog_id); + } else preview.stop(); + preview.poll(); + const preview_state = preview.snapshot(); + const has_preview_backdrop = preview_state.identity == group.identity and preview_state.metadata.backdrop_url_len > 0; + const has_backdrop = item.backdrop_url_len > 0 or has_preview_backdrop; + const preview_active = preview_state.status == .playing or preview_state.status == .starting; + const art_size = gallery_layout.heroArtSize(width, has_backdrop or preview_active); + var art = dvui.overlay(@src(), .{ .id_extra = key +% 8, .min_size_content = .{ .w = art_size.w, .h = art_size.h }, .max_size_content = .{ .w = art_size.w, .h = art_size.h }, .color_fill = theme.colors.bg_elevated, .background = true, .corner_radius = dvui.Rect.all(theme.radius.md), .margin = .{ .x = 0, .y = 0, .w = 20, .h = 0 } }); + if (preview_active) { + if (art.data().visible()) preview.render(@src(), key +% 9, art_size.w, art_size.h); + } else if (has_backdrop) { + const backdrop = if (item.backdrop_url_len > 0) item.backdrop_url[0..item.backdrop_url_len] else preview_state.metadata.backdrop_url[0..preview_state.metadata.backdrop_url_len]; + components.galleryCoverArt(@src(), key +% 9, &gallery_backdrop, backdrop, galleryIcon(group.category), theme.radius.md, art_size.w, art_size.h); + } else if (galleryCover(group.identity)) |slot| components.galleryCoverArt(@src(), key +% 9, slot, item.poster_url[0..item.poster_url_len], galleryIcon(group.category), theme.radius.md, art_size.w, art_size.h); + art.deinit(); + var copy = dvui.box(@src(), .{ .dir = .vertical }, .{ .id_extra = 95001, .expand = .horizontal, .gravity_y = gallery_layout.heroCopyGravity(width), .padding = dvui.Rect.all(4) }); + defer copy.deinit(); + _ = dvui.label(@src(), "{s}", .{galleryCategoryLabel(group.category)}, .{ .color_text = theme.colors.accent, .font = dvui.themeGet().font_body.withSize(theme.font_size.small) }); + const copy_width = @max(1, copy.data().contentRect().w); + const heading = dvui.themeGet().font_title.withSize(if (width < 760) 26 else 34); + var title: [520]u8 = undefined; + _ = dvui.label(@src(), "{s}", .{searchRowTitle(item.name[0..item.name_len], heading, copy_width, &title)}, .{ .id_extra = 95002, .color_text = theme.colors.text_primary, .font = heading, .max_size_content = .{ .w = copy_width, .h = heading.textHeight() * 2 }, .margin = .{ .x = 0, .y = 4, .w = 0, .h = 8 } }); + searchRowLine(95003, safeUtf8(item.detail[0..item.detail_len]), dvui.themeGet().font_body.withSize(theme.font_size.small), copy_width, theme.colors.text_secondary); + if (item.summary_len > 0) { + var summary = dvui.textLayout(@src(), .{ .break_lines = true }, .{ .id_extra = 95004, .expand = .horizontal, .background = false, .color_fill = theme.transparent, .max_size_content = .{ .w = copy_width, .h = dvui.themeGet().font_body.textHeight() * 4 }, .margin = .{ .x = 0, .y = 10, .w = 0, .h = 12 } }); + summary.addText(safeUtf8(item.summary[0..item.summary_len]), .{ .color_text = theme.colors.text_secondary }); + summary.deinit(); + } + var actions = dvui.flexbox(@src(), .{ .justify_content = .start }, .{ .id_extra = 95005, .expand = .horizontal }); + const television = item.source == .tmdb and std.mem.eql(u8, item.catalog_kind[0..item.catalog_kind_len], "tv"); + if (item.source != .tmdb or television) { + if (components.actionButton(@src(), if (television) "Episodes" else "Open", .primary, 95006)) pending.* = .{ .idx = group.representative }; + } + if (group.offer_count > 0) { + var source_text: [48]u8 = undefined; + const text = std.fmt.bufPrint(&source_text, "Sources ({d})", .{group.offer_count}) catch "Sources"; + if (components.actionButton(@src(), text, .secondary, 95007)) gallery_show_sources = !gallery_show_sources; + } + const store = @import("library_store.zig"); + var identity: [32]u8 = undefined; + const identity_text = std.fmt.bufPrint(&identity, "{x}", .{group.identity}) catch ""; + const saved = store.isFavorite("search", identity_text); + if (components.actionButton(@src(), if (saved) "Saved" else "Save", .secondary, 95008)) { + var saved_link: [288]u8 = undefined; + const reopen = std.fmt.bufPrint(&saved_link, "opal://search/{s}", .{item.name[0..item.name_len]}) catch ""; + store.setFavorite("search", identity_text, !saved, item.name[0..item.name_len], item.poster_url[0..item.poster_url_len], reopen); + state.showToast(if (saved) "Removed from saved content" else "Saved to your library"); + } + if (preview_state.canStart()) { + if (components.actionButton(@src(), "Preview", .secondary, 95009)) _ = preview.start(); + } + if (preview_active) { + if (components.actionButton(@src(), if (preview_state.muted) "Unmute" else "Mute", .secondary, 95010)) preview.setMuted(!preview_state.muted); + if (components.actionButton(@src(), "Close preview", .secondary, 95011)) preview.closePlayback(); + } + actions.deinit(); + if (item.source == .tmdb and preview_state.status != .idle) searchRowLine(95012, preview_state.label(), dvui.themeGet().font_body.withSize(theme.font_size.small), copy_width, theme.colors.text_tertiary); +} + fn showResult(item: *const @import("resolver.zig").ResolvedItem) bool { const resolver = @import("resolver.zig"); if (item.name_len == 0 or (state.app.nsfw_filter_enabled and item.is_nsfw)) return false; @@ -1741,9 +2104,9 @@ fn renderSourceSummary(source_has: std.EnumSet(@import("resolver.zig").SourceBit const entries = [_]Entry{ .{ .name = "Torrents", .src = .torrent, .rss = false, .st = combinedTorrentStatus(), .bit = .torrent }, .{ .name = "Jellyfin", .src = .jellyfin, .rss = false, .st = resolver.status_jf.load(.acquire), .bit = .jellyfin }, - .{ .name = "Anime", .src = .anime, .rss = false, .st = resolver.status_anime.load(.acquire), .bit = .anime }, + .{ .name = "Anime", .src = .anime, .rss = false, .st = resolver.combineSourceStatuses(resolver.status_anime.load(.acquire), resolver.status_anime_catalog.load(.acquire)), .bit = .anime }, .{ .name = "YouTube", .src = .youtube, .rss = false, .st = resolver.status_yt.load(.acquire), .bit = .youtube }, - .{ .name = "Comics", .src = .comics, .rss = false, .st = resolver.status_comics.load(.acquire), .bit = .comics }, + .{ .name = "Comics", .src = .comics, .rss = false, .st = resolver.combineSourceStatuses(resolver.status_comics.load(.acquire), resolver.status_manga_catalog.load(.acquire)), .bit = .comics }, .{ .name = "Streams", .src = .stremio, .rss = false, .st = combinedStreamStatus(), .bit = .stremio }, .{ .name = "RSS", .src = .torrent, .rss = true, .st = resolver.status_rss.load(.acquire), .bit = .rss }, .{ .name = "On-disk", .src = .local, .rss = false, .st = resolver.status_local.load(.acquire), .bit = .local }, @@ -1751,7 +2114,7 @@ fn renderSourceSummary(source_has: std.EnumSet(@import("resolver.zig").SourceBit .{ .name = "Music", .src = .music, .rss = false, .st = resolver.status_music.load(.acquire), .bit = .music }, .{ .name = "Radio", .src = .radio, .rss = false, .st = resolver.status_radio.load(.acquire), .bit = .radio }, .{ .name = "Podcasts", .src = .podcast, .rss = false, .st = resolver.status_podcast.load(.acquire), .bit = .podcast }, - .{ .name = "Novels", .src = .novels, .rss = false, .st = resolver.combineSourceStatuses(resolver.status_novels.load(.acquire), resolver.status_novel_archive.load(.acquire)), .bit = .novels }, + .{ .name = "Novels", .src = .novels, .rss = false, .st = resolver.combineSourceStatuses(resolver.combineSourceStatuses(resolver.status_novels.load(.acquire), resolver.status_novel_archive.load(.acquire)), resolver.status_public_books.load(.acquire)), .bit = .novels }, .{ .name = "Visual novels", .src = .vndb, .rss = false, .st = resolver.status_vndb.load(.acquire), .bit = .vndb }, .{ .name = "Audiobooks", .src = .audiobooks, .rss = false, .st = resolver.status_audiobooks.load(.acquire), .bit = .audiobooks }, .{ .name = "OPDS", .src = .opds, .rss = false, .st = resolver.status_opds.load(.acquire), .bit = .opds }, @@ -1933,7 +2296,7 @@ fn renderCompactRow(idx: usize, item: *const @import("resolver.zig").ResolvedIte }; const body = dvui.themeGet().font_body; const small = body.withSize(theme.font_size.small); - const row_height = body.textHeight() * 4 + 14; + const row_height = body.textHeight() * 3 + 14 + (if (risk.risk != .ok) small.textHeight() else @as(f32, 0)); var row = dvui.box(@src(), .{ .dir = .horizontal }, .{ .id_extra = row_key, .expand = .horizontal, @@ -1965,7 +2328,7 @@ fn renderCompactRow(idx: usize, item: *const @import("resolver.zig").ResolvedIte else std.fmt.bufPrint(&line, "{s}{s}{s}{s}{s}", .{ chip_text, if (meta.len > 0) " · " else "", meta, if (item.detail_len > 0) " · " else "", safeUtf8(item.detail[0..item.detail_len]) }) catch chip_text; searchRowLine(row_key +% 2, metadata, small, text_width, theme.colors.text_secondary); - if (risk.risk != .ok) searchRowLine(row_key +% 3, if (risk.risk == .block) "Scam? · blocked torrent" else "Caution · check torrent details", small, text_width, if (risk.risk == .block) theme.colors.danger else theme.colors.warning) else if (item.summary_len > 0) searchRowLine(row_key +% 3, safeUtf8(item.summary[0..item.summary_len]), small, text_width, theme.colors.text_tertiary); + if (risk.risk != .ok) searchRowLine(row_key +% 3, if (risk.risk == .block) "Scam? · blocked torrent" else "Caution · check torrent details", small, text_width, if (risk.risk == .block) theme.colors.danger else theme.colors.warning); content.deinit(); var actions = dvui.box(@src(), .{ .dir = .horizontal }, .{ .gravity_y = 0.5 }); defer actions.deinit(); diff --git a/src/services/search_content_pure.zig b/src/services/search_content_pure.zig new file mode 100644 index 00000000..cbe30eaa --- /dev/null +++ b/src/services/search_content_pure.zig @@ -0,0 +1,452 @@ +//! Bounded content identities projected from immutable search rows. No UI or I/O. +const std = @import("std"); +pub const MAX_ROWS: usize = 192; +/// Opaque bounded cache scope includes installed endpoint/credential identity. +/// Hashing the query avoids long-prefix overflow and exposes no source URLs. +pub fn cacheIdentity(out: []u8, query: []const u8, mask: u16, fingerprint: u64) ?[]const u8 { + return std.fmt.bufPrint(out, "search:v10:{x}:{x}:{x}", .{ mask, fingerprint, std.hash.Wyhash.hash(0, query) }) catch null; +} +pub const CacheItem = struct { source: []const u8, provider: []const u8 = "", url: []const u8 = "" }; +/// Account-bound rows are fetched afresh each wave. Public metadata/cache art +/// remain useful without replaying another account's opaque item or stream ID. +pub fn cacheEligible(item: CacheItem) bool { + inline for (.{ "jellyfin", "plex", "opds", "audiobooks", "plugin", "local" }) |private| { + if (std.mem.eql(u8, item.source, private)) return false; + } + if (std.mem.eql(u8, item.source, "music") and + !std.mem.eql(u8, item.provider, "jiosaavn") and !std.mem.eql(u8, item.provider, "audius")) return false; + if (std.mem.eql(u8, item.source, "stremio") and + !std.mem.eql(u8, item.provider, "archive") and !std.mem.eql(u8, item.provider, "nasa") and !std.mem.eql(u8, item.provider, "commons")) return false; + // Deep links can contain an embedded HTTP URL. Conservatively avoid user + // authority text even there; the original row stays in the live result set. + if (std.mem.indexOf(u8, item.url, "://")) |scheme| { + const start = scheme + 3; + const end = std.mem.indexOfAnyPos(u8, item.url, start, "/?#|") orelse item.url.len; + if (std.mem.indexOfScalar(u8, item.url[start..end], '@') != null) return false; + } + return true; +} +pub const Category = enum { + movies, + shows, + anime, + comics, + books, + music, + podcasts, + radio, + live_tv, + visual_novels, + videos, + releases, + pub fn label(self: Category) []const u8 { + return switch (self) { + .movies => "Movies", + .shows => "Shows", + .anime => "Anime", + .comics => "Comics", + .books => "Books", + .music => "Music", + .podcasts => "Podcasts", + .radio => "Radio", + .live_tv => "Live TV", + .visual_novels => "Visual novels", + .videos => "Videos", + .releases => "Other releases", + }; + } +}; +pub const Group = struct { + representative: usize = 0, + indexes: [MAX_ROWS]u16 = undefined, + count: usize = 0, + offer_count: usize = 0, + category: Category = .videos, + identity: u64 = 0, + score: u32 = 9999, +}; +pub const Projection = struct { groups: [MAX_ROWS]Group = undefined, count: usize = 0 }; +pub fn category(row: anytype) Category { + const source = @tagName(row.source); + const kind = row.catalog_kind[0..row.catalog_kind_len]; + if (std.mem.eql(u8, source, "torrent")) return .releases; + if (std.mem.eql(u8, source, "anime")) return .anime; + if (std.mem.eql(u8, source, "comics")) return .comics; + if (std.mem.eql(u8, source, "novels") or std.mem.eql(u8, source, "opds") or std.mem.eql(u8, source, "audiobooks")) return .books; + if (std.mem.eql(u8, source, "music")) return .music; + if (std.mem.eql(u8, source, "podcast")) return .podcasts; + if (std.mem.eql(u8, source, "radio")) return .radio; + if (std.mem.eql(u8, source, "livetv")) return .live_tv; + if (std.mem.eql(u8, source, "vndb")) return .visual_novels; + if (std.mem.eql(u8, kind, "movie")) return .movies; + if (std.mem.eql(u8, kind, "tv") or std.mem.eql(u8, kind, "series") or std.mem.eql(u8, kind, "episode")) return .shows; + return .videos; +} +pub fn isRelease(row: anytype) bool { + return std.mem.eql(u8, @tagName(row.source), "torrent"); +} +fn title(row: anytype) []const u8 { + return row.name[0..row.name_len]; +} +fn normalized(text: []const u8, out: []u8) []const u8 { + var n: usize = 0; + var space = false; + for (text) |c| { + if (std.ascii.isAlphanumeric(c) or c >= 128) { + if (space and n > 0 and n < out.len) { + out[n] = ' '; + n += 1; + } + if (n == out.len) break; + out[n] = std.ascii.toLower(c); + n += 1; + space = false; + } else space = true; + } + return out[0..n]; +} +/// Only a title immediately followed by a positive year is eligible. No fuzzy +/// prefix/substring matches, no inference from quality digits or episode names. +pub fn releaseMatches(work_title: []const u8, work_year: u16, release_title: []const u8) bool { + if (work_year < 1800 or work_year > 2199) return false; + var i: usize = 0; + while (i + 4 <= release_title.len) : (i += 1) { + if (i > 0 and std.ascii.isAlphanumeric(release_title[i - 1])) continue; + const year = std.fmt.parseInt(u16, release_title[i..][0..4], 10) catch continue; + if (year != work_year or (i + 4 < release_title.len and std.ascii.isDigit(release_title[i + 4]))) continue; + var a: [256]u8 = undefined; + var b: [256]u8 = undefined; + return std.mem.eql(u8, normalized(work_title, &a), normalized(release_title[0..i], &b)); + } + return false; +} +/// Explicit season tokens establish a show release, never a movie. The caller +/// still requires exactly one matching typed show across all visible catalogs. +pub fn episodeMatches(work_title: []const u8, release_title: []const u8) bool { + var i: usize = 1; + while (i + 3 <= release_title.len) : (i += 1) { + if (std.ascii.toLower(release_title[i]) != 's' or std.ascii.isAlphanumeric(release_title[i - 1])) continue; + if (!std.ascii.isDigit(release_title[i + 1]) or !std.ascii.isDigit(release_title[i + 2])) continue; + const end = i + 3; + if (end < release_title.len and std.ascii.isAlphanumeric(release_title[end])) { + if (std.ascii.toLower(release_title[end]) != 'e' or end + 2 >= release_title.len or !std.ascii.isDigit(release_title[end + 1]) or !std.ascii.isDigit(release_title[end + 2])) continue; + } + var a: [256]u8 = undefined; + var b: [256]u8 = undefined; + return std.mem.eql(u8, normalized(work_title, &a), normalized(release_title[0..i], &b)); + } + return false; +} +fn imdb(row: anytype) []const u8 { + return row.catalog_imdb[0..row.catalog_imdb_len]; +} +fn validImdb(text: []const u8) bool { + if (text.len < 3 or !std.mem.startsWith(u8, text, "tt")) return false; + for (text[2..]) |c| if (!std.ascii.isDigit(c)) return false; + return true; +} +pub fn identity(row: anytype) u64 { + var h = std.hash.Wyhash.init(0); + const typed_imdb = imdb(&row); + if (validImdb(typed_imdb) and row.catalog_kind_len > 0) { + h.update("imdb"); + h.update(row.catalog_kind[0..row.catalog_kind_len]); + h.update(typed_imdb); + const key = h.final(); + return if (key == 0) 1 else key; + } + h.update(@tagName(row.source)); + h.update(row.catalog_kind[0..row.catalog_kind_len]); + if (row.catalog_id > 0) { + h.update(std.mem.asBytes(&row.catalog_id)); + } else { + h.update(row.url[0..row.url_len]); + } + const value = h.final(); + return if (value == 0) 1 else value; +} +fn sameWork(a: anytype, b: anytype) bool { + const same_kind = a.catalog_kind_len > 0 and std.mem.eql(u8, a.catalog_kind[0..a.catalog_kind_len], b.catalog_kind[0..b.catalog_kind_len]); + return same_kind and ((a.catalog_id > 0 and b.catalog_id == a.catalog_id and a.source == b.source) or (validImdb(imdb(&a)) and std.mem.eql(u8, imdb(&a), imdb(&b)))); +} +fn isOffer(row: anytype) bool { + const source = @tagName(row.source); + inline for (.{ "tmdb", "vndb" }) |catalog| if (std.mem.eql(u8, source, catalog)) return false; + if (std.mem.eql(u8, source, "anime")) { + if (@hasField(@TypeOf(row), "provider")) { + const provider = row.provider.name(); + if (std.mem.eql(u8, provider, "jikan") or std.mem.eql(u8, provider, "anilist")) return false; + } + } + if (@hasField(@TypeOf(row), "opds_entry")) { + if (std.mem.eql(u8, source, "opds") and row.opds_entry.is_navigation) return false; + } + return row.url_len > 0; +} +fn hasSameOffer(rows: anytype, group: *const Group, row: anytype) bool { + for (group.indexes[0..group.count]) |index| { + const previous = rows[index]; + if (!isOffer(previous) or previous.source != row.source) continue; + if (std.mem.eql(u8, previous.url[0..previous.url_len], row.url[0..row.url_len])) return true; + } + return false; +} +fn append(group: *Group, index: usize, offer: bool) void { + if (group.count >= MAX_ROWS) return; + group.indexes[group.count] = @intCast(index); + group.count += 1; + if (offer) group.offer_count += 1; +} +/// Caller owns the output (static or heap); indexes refer to the original rows, +/// including after faceting. Ambiguous matches stay independent and actionable. +pub fn projectInto(rows: anytype, indices: []const usize, out: *Projection) void { + out.count = 0; + for (indices) |index| { + if (index >= rows.len or index >= MAX_ROWS or isRelease(rows[index])) continue; + var found: ?usize = null; + for (out.groups[0..out.count], 0..) |group, g| if (sameWork(rows[group.representative], rows[index]) or identity(rows[group.representative]) == identity(rows[index])) { + found = g; + break; + }; + if (found) |g| { + append(&out.groups[g], index, isOffer(rows[index]) and !hasSameOffer(rows, &out.groups[g], rows[index])); + if (std.mem.eql(u8, @tagName(rows[index].source), "tmdb")) { + out.groups[g].representative = index; + out.groups[g].category = category(rows[index]); + } + continue; + } + if (out.count == MAX_ROWS) break; + out.groups[out.count] = .{ .representative = index, .category = category(rows[index]), .identity = identity(rows[index]), .score = 100 - @as(u32, rows[index].match_pct) }; + append(&out.groups[out.count], index, isOffer(rows[index])); + out.count += 1; + } + for (indices) |index| { + if (index >= rows.len or index >= MAX_ROWS or !isRelease(rows[index])) continue; + var match: ?usize = null; + var matches: usize = 0; + for (out.groups[0..out.count], 0..) |group, g| { + const work = rows[group.representative]; + if ((group.category == .movies or group.category == .shows) and (releaseMatches(title(&work), work.year, rows[index].name[0..rows[index].name_len]) or (group.category == .shows and episodeMatches(title(&work), rows[index].name[0..rows[index].name_len])))) { + match = g; + matches += 1; + } + } + // Hidden facets cannot turn a known remake ambiguity into a verified + // association. Evaluate candidate identities against the entire snapshot. + if (matches == 1) { + const linked = rows[out.groups[match.?].representative]; + for (rows) |other| { + if (isRelease(other) or identity(other) == identity(linked)) continue; + const other_cat = category(other); + if ((other_cat == .movies or other_cat == .shows) and + (releaseMatches(other.name[0..other.name_len], other.year, rows[index].name[0..rows[index].name_len]) or + (other_cat == .shows and episodeMatches(other.name[0..other.name_len], rows[index].name[0..rows[index].name_len])))) + { + matches += 1; + break; + } + } + } + if (matches == 1) { + append(&out.groups[match.?], index, true); + continue; + } + if (out.count == MAX_ROWS) break; + out.groups[out.count] = .{ .representative = index, .category = .releases, .identity = identity(rows[index]), .score = 200 + 100 - @as(u32, rows[index].match_pct) }; + append(&out.groups[out.count], index, true); + out.count += 1; + } + std.sort.insertion(Group, out.groups[0..out.count], {}, struct { + fn less(_: void, a: Group, b: Group) bool { + return a.score < b.score or (a.score == b.score and a.identity < b.identity); + } + }.less); +} +/// Fair bounded admission reserves eight rows per represented category. A late +/// category can reclaim an overrepresented category's worst row regardless of +/// swarm score. Within a category relevance still selects its strongest rows. +pub fn evictionIndex(rows: anytype, candidate: anytype) ?usize { + if (rows.len == 0) return null; + var counts: [@typeInfo(Category).@"enum".fields.len]usize = @splat(0); + for (rows) |row| counts[@intFromEnum(category(row))] += 1; + const cat = category(candidate); + const under = counts[@intFromEnum(cat)] < 8; + var selected: ?usize = null; + for (rows, 0..) |row, i| { + const row_cat = category(row); + if (under) { + if (counts[@intFromEnum(row_cat)] <= 8) continue; + } else if (row_cat != cat) continue; + if (selected == null or row.score > rows[selected.?].score) selected = i; + } + if (selected) |i| { + if (under or candidate.score < rows[i].score or (candidate.score == rows[i].score and identity(candidate) < identity(rows[i]))) return i; + } + return null; +} +test "strict work release linking preserves title year and rejects ambiguity" { + try std.testing.expect(releaseMatches("Reacher", 2022, "Reacher.2022.S01.1080p")); + try std.testing.expect(!releaseMatches("Reacher", 2022, "Jack.Reacher.2022.1080p")); + try std.testing.expect(!releaseMatches("Reacher", 2022, "Reacher.S01.1080p")); + try std.testing.expect(!releaseMatches("Reacher", 2022, "Reacher.2012.1080p")); + try std.testing.expect(!releaseMatches("Reacher", 0, "Reacher.2022")); +} +const Fixture = struct { + source: enum { tmdb, torrent, music } = .tmdb, + catalog_kind: [8]u8 = @splat(0), + catalog_kind_len: usize = 0, + catalog_imdb: [16]u8 = @splat(0), + catalog_imdb_len: usize = 0, + catalog_id: i32 = 0, + year: u16 = 0, + name: [256]u8 = @splat(0), + name_len: usize = 0, + url: [64]u8 = @splat(0), + url_len: usize = 0, + score: u32 = 0, + match_pct: u8 = 100, +}; +fn fixture(source: @FieldType(Fixture, "source"), name: []const u8, year: u16, id: i32) Fixture { + var row: Fixture = .{ .source = source, .year = year, .catalog_id = id }; + @memcpy(row.name[0..name.len], name); + row.name_len = name.len; + @memcpy(row.catalog_kind[0..5], "movie"); + row.catalog_kind_len = 5; + @memcpy(row.url[0..name.len], name); + row.url_len = name.len; + return row; +} +test "projection groups releases only with one catalog and retains original filtered indexes" { + const rows = [_]Fixture{ fixture(.tmdb, "Reacher", 2022, 1), fixture(.torrent, "Reacher.2022.1080p", 0, 0), fixture(.torrent, "Reacher.S01", 0, 0) }; + const out = try std.testing.allocator.create(Projection); + defer std.testing.allocator.destroy(out); + projectInto(&rows, &.{ 0, 1, 2 }, out); + try std.testing.expectEqual(@as(usize, 2), out.count); + try std.testing.expectEqual(@as(usize, 1), out.groups[0].offer_count); + try std.testing.expectEqual(@as(u16, 1), out.groups[0].indexes[1]); + const duplicate = [_]Fixture{ rows[0], fixture(.tmdb, "Reacher", 2022, 2), rows[1] }; + projectInto(&duplicate, &.{ 0, 1, 2 }, out); + try std.testing.expectEqual(@as(usize, 3), out.count); + projectInto(&rows, &.{2}, out); + try std.testing.expectEqual(@as(usize, 2), out.groups[0].representative); +} +test "late catalogs reclaim torrent saturation without unbounding results" { + var rows: [16]Fixture = undefined; + for (&rows, 0..) |*row, i| { + row.* = fixture(.torrent, "Release", 0, 0); + row.score = @intCast(i); + } + var catalog = fixture(.tmdb, "Work", 2020, 1); + catalog.score = 45; + try std.testing.expectEqual(@as(?usize, 15), evictionIndex(&rows, catalog)); + for (rows[0..8]) |*row| row.* = catalog; + try std.testing.expect(evictionIndex(&rows, catalog) == null); +} +test "cache identity separates masks configurations and long queries without plaintext" { + var a: [80]u8 = undefined; + var b: [80]u8 = undefined; + const long_query = [_]u8{'q'} ** 255; + const first = cacheIdentity(&a, &long_query, 65535, 1).?; + try std.testing.expect(first.len < 80); + try std.testing.expect(!std.mem.eql(u8, first, cacheIdentity(&b, &long_query, 1, 1).?)); + try std.testing.expect(!std.mem.eql(u8, first, cacheIdentity(&b, &long_query, 65535, 2).?)); + try std.testing.expect(std.mem.indexOf(u8, first, "qqqq") == null); + try std.testing.expect(cacheIdentity(a[0..2], &long_query, 65535, 1) == null); +} +test "persisted search excludes account identities while retaining public catalogs and audio" { + inline for (.{ "jellyfin", "plex", "opds", "audiobooks", "plugin", "local" }) |source| { + try std.testing.expect(!cacheEligible(.{ .source = source, .url = "opaque-item-id" })); + } + try std.testing.expect(!cacheEligible(.{ .source = "music", .provider = "subsonic", .url = "opal://music/subsonic/item" })); + try std.testing.expect(!cacheEligible(.{ .source = "stremio", .url = "https://server.test/private-token/stream" })); + try std.testing.expect(cacheEligible(.{ .source = "music", .provider = "audius", .url = "opal://music/audius/track" })); + try std.testing.expect(cacheEligible(.{ .source = "music", .provider = "jiosaavn", .url = "https://public.test/track" })); + try std.testing.expect(cacheEligible(.{ .source = "stremio", .provider = "nasa", .url = "https://public.test/video" })); + try std.testing.expect(cacheEligible(.{ .source = "tmdb", .url = "opal://catalog/tv/1" })); + try std.testing.expect(!cacheEligible(.{ .source = "novels", .url = "novel|royalroad|https://user:pass@host.test/work|Book" })); +} +test "192 row admission reserves late works without growing the bounded wave" { + const rows = try std.testing.allocator.alloc(Fixture, MAX_ROWS); + defer std.testing.allocator.free(rows); + for (rows, 0..) |*row, i| { + row.* = fixture(.torrent, "Release", 0, 0); + row.score = @intCast(i); + } + var work = fixture(.tmdb, "Work", 2022, 1); + work.score = 500; + for (0..8) |i| { + work.catalog_id = @intCast(i + 1); + const victim = evictionIndex(rows, work).?; + try std.testing.expect(isRelease(rows[victim])); + rows[victim] = work; + } + var ninth = work; + ninth.score = 501; + try std.testing.expect(evictionIndex(rows, ninth) == null); + var song = fixture(.music, "Song", 0, 0); + song.score = 999; + const victim = evictionIndex(rows, song).?; + try std.testing.expect(isRelease(rows[victim])); + var catalogs: usize = 0; + for (rows) |row| if (category(row) == .movies) { + catalogs += 1; + }; + try std.testing.expectEqual(@as(usize, 8), catalogs); + try std.testing.expectEqual(@as(usize, 192), rows.len); +} +test "duplicate metadata catalogs do not count as playable offers" { + const row = fixture(.tmdb, "Work", 2022, 1); + const out = try std.testing.allocator.create(Projection); + defer std.testing.allocator.destroy(out); + projectInto(&[_]Fixture{ row, row }, &.{ 0, 1 }, out); + try std.testing.expectEqual(@as(usize, 1), out.count); + try std.testing.expectEqual(@as(usize, 2), out.groups[0].count); + try std.testing.expectEqual(@as(usize, 0), out.groups[0].offer_count); +} +test "repeated availability does not inflate source count" { + const row = fixture(.music, "Song", 2022, 1); + const out = try std.testing.allocator.create(Projection); + defer std.testing.allocator.destroy(out); + projectInto(&[_]Fixture{ row, row }, &.{ 0, 1 }, out); + try std.testing.expectEqual(@as(usize, 1), out.count); + try std.testing.expectEqual(@as(usize, 1), out.groups[0].offer_count); +} + +test "Reacher episode no year groups only a unique typed show and never movie" { + var show = fixture(.tmdb, "Reacher", 2022, 1); + @memcpy(show.catalog_kind[0..2], "tv"); + show.catalog_kind_len = 2; + const release = fixture(.torrent, "Reacher.S04E06.1080p", 0, 0); + const out = try std.testing.allocator.create(Projection); + defer std.testing.allocator.destroy(out); + projectInto(&[_]Fixture{ show, release }, &.{ 0, 1 }, out); + try std.testing.expectEqual(@as(usize, 1), out.count); + try std.testing.expectEqual(@as(usize, 1), out.groups[0].offer_count); + var remake = show; + remake.catalog_id = 2; + projectInto(&[_]Fixture{ show, remake, release }, &.{ 0, 1, 2 }, out); + try std.testing.expectEqual(@as(usize, 3), out.count); + projectInto(&[_]Fixture{ show, remake, release }, &.{ 0, 2 }, out); + try std.testing.expectEqual(@as(usize, 2), out.count); + projectInto(&[_]Fixture{ fixture(.tmdb, "Reacher", 2022, 1), release }, &.{ 0, 1 }, out); + try std.testing.expectEqual(@as(usize, 2), out.count); + try std.testing.expect(episodeMatches("Reacher", "Reacher.S04.1080p")); + try std.testing.expect(!episodeMatches("Reacher", "Jack.Reacher.S04E06")); +} +test "verified IMDb identity groups cross provider but keeps movie and TV separate" { + var a = fixture(.tmdb, "Work", 2022, 1); + var b = fixture(.music, "Work", 2022, 2); + @memcpy(a.catalog_imdb[0..7], "tt12345"); + a.catalog_imdb_len = 7; + b.catalog_imdb = a.catalog_imdb; + b.catalog_imdb_len = a.catalog_imdb_len; + const out = try std.testing.allocator.create(Projection); + defer std.testing.allocator.destroy(out); + projectInto(&[_]Fixture{ a, b }, &.{ 0, 1 }, out); + try std.testing.expectEqual(@as(usize, 1), out.count); + @memcpy(b.catalog_kind[0..2], "tv"); + b.catalog_kind_len = 2; + projectInto(&[_]Fixture{ a, b }, &.{ 0, 1 }, out); + try std.testing.expectEqual(@as(usize, 2), out.count); +} diff --git a/src/services/search_gallery_native_test.zig b/src/services/search_gallery_native_test.zig new file mode 100644 index 00000000..5d9b9d77 --- /dev/null +++ b/src/services/search_gallery_native_test.zig @@ -0,0 +1,146 @@ +//! Opt-in SDL pixel test. No HTTP, playback, user config, or screen recording. +//! OPAL_GALLERY_ART_DIR may contain tt2543164.jpg, tt1856101.jpg, +//! tt0816692.jpg, tt0245429.jpg. Without it, colored fixture geometry is used. +//! OPAL_GALLERY_CAPTURE_DIR optionally receives rendered PNG artifacts. +const std = @import("std"); +const dvui = @import("dvui"); +const io = @import("../core/io_global.zig"); +const workers = @import("../core/workers.zig"); +const alloc = @import("../core/alloc.zig").allocator; +const search = @import("search.zig"); +const resolver = @import("resolver.zig"); +const content = @import("search_content_pure.zig"); + +fn field(buffer: []u8, length: *usize, value: []const u8) void { + length.* = @min(buffer.len, value.len); + @memcpy(buffer[0..length.*], value[0..length.*]); +} + +fn fixtureRows() ![]resolver.ResolvedItem { + const rows = try alloc.alloc(resolver.ResolvedItem, 14); + const catalog_ids = [_]i32{ 329865, 335984, 157336, 129 }; + const titles = [_][]const u8{ "Arrival", "Blade Runner 2049", "Interstellar", "Spirited Away", "Fixture album", "Fixture podcast" }; + for (rows, 0..) |*row, index| { + row.* = .{}; + row.source = if (index < 12) .tmdb else if (index == 12) .music else .podcast; + row.catalog_id = if (index < 4) catalog_ids[index] else if (index < 12) @intCast(10000 + index) else 0; + field(&row.catalog_kind, &row.catalog_kind_len, "movie"); + var fixture_title: [64]u8 = undefined; + const title = if (index < 4) titles[index] else if (index < 12) try std.fmt.bufPrint(&fixture_title, "Offline movie fixture {d}", .{index}) else titles[index - 8]; + field(&row.name, &row.name_len, title); + field(&row.summary, &row.summary_len, "Explicit offline layout fixture: source comparison, artwork, and contextual actions. No provider availability or playback is asserted."); + field(&row.poster_url, &row.poster_url_len, "fixture://offline-art"); + row.year = 2016; + row.score = @intCast(index); + } + return rows; +} + +fn fixtureTexture(index: usize) !dvui.Texture { + if (io.getenv("OPAL_GALLERY_ART_DIR")) |directory| { + const names = [_][]const u8{ "tt2543164.jpg", "tt1856101.jpg", "tt0816692.jpg", "tt0245429.jpg" }; + const path = try std.fs.path.join(alloc, &.{ directory, names[index % names.len] }); + defer alloc.free(path); + const bytes = try io.cwdReadFileAlloc(path, alloc, 4 * 1024 * 1024); + defer alloc.free(bytes); + return try dvui.Texture.fromImageFile(path, bytes, .linear); + } + var pixels: [64 * 96 * 4]u8 = undefined; + for (0..64 * 96) |pixel| { + const y = pixel / 64; + pixels[pixel * 4] = @intCast(35 + index * 20); + pixels[pixel * 4 + 1] = @intCast(50 + y); + pixels[pixel * 4 + 2] = @intCast(130 + index * 12); + pixels[pixel * 4 + 3] = 255; + } + return try dvui.Texture.fromImageSource(.{ .pixels = .{ .rgba = &pixels, .width = 64, .height = 96, .interpolation = .linear } }); +} + +fn renderSize(width: u32, height: u32, rows: []const resolver.ResolvedItem, movies_only: bool) !void { + var backend = try dvui.backend.initWindow(.{ .io = io.io(), .allocator = alloc, .size = .{ .w = @floatFromInt(width), .h = @floatFromInt(height) }, .vsync = false, .title = "Opal offline gallery pixel regression", .hidden = true }); + defer backend.deinit(); + var window = try dvui.Window.init(@src(), alloc, backend.backend(), .{}); + defer window.deinit(); + @import("../ui/theme.zig").markUiThread(); + search.setGalleryFixtureForTest(rows, "Arrival"); + if (movies_only) search.setGalleryContentFilterForTest(.movies); + // Parent-owned workers must finish before their GPU storage is destroyed. + defer workers.beginShutdownAndDrain(800); + const projection = try alloc.create(content.Projection); + defer alloc.destroy(projection); + const indices = [_]usize{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13 }; + content.projectInto(rows, &indices, projection); + for (0..if (movies_only) @as(usize, 6) else 4) |frame| { + try window.begin(@as(i128, @intCast(frame + 1)) * 16_666_667); + errdefer { + workers.beginShutdownAndDrain(800); + search.shutdown(); + search.deinitGallery(); + _ = window.end(.{}) catch null; + } + if (frame == 0) { + for (projection.groups[0..projection.count]) |group| { + const texture = try fixtureTexture(group.representative); + search.setGalleryTextureForTest(group.identity, rows[group.representative].poster_url[0..rows[group.representative].poster_url_len], texture, texture.width, texture.height); + } + } + if (movies_only and frame == 4) { + _ = try window.addEventMouseMotion(.{ .pt = .{ .x = @as(f32, @floatFromInt(width)), .y = @as(f32, @floatFromInt(height)) } }); + _ = try window.addEventMouseWheel(-600, .vertical); + } + @import("../ui/theme.zig").setTheme(); + const rect = dvui.windowRectPixels(); + try std.testing.expect(rect.w >= @as(f32, @floatFromInt(width))); + try std.testing.expect(rect.h >= @as(f32, @floatFromInt(height))); + var picture = dvui.Picture.start(rect) orelse return error.CaptureUnavailable; + var recording = true; + var picture_alive = true; + errdefer if (picture_alive) { + if (recording) picture.stop(); + picture.deinit(); + }; + search.renderGalleryForTest(); + window.endRendering(.{}); + picture.stop(); + recording = false; + if (frame == 3 or (movies_only and frame == 5)) { + var png: std.Io.Writer.Allocating = try .initCapacity(alloc, 128 * 1024); + defer png.deinit(); + try picture.png(&png.writer); + const encoded = png.written(); + try std.testing.expect(encoded.len > 1000); + try std.testing.expectEqualSlices(u8, "\x89PNG\r\n\x1a\n", encoded[0..8]); + // Assert actual GPU capture bounds, including Retina/high-DPI scale. + try std.testing.expectEqual(@as(u32, @intFromFloat(rect.w)), std.mem.readInt(u32, encoded[16..20], .big)); + try std.testing.expectEqual(@as(u32, @intFromFloat(rect.h)), std.mem.readInt(u32, encoded[20..24], .big)); + if (io.getenv("OPAL_GALLERY_CAPTURE_DIR")) |directory| { + try std.Io.Dir.cwd().createDirPath(io.io(), directory); + const name = try std.fmt.allocPrint(alloc, "gallery-{d}x{d}{s}.png", .{ width, height, if (movies_only and frame == 5) "-movies-scrolled" else if (movies_only) "-movies" else "" }); + defer alloc.free(name); + const path = try std.fs.path.join(alloc, &.{ directory, name }); + defer alloc.free(path); + try io.cwdWriteFile(.{ .sub_path = path, .data = encoded }); + } + } + picture.deinit(); + picture_alive = false; + if (frame == (if (movies_only) @as(usize, 5) else 3)) { + workers.beginShutdownAndDrain(800); + search.shutdown(); + search.deinitGallery(); + } + _ = try window.end(.{}); + } +} + +test "Native Search gallery offline SDL pixel capture" { + const rows = try fixtureRows(); + defer alloc.free(rows); + for ([_][2]u32{ .{ 1360, 1000 }, .{ 640, 800 } }) |size| { + for ([_]bool{ false, true }) |movies_only| { + workers.init(); + try renderSize(size[0], size[1], rows, movies_only); + workers.finishShutdown(); + } + } +} diff --git a/src/services/search_preview.zig b/src/services/search_preview.zig new file mode 100644 index 00000000..e26a02df --- /dev/null +++ b/src/services/search_preview.zig @@ -0,0 +1,382 @@ +//! One explicit, muted-by-default trailer player, isolated from playback/history. +//! select/start/poll/render/stop/deinit run on the UI thread. snapshot is safe +//! for other readers. Stop on query, selection, route, and overlay close; deinit +//! after the process worker barrier. Only verified TMDB records reach libmpv. +const std = @import("std"); +const dvui = @import("dvui"); +const c = @import("../core/c.zig").mpv; +const sdl = @import("../core/c.zig").sdl; +const state = @import("../core/state.zig"); +const workers = @import("../core/workers.zig"); +const allocator = @import("../core/alloc.zig").allocator; +const sync = @import("../core/sync.zig"); +const pure = @import("search_preview_pure.zig"); +pub const Kind = pure.Kind; +pub const Status = pure.Status; +pub const Failure = pure.Failure; +pub const Metadata = pure.Metadata; +pub const Lookup = struct { + metadata: Metadata = .{}, + status: Status = .failed, + failure: Failure = .transport, +}; +pub const Credentials = struct { + key: [256]u8 = @splat(0), + key_len: usize = 0, +}; +pub const Snapshot = struct { + identity: u64 = 0, + generation: u64 = 0, + status: Status = .idle, + failure: Failure = .none, + metadata: pure.Metadata = .{}, + muted: bool = true, + pub fn canStart(self: Snapshot) bool { + return (self.status == .ready or self.status == .ended or (self.status == .failed and self.failure == .playback)) and self.metadata.trailer_url_len > 0; + } + pub fn label(self: Snapshot) []const u8 { + return switch (self.status) { + .idle => "No trailer selected", + .loading => "Checking official trailers…", + .ready => "Official trailer", + .starting => "Loading trailer…", + .playing => "Trailer preview", + .ended => "Trailer ended", + .unavailable => if (self.failure == .no_credentials) "Add a TMDB key to enable trailers" else "No official trailer available", + .failed => if (self.failure == .playback) "Trailer could not play — open its verified link" else "Trailer metadata could not load", + }; + } +}; + +const Job = struct { + generation: u64, + kind: Kind, + id: i32, + key: [512]u8 = @splat(0), + key_len: usize = 0, +}; +var mutex: sync.Mutex = .{}; +var lifecycle: pure.Lifecycle = .{}; +var current: Snapshot = .{}; +var selected_kind: Kind = .movie; +var selected_id: i32 = 0; +var pending: ?Job = null; +var worker_busy: bool = false; // mutex; at most one fetch, latest pending coalesces. +var credentials: Credentials = .{}; // UI/config owner publishes; API readers copy. +var mpv: ?*c.mpv_handle = null; // UI only, never registered in state.app.players. +var renderer: ?*c.mpv_render_context = null; +var pixels: ?[]u8 = null; +var texture: ?dvui.Texture = null; +var frame_ready = false; +const frame_width: u32 = 640; +const frame_height: u32 = 360; + +pub fn snapshot() Snapshot { + mutex.lock(); + defer mutex.unlock(); + return current; +} + +/// Called by the credential owner (UI after config is ready; headless startup). +/// A reader-only lock around raw settings would not protect concurrent writers. +pub fn publishCredentials(key: []const u8) void { + mutex.lock(); + defer mutex.unlock(); + credentials = .{}; + if (key.len > credentials.key.len) return; + @memcpy(credentials.key[0..key.len], key); + credentials.key_len = key.len; +} + +pub fn copyCredentials() Credentials { + mutex.lock(); + defer mutex.unlock(); + return credentials; +} + +/// Selecting fetches metadata only. No playback or audio begins until start(). +pub fn select(identity: u64, kind: Kind, id: i32) void { + // This method is UI-only, so the editable credential is copied by its owner. + const key_len = @min(state.app.tmdb.api_key_len, state.app.tmdb.api_key.len); + publishCredentials(state.app.tmdb.api_key[0..key_len]); + mutex.lock(); + const unchanged = lifecycle.selected and lifecycle.identity == identity and selected_kind == kind and selected_id == id; + mutex.unlock(); + if (unchanged) return; + destroyPlayer(); + mutex.lock(); + const generation = lifecycle.select(identity); + selected_kind = kind; + selected_id = id; + current = .{ .identity = identity, .generation = generation, .status = .loading }; + pending = null; + if (id <= 0) { + current.status = .unavailable; + current.failure = .no_official_video; + } else if (credentials.key_len == 0) { + current.status = .unavailable; + current.failure = .no_credentials; + } else { + var job = Job{ .generation = generation, .kind = kind, .id = id, .key_len = credentials.key_len }; + @memcpy(job.key[0..job.key_len], credentials.key[0..job.key_len]); + pending = job; + } + mutex.unlock(); + launchPending(); +} + +fn launchPending() void { + mutex.lock(); + defer mutex.unlock(); + if (worker_busy or pending == null or workers.isQuitting()) return; + const job = pending.?; + pending = null; + worker_busy = true; + workers.spawn(fetchMetadata, .{job}) catch { + worker_busy = false; + if (lifecycle.accepts(job.generation)) { + current.status = .failed; + current.failure = .transport; + } + }; +} + +fn fetchMetadata(job: Job) void { + var reply: Lookup = .{}; + defer { + mutex.lock(); + worker_busy = false; + if (!workers.isQuitting() and lifecycle.accepts(job.generation)) { + current.metadata = reply.metadata; + current.failure = reply.failure; + current.status = reply.status; + } + mutex.unlock(); + state.wakeUi(); + } + reply = lookupMetadata(job.kind, job.id, job.key[0..job.key_len]); +} + +/// Independent bounded lookup for API workers. The caller owns/snapshots its +/// key; neither selected native work nor preview playback is touched here. +pub fn lookupMetadata(kind: Kind, id: i32, key: []const u8) Lookup { + if (id <= 0) return .{ .failure = .invalid_response }; + if (key.len == 0 or key.len > 256) return .{ .status = .unavailable, .failure = .no_credentials }; + for (key) |ch| if (ch <= 32 or ch == 127) return .{ .status = .unavailable, .failure = .no_credentials }; + if (workers.isQuitting()) return .{}; + var path_buf: [96]u8 = undefined; + const path = std.fmt.bufPrint(&path_buf, "/3/{s}/{d}?append_to_response=videos", .{ @tagName(kind), id }) catch return .{}; + const body = @import("tmdb_api.zig").tmdbPreviewApiOwned(path, key) orelse return .{}; + defer allocator.free(body); + const meta = pure.parseForId(allocator, body, id) catch return .{ .failure = .invalid_response }; + return .{ .metadata = meta, .status = if (meta.trailer_url_len > 0) .ready else .unavailable, .failure = if (meta.trailer_url_len > 0) .none else .no_official_video }; +} + +fn failPlayback() void { + destroyPlayer(); + mutex.lock(); + current.status = .failed; + current.failure = .playback; + mutex.unlock(); +} + +/// Explicit activation only. A separate libmpv core has no app scripts, +/// history, playlist, resume state, browser cookies, or persistent watch-later. +pub fn start() bool { + const snap = snapshot(); + if (!snap.canStart() or workers.isQuitting()) return false; + destroyPlayer(); + const handle = c.mpv_create() orelse { + failPlayback(); + return false; + }; + mpv = handle; + const opts = [_]struct { name: [*:0]const u8, value: [*:0]const u8 }{ + .{ .name = "config", .value = "no" }, + .{ .name = "terminal", .value = "no" }, + .{ .name = "load-scripts", .value = "no" }, + .{ .name = "audio-file-auto", .value = "no" }, + .{ .name = "sub-auto", .value = "no" }, + .{ .name = "vo", .value = if (@import("build_options").headless) "null" else "libmpv" }, + .{ .name = "mute", .value = "yes" }, + .{ .name = "volume", .value = "50" }, + .{ .name = "loop-file", .value = "no" }, + .{ .name = "keep-open", .value = "yes" }, + .{ .name = "idle", .value = "yes" }, + .{ .name = "save-position-on-quit", .value = "no" }, + .{ .name = "resume-playback", .value = "no" }, + .{ .name = "input-default-bindings", .value = "no" }, + .{ .name = "input-vo-keyboard", .value = "no" }, + .{ .name = "network-timeout", .value = "8" }, + .{ .name = "video-timing-offset", .value = "0" }, + .{ .name = "ytdl-format", .value = "best[height<=480]/bestvideo[height<=480]+bestaudio/best" }, + .{ .name = "ytdl-raw-options", .value = "ignore-config=,no-playlist=" }, + }; + for (opts) |opt| if (c.mpv_set_option_string(handle, opt.name, opt.value) < 0) { + failPlayback(); + return false; + }; + var script_buf: [1024]u8 = undefined; + if (@import("../player/ytdl_opts_pure.zig").buildScriptOpts(.{ .ytdl_path = @import("ytdlp.zig").getPath() orelse "yt-dlp" }, &script_buf)) |opts_z| { + _ = c.mpv_set_option_string(handle, "script-opts", opts_z.ptr); + } + if (c.mpv_initialize(handle) < 0) { + failPlayback(); + return false; + } + if (!@import("build_options").headless) { + var params = [_]c.mpv_render_param{ + .{ .type = c.MPV_RENDER_PARAM_API_TYPE, .data = @constCast(c.MPV_RENDER_API_TYPE_SW) }, + .{ .type = c.MPV_RENDER_PARAM_INVALID, .data = null }, + }; + if (c.mpv_render_context_create(&renderer, handle, ¶ms) < 0) { + failPlayback(); + return false; + } + pixels = allocator.alloc(u8, frame_width * frame_height * 4) catch { + failPlayback(); + return false; + }; + } + if (!@import("../player/player.zig").loadDetached(handle, .{ .url = snap.metadata.trailer_url[0..snap.metadata.trailer_url_len] })) { + failPlayback(); + return false; + } + mutex.lock(); + current.status = .starting; + current.failure = .none; + current.muted = true; + mutex.unlock(); + return true; +} + +pub fn setMuted(muted: bool) void { + const handle = mpv orelse return; + var value: c_int = if (muted) 1 else 0; + if (c.mpv_set_property(handle, "mute", c.MPV_FORMAT_FLAG, &value) >= 0) { + mutex.lock(); + current.muted = muted; + mutex.unlock(); + } +} + +/// Non-blocking event drain. Call each UI frame while preview is selected. +pub fn poll() void { + launchPending(); + const handle = mpv orelse return; + for (0..32) |_| { + const event = c.mpv_wait_event(handle, 0); + if (event == null or event.*.event_id == c.MPV_EVENT_NONE) break; + switch (event.*.event_id) { + c.MPV_EVENT_FILE_LOADED => { + mutex.lock(); + current.status = .playing; + mutex.unlock(); + }, + c.MPV_EVENT_END_FILE => { + const end: *const c.mpv_event_end_file = @ptrCast(@alignCast(event.*.data)); + mutex.lock(); + current.status = if (end.reason == c.MPV_END_FILE_REASON_ERROR) .failed else .ended; + current.failure = if (end.reason == c.MPV_END_FILE_REASON_ERROR) .playback else .none; + mutex.unlock(); + }, + else => {}, + } + } + // keep-open retains the last trailer frame without END_FILE on some mpv + // versions. The real EOF property, never elapsed-time guessing, ends UI state. + if (snapshot().status == .playing) { + var eof: c_int = 0; + if (c.mpv_get_property(handle, "eof-reached", c.MPV_FORMAT_FLAG, &eof) >= 0 and eof != 0) { + mutex.lock(); + current.status = .ended; + mutex.unlock(); + } + } +} + +/// Bounded 640×360 software preview; no decode/upload for invisible overlays. +/// Call inside a bounded preview panel. Muted previews do not block for A/V time. +pub fn render(src: std.builtin.SourceLocation, id: usize, width: f32, height: f32) void { + if (comptime @import("build_options").headless) return; + if (width <= 0 or height <= 0) return; + const ctx = renderer orelse return; + const buf = pixels orelse return; + const flags = c.mpv_render_context_update(ctx); + if (flags & c.MPV_RENDER_UPDATE_FRAME != 0) { + const size = [2]c_int{ frame_width, frame_height }; + const stride: usize = frame_width * 4; + const blocking: c_int = 0; + var params = [_]c.mpv_render_param{ + .{ .type = c.MPV_RENDER_PARAM_SW_SIZE, .data = @constCast(&size) }, + .{ .type = c.MPV_RENDER_PARAM_SW_FORMAT, .data = @constCast("rgb0") }, + .{ .type = c.MPV_RENDER_PARAM_SW_STRIDE, .data = @constCast(&stride) }, + .{ .type = c.MPV_RENDER_PARAM_SW_POINTER, .data = buf.ptr }, + .{ .type = c.MPV_RENDER_PARAM_BLOCK_FOR_TARGET_TIME, .data = @constCast(&blocking) }, + .{ .type = c.MPV_RENDER_PARAM_INVALID, .data = null }, + }; + if (c.mpv_render_context_render(ctx, ¶ms) >= 0) { + const sdl_renderer: *sdl.SDL_Renderer = @ptrCast(@alignCast(dvui.currentWindow().backend.impl.renderer)); + if (texture == null) { + if (sdl.SDL_CreateTexture(sdl_renderer, sdl.SDL_PIXELFORMAT_RGBX32, sdl.SDL_TEXTUREACCESS_STREAMING, frame_width, frame_height)) |tex| { + _ = sdl.SDL_SetTextureBlendMode(tex, sdl.SDL_BLENDMODE_NONE); + _ = sdl.SDL_SetTextureScaleMode(tex, sdl.SDL_ScaleModeLinear); + texture = .{ .ptr = tex, .width = frame_width, .height = frame_height, .format = .rgbx_32 }; + } + } + if (texture) |tex| { + const ptr: *sdl.SDL_Texture = @ptrCast(@alignCast(tex.ptr)); + frame_ready = sdl.SDL_UpdateTexture(ptr, null, buf.ptr, frame_width * 4) == 0; + } + } + } + if (frame_ready) if (texture) |tex| { + _ = dvui.image(src, .{ .source = .{ .texture = tex } }, .{ .id_extra = id, .min_size_content = .{ .w = width, .h = height }, .max_size_content = .{ .w = width, .h = height } }); + }; + const status = snapshot().status; + if (status == .playing or status == .starting) @import("../ui/components.zig").animatedRefresh(33_000); +} + +fn destroyPlayer() void { + // No render worker: all render calls and teardown belong to the UI thread. + if (renderer) |ctx| c.mpv_render_context_free(ctx); + renderer = null; + if (mpv) |ctx| c.mpv_terminate_destroy(ctx); + mpv = null; + if (pixels) |buf| allocator.free(buf); + pixels = null; + if (texture) |tex| dvui.textureDestroyLater(tex); + texture = null; + frame_ready = false; +} + +pub fn stop() void { + destroyPlayer(); + mutex.lock(); + defer mutex.unlock(); + // Route guards may call stop every frame. Once invalidated, preserve its + // generation; a still-running old worker already cannot publish into it. + if (!lifecycle.selected and pending == null and current.status == .idle) return; + lifecycle.stop(); + pending = null; + current = .{ .generation = lifecycle.generation }; +} + +/// Close/replay the selected preview without refetching verified metadata. +pub fn closePlayback() void { + destroyPlayer(); + mutex.lock(); + defer mutex.unlock(); + if (lifecycle.selected and current.metadata.trailer_url_len > 0) { + current.status = .ready; + current.failure = .none; + current.muted = true; + } +} + +pub fn deinit() void { + stop(); + mutex.lock(); + credentials = .{}; + mutex.unlock(); +} diff --git a/src/services/search_preview_pure.zig b/src/services/search_preview_pure.zig new file mode 100644 index 00000000..b45dbc41 --- /dev/null +++ b/src/services/search_preview_pure.zig @@ -0,0 +1,163 @@ +//! Verified TMDB preview metadata and generation policy; no guessed trailers. +const std = @import("std"); + +pub const Kind = enum { movie, tv }; +pub const Status = enum { idle, loading, ready, unavailable, failed, starting, playing, ended }; +pub const Failure = enum { none, no_credentials, no_official_video, transport, invalid_response, playback }; +pub const https_arguments = [_][]const u8{ "--proto", "=https", "--proto-redir", "=https", "--fail" }; +pub const Metadata = struct { + catalog_id: i32 = 0, + trailer_url: [128]u8 = @splat(0), + trailer_url_len: usize = 0, + title: [192]u8 = @splat(0), + title_len: usize = 0, + backdrop_url: [256]u8 = @splat(0), + backdrop_url_len: usize = 0, +}; + +pub const Lifecycle = struct { + generation: u64 = 0, + identity: u64 = 0, + selected: bool = false, + + pub fn select(self: *Lifecycle, identity: u64) u64 { + self.generation +%= 1; + self.identity = identity; + self.selected = true; + return self.generation; + } + pub fn stop(self: *Lifecycle) void { + if (!self.selected) return; + self.generation +%= 1; + self.selected = false; + self.identity = 0; + } + pub fn accepts(self: Lifecycle, generation: u64) bool { + return self.selected and self.generation == generation; + } +}; + +fn string(obj: std.json.Value, key: []const u8) []const u8 { + if (obj != .object) return ""; + const v = obj.object.get(key) orelse return ""; + return if (v == .string) v.string else ""; +} + +pub fn validVideoKey(key: []const u8) bool { + if (key.len != 11) return false; + for (key) |ch| if (!std.ascii.isAlphanumeric(ch) and ch != '_' and ch != '-') return false; + return true; +} + +fn validImagePath(path: []const u8) bool { + if (path.len < 2 or path.len > 192 or path[0] != '/' or std.mem.indexOf(u8, path, "..") != null) return false; + for (path[1..]) |ch| if (!std.ascii.isAlphanumeric(ch) and ch != '_' and ch != '-' and ch != '.') return false; + return true; +} + +/// An official YouTube Trailer is preferred to an official Teaser. Other sites, +/// arbitrary URLs, featurettes, and unofficial uploads are never playable here. +pub fn parse(allocator: std.mem.Allocator, body: []const u8) !Metadata { + if (body.len > 256 * 1024) return error.Oversized; + var doc = try std.json.parseFromSlice(std.json.Value, allocator, body, .{ .max_value_len = 256 * 1024 }); + defer doc.deinit(); + if (doc.value != .object) return error.InvalidResponse; + const id = doc.value.object.get("id") orelse return error.InvalidResponse; + if (id != .integer or id.integer <= 0) return error.InvalidResponse; + var result: Metadata = .{}; + result.catalog_id = std.math.cast(i32, id.integer) orelse return error.InvalidResponse; + const backdrop = string(doc.value, "backdrop_path"); + if (validImagePath(backdrop)) { + const url = std.fmt.bufPrint(&result.backdrop_url, "https://image.tmdb.org/t/p/w780{s}", .{backdrop}) catch ""; + result.backdrop_url_len = url.len; + } + const videos = doc.value.object.get("videos") orelse return error.InvalidResponse; + if (videos != .object) return error.InvalidResponse; + const rows = videos.object.get("results") orelse return error.InvalidResponse; + if (rows != .array) return error.InvalidResponse; + var best: ?std.json.Value = null; + var best_rank: u8 = 255; + for (rows.array.items) |row| { + if (row != .object) continue; + const official = row.object.get("official") orelse continue; + if (official != .bool or !official.bool or !std.mem.eql(u8, string(row, "site"), "YouTube")) continue; + const key = string(row, "key"); + if (!validVideoKey(key)) continue; + const ty = string(row, "type"); + const rank: u8 = if (std.mem.eql(u8, ty, "Trailer")) 0 else if (std.mem.eql(u8, ty, "Teaser")) 2 else continue; + const score = rank + @as(u8, if (std.mem.eql(u8, string(row, "iso_639_1"), "en")) 0 else 1); + if (best == null or score < best_rank or (score == best_rank and std.mem.order(u8, key, string(best.?, "key")) == .lt)) { + best = row; + best_rank = score; + } + } + if (best) |row| { + const url = try std.fmt.bufPrint(&result.trailer_url, "https://www.youtube.com/watch?v={s}", .{string(row, "key")}); + result.trailer_url_len = url.len; + const title = string(row, "name"); + // Labels have a fixed row; retain UTF-8 and remove ASCII control bytes. + var n = @min(title.len, result.title.len); + while (n > 0 and !std.unicode.utf8ValidateSlice(title[0..n])) n -= 1; + for (title[0..n], 0..) |ch, i| result.title[i] = if (ch < 32 or ch == 127) ' ' else ch; + result.title_len = n; + } + return result; +} + +pub fn parseForId(allocator: std.mem.Allocator, body: []const u8, expected_id: i32) !Metadata { + const meta = try parse(allocator, body); + if (expected_id <= 0 or meta.catalog_id != expected_id) return error.InvalidResponse; + return meta; +} + +test "preview selection and close reject late metadata" { + var life: Lifecycle = .{}; + const a = life.select(1); + try std.testing.expect(life.accepts(a)); + const b = life.select(2); + try std.testing.expect(!life.accepts(a)); + try std.testing.expect(life.accepts(b)); + life.stop(); + try std.testing.expect(!life.accepts(b)); + const stopped_generation = life.generation; + life.stop(); + try std.testing.expectEqual(stopped_generation, life.generation); +} + +test "verified preview transport forbids HTTP and insecure redirects" { + try std.testing.expectEqualStrings("--proto", https_arguments[0]); + try std.testing.expectEqualStrings("=https", https_arguments[1]); + try std.testing.expectEqualStrings("--proto-redir", https_arguments[2]); + try std.testing.expectEqualStrings("=https", https_arguments[3]); + try std.testing.expectEqualStrings("--fail", https_arguments[4]); +} + +test "preview is verified official typed TMDB trailer not arbitrary video" { + const body = + \\{"id":42,"backdrop_path":"/actual.jpg","videos":{"results":[ + \\{"site":"YouTube","official":false,"type":"Trailer","key":"aaaaaaaaaaa"}, + \\{"site":"YouTube","official":true,"type":"Featurette","key":"bbbbbbbbbbb"}, + \\{"site":"YouTube","official":true,"type":"Teaser","key":"ccccccccccc"}, + \\{"site":"YouTube","official":true,"type":"Trailer","iso_639_1":"en","name":"Real\ntrailer","key":"ddddddddddd"}]}} + ; + const meta = try parse(std.testing.allocator, body); + try std.testing.expectEqualStrings("https://www.youtube.com/watch?v=ddddddddddd", meta.trailer_url[0..meta.trailer_url_len]); + try std.testing.expectEqualStrings("Real trailer", meta.title[0..meta.title_len]); + try std.testing.expectEqualStrings("https://image.tmdb.org/t/p/w780/actual.jpg", meta.backdrop_url[0..meta.backdrop_url_len]); +} + +test "preview tie deterministic independent of provider result order" { + const a = try parse(std.testing.allocator, "{\"id\":1,\"videos\":{\"results\":[{\"official\":true,\"site\":\"YouTube\",\"type\":\"Trailer\",\"key\":\"bbbbbbbbbbb\"},{\"official\":true,\"site\":\"YouTube\",\"type\":\"Trailer\",\"key\":\"aaaaaaaaaaa\"}]}}"); + try std.testing.expect(std.mem.endsWith(u8, a.trailer_url[0..a.trailer_url_len], "aaaaaaaaaaa")); +} + +test "preview missing official video preserves real artwork and rejects path injection" { + const a = try parse(std.testing.allocator, "{\"id\":1,\"backdrop_path\":\"/real.png\",\"videos\":{\"results\":[]}}"); + try std.testing.expectEqual(@as(usize, 0), a.trailer_url_len); + try std.testing.expect(a.backdrop_url_len > 0); + const b = try parse(std.testing.allocator, "{\"id\":1,\"backdrop_path\":\"//evil.com/x\",\"videos\":{\"results\":[{\"official\":true,\"site\":\"YouTube\",\"type\":\"Trailer\",\"key\":\"x&v=evil123\"}]}}"); + try std.testing.expectEqual(@as(usize, 0), b.backdrop_url_len); + try std.testing.expectEqual(@as(usize, 0), b.trailer_url_len); + try std.testing.expectError(error.InvalidResponse, parse(std.testing.allocator, "{\"status_message\":\"bad key\"}")); + try std.testing.expectError(error.InvalidResponse, parseForId(std.testing.allocator, "{\"id\":2,\"videos\":{\"results\":[]}}", 1)); +} diff --git a/src/services/search_reading_pure.zig b/src/services/search_reading_pure.zig new file mode 100644 index 00000000..00942d51 --- /dev/null +++ b/src/services/search_reading_pure.zig @@ -0,0 +1,80 @@ +//! Owned, bounded independent reading listings. Reuses Browse's real DOM seam. +const std = @import("std"); +const expanded = @import("expanded_reading_pure.zig"); +const text = @import("novels_pure.zig"); +pub const Source = enum { royalroad, novelfire }; +pub const MAX_ITEMS = 12; +pub const Item = struct { + title: [256]u8 = @splat(0), + title_len: usize = 0, + url: [1024]u8 = @splat(0), + url_len: usize = 0, + cover: [512]u8 = @splat(0), + cover_len: usize = 0, +}; +pub const Result = struct { count: usize = 0, valid_listing: bool = false }; +fn coverUrl(out: []u8, base: []const u8, raw: []const u8) ?[]const u8 { + const candidate = expanded.html.sourceUrl(out, base, raw) orelse raw; + // Providers advertise real cover CDN URLs outside the listing origin. + // Preserve those HTTPS URLs without admitting scripts or user credentials. + if (candidate.len > out.len or std.mem.indexOfAny(u8, candidate, "\r\n") != null) return null; + const uri = std.Uri.parse(candidate) catch return null; + if (!std.ascii.eqlIgnoreCase(uri.scheme, "https") or uri.host == null or uri.user != null or uri.password != null) return null; + if (candidate.ptr != out.ptr) @memcpy(out[0..candidate.len], candidate); + return out[0..candidate.len]; +} +/// Only links inside the advertised work-list container are admitted. Chapter, +/// random-navigation, external-host, script and duplicate links are excluded. +pub fn parseInto(body: []const u8, base: []const u8, source: Source, out: []Item) Result { + const listing = expanded.novelListingHtml(body, @tagName(source)) orelse return .{}; + var result: Result = .{ .valid_listing = true }; + var iter = expanded.html.AnchorIter{ .html = listing, .path = if (source == .royalroad) "/fiction/" else "/book/" }; + while (iter.next()) |anchor| { + if (std.mem.indexOf(u8, anchor.url, "/chapter") != null or std.mem.endsWith(u8, anchor.url, "/random")) continue; + var item: Item = .{}; + const url = expanded.html.sourceUrl(&item.url, base, anchor.url) orelse continue; + item.url_len = url.len; + item.title_len = text.htmlToText(anchor.title, &item.title); + if (item.title_len == 0) continue; + if (coverUrl(&item.cover, base, anchor.cover)) |cover| item.cover_len = cover.len; + var duplicate = false; + for (out[0..result.count]) |*previous| { + if (!std.mem.eql(u8, previous.url[0..previous.url_len], url)) continue; + if (previous.cover_len == 0 and item.cover_len > 0) { + previous.cover = item.cover; + previous.cover_len = item.cover_len; + } + duplicate = true; + break; + } + if (duplicate) continue; + if (result.count == @min(out.len, MAX_ITEMS)) break; + out[result.count] = item; + result.count += 1; + } + return result; +} +test "independent reading list owns valid works and merges cover without chapter or foreign links" { + const html = "Outside
"; + var out: [MAX_ITEMS]Item = undefined; + const result = parseInto(html, "https://books.test", .novelfire, &out); + try std.testing.expect(result.valid_listing); + try std.testing.expectEqual(@as(usize, 1), result.count); + try std.testing.expectEqualStrings("Real & Good", out[0].title[0..out[0].title_len]); + try std.testing.expectEqualStrings("https://books.test/book/real", out[0].url[0..out[0].url_len]); + try std.testing.expectEqualStrings("https://books.test/cover.jpg", out[0].cover[0..out[0].cover_len]); + try std.testing.expect(!parseInto("Challenge", "https://books.test", .novelfire, &out).valid_listing); +} +test "reading results stay bounded and valid empty containers are not parser failures" { + var out: [1]Item = undefined; + const result = parseInto("", "https://rr.test", .royalroad, &out); + try std.testing.expectEqual(@as(usize, 1), result.count); + try std.testing.expect(parseInto("", "https://rr.test", .royalroad, &out).valid_listing); +} +test "advertised cover CDN preserved but credentials and unsafe schemes rejected" { + var out: [512]u8 = undefined; + try std.testing.expectEqualStrings("https://cdn.test/cover.jpg", coverUrl(&out, "https://books.test", "https://cdn.test/cover.jpg").?); + try std.testing.expect(coverUrl(&out, "https://books.test", "https://user:pass@cdn.test/cover.jpg") == null); + try std.testing.expect(coverUrl(&out, "https://user:pass@books.test", "/cover.jpg") == null); + try std.testing.expect(coverUrl(&out, "https://books.test", "javascript:alert(1)") == null); +} diff --git a/src/services/tmdb_api.zig b/src/services/tmdb_api.zig index e0007b87..0e3e0436 100644 --- a/src/services/tmdb_api.zig +++ b/src/services/tmdb_api.zig @@ -583,6 +583,10 @@ pub fn fetchPoster(item: *state.TmdbItem) void { pub var tmdb_https_blocked: std.atomic.Value(bool) = std.atomic.Value(bool).init(false); fn curlOwnedOnce(url: []const u8, auth_header: []const u8, max_bytes: usize) ?[]u8 { + return curlOwnedPolicy(url, auth_header, max_bytes, false); +} + +fn curlOwnedPolicy(url: []const u8, auth_header: []const u8, max_bytes: usize, https_only: bool) ?[]u8 { const io_g = @import("../core/io_global.zig"); // --connect-timeout bounds a dead/slow host to 3s on connect instead of // burning the full --max-time; --max-time trimmed to 8s for interactive @@ -602,10 +606,18 @@ fn curlOwnedOnce(url: []const u8, auth_header: []const u8, max_bytes: usize) ?[] // -L is required by the public Cinemeta endpoint, which currently answers // with a 307 to its catalog shard. Without it we read the redirect text, // reject it as non-JSON, and leave the keyless gallery empty. - var child = if (auth_header.len > 0) - io_g.Child.init(&.{ "curl", "-L", "-s", "--connect-timeout", "3", "--config", "-", "--max-time", "8", "-o", path, url }, alloc) - else - io_g.Child.init(&.{ "curl", "-L", "-s", "--connect-timeout", "3", "--max-time", "8", "-o", path, url }, alloc); + var args: std.ArrayListUnmanaged([]const u8) = .empty; + defer args.deinit(alloc); + args.appendSlice(alloc, &.{ "curl", "-L", "-s", "--connect-timeout", "3", "--max-time", "8", "-o", path }) catch return null; + if (auth_header.len > 0) args.appendSlice(alloc, &.{ "--config", "-" }) catch return null; + var size_buf: [24]u8 = undefined; + if (https_only) { + args.appendSlice(alloc, &@import("search_preview_pure.zig").https_arguments) catch return null; + const size_text = std.fmt.bufPrint(&size_buf, "{d}", .{max_bytes}) catch return null; + args.appendSlice(alloc, &.{ "--max-filesize", size_text }) catch return null; + } + args.append(alloc, url) catch return null; + var child = io_g.Child.init(args.items, alloc); child.stdout_behavior = .Ignore; child.stderr_behavior = .Ignore; if (auth_header.len > 0) { @@ -637,6 +649,20 @@ fn curlOwnedOnce(url: []const u8, auth_header: []const u8, max_bytes: usize) ?[] return body; } +/// Trailer verification requires an authenticated HTTPS response. Unlike the +/// legacy browse fallback, previews never downgrade HTTP or follow HTTP redirects. +pub fn tmdbPreviewApiOwned(path: []const u8, key: []const u8) ?[]u8 { + const v4 = @import("tmdb_pure.zig").keyIsV4(key); + var url_buf: [768]u8 = undefined; + const url = if (v4) + std.fmt.bufPrint(&url_buf, "https://api.themoviedb.org{s}", .{path}) catch return null + else + std.fmt.bufPrint(&url_buf, "https://api.themoviedb.org{s}&api_key={s}", .{ path, key }) catch return null; + var auth_buf: [320]u8 = undefined; + const auth = if (v4) std.fmt.bufPrint(&auth_buf, "Authorization: Bearer {s}", .{key}) catch return null else ""; + return curlOwnedPolicy(url, auth, 256 * 1024, true); +} + /// curl `url` (HTTPS) into `buf`, with an HTTPS→HTTP fallback for SNI-blocked /// networks. CRITICAL: a response is only accepted if it looks like JSON — some /// ISPs hijack plain HTTP to api.themoviedb.org and inject an HTML block page diff --git a/src/ui/components.zig b/src/ui/components.zig index cdcdf60f..87f6e80e 100644 --- a/src/ui/components.zig +++ b/src/ui/components.zig @@ -246,6 +246,23 @@ pub fn renderCoverSlot(src: std.builtin.SourceLocation, id_extra: usize, slot: * }); } +/// Fixed gallery geometry with a centered crop; real covers keep their aspect +/// ratio instead of being stretched to match a different content format. +pub fn galleryCoverArt(src: std.builtin.SourceLocation, id: usize, slot: *CoverSlot, url: []const u8, fallback: []const u8, radius: f32, width: f32, height: f32) void { + syncCoverSlot(slot, url); + pollCoverSlot(slot); + if (@import("search_gallery_pure.zig").shouldFetchCover(url.len > 0, slot.failed, slot.fetching, slot.pixels != null, slot.tex != null)) { + poster.fetchAsync(url, &slot.pixels, &slot.w, &slot.h, &slot.fetching); + if (slot.fetching) slot.attempted = true; + } + if (slot.url_hash == slot.desired_url_hash) if (slot.tex) |tex| { + const crop = @import("search_gallery_pure.zig").coverCrop(.{ .w = @floatFromInt(slot.w), .h = @floatFromInt(slot.h) }, .{ .w = width, .h = height }); + _ = dvui.image(src, .{ .source = .{ .texture = tex }, .uv = .{ .x = crop.x, .y = crop.y, .w = crop.w, .h = crop.h } }, .{ .id_extra = id, .min_size_content = .{ .w = width, .h = height }, .max_size_content = .{ .w = width, .h = height }, .corner_radius = dvui.Rect.all(radius) }); + return; + }; + renderCoverSlot(src, id, slot, url.len > 0, fallback, radius); +} + /// Fill the current parent with a fetched cover, an animated loading skeleton, /// or a quiet fallback icon. This keeps image lifecycle and failure latching /// identical across secondary browse pages. @@ -253,7 +270,7 @@ pub fn coverArt(src: std.builtin.SourceLocation, id_extra: usize, slot: *CoverSl syncCoverSlot(slot, url); pollCoverSlot(slot); - if (url.len > 0 and !slot.failed and !slot.fetching) { + if (@import("search_gallery_pure.zig").shouldFetchCover(url.len > 0, slot.failed, slot.fetching, slot.pixels != null, slot.tex != null)) { poster.fetchAsync(url, &slot.pixels, &slot.w, &slot.h, &slot.fetching); if (slot.fetching) slot.attempted = true; } diff --git a/src/ui/home.zig b/src/ui/home.zig index 6aecf13c..8f13ffdf 100644 --- a/src/ui/home.zig +++ b/src/ui/home.zig @@ -951,6 +951,10 @@ fn openLibItem(item: *const library_pure.LibraryItem) void { state.app.router.navigate(.browse); }, .novels => @import("../services/novels.zig").openDeepLink(link), + .search => { + @import("../services/search.zig").submitQuery(title); + state.app.router.navigate(.search); + }, .comics => @import("../services/comics.zig").openDeepLink(link), .podcast => @import("../services/podcasts.zig").openDeepLink(link), else => browser.resumePlayback(link), @@ -1086,6 +1090,7 @@ fn renderLibraryItemsRail(items: []const library_pure.LibraryItem, heading: []co .comics => "Comic", .novels => "Read", .iptv => "Live", + .search => "Saved title", .other => "Continue", }; _ = dvui.label(@src(), "{s}", .{if (revealed) kind_label else "Private"}, .{ diff --git a/src/ui/search_gallery_pure.zig b/src/ui/search_gallery_pure.zig new file mode 100644 index 00000000..07427eae --- /dev/null +++ b/src/ui/search_gallery_pure.zig @@ -0,0 +1,107 @@ +//! Geometry policy shared by the native search gallery and its regression tests. +const std = @import("std"); +pub const Shape = enum { portrait, square, landscape }; +pub const Size = struct { w: f32, h: f32 }; +pub const Crop = struct { x: f32 = 0, y: f32 = 0, w: f32 = 1, h: f32 = 1 }; +pub fn shouldFetchCover(has_url: bool, failed: bool, fetching: bool, has_pixels: bool, has_texture: bool) bool { + return has_url and !failed and !fetching and !has_pixels and !has_texture; +} +pub fn coverCrop(image: Size, target: Size) Crop { + if (image.w <= 0 or image.h <= 0 or target.w <= 0 or target.h <= 0) return .{}; + const source_ratio = image.w / image.h; + const target_ratio = target.w / target.h; + if (source_ratio > target_ratio) { + const width = target_ratio / source_ratio; + return .{ .x = (1 - width) / 2, .w = width }; + } + const height = source_ratio / target_ratio; + return .{ .y = (1 - height) / 2, .h = height }; +} +pub fn cardSize(shape: Shape, viewport_width: f32) Size { + const compact = viewport_width < 640; + const width: f32 = switch (shape) { + .portrait => if (compact) 128 else 164, + .square => if (compact) 148 else 180, + .landscape => if (compact) 224 else 272, + }; + return .{ .w = width, .h = switch (shape) { + .portrait => width * 1.5, + .square => width, + .landscape => width * 9 / 16, + } }; +} +pub fn stackedHero(width: f32) bool { + return width < 760; +} +pub fn heroCopyGravity(width: f32) f32 { + // Fractional gravity in a vertical Box positions a child as an overlay. + return if (stackedHero(width)) 0 else 0.5; +} +pub fn shelfHeight(art_height: f32, body_height: f32) f32 { + // Two title lines, a metadata line, button, and widget margins/padding. + return art_height + body_height * 4 + 64; +} +pub fn heroArtSize(width: f32, backdrop: bool) Size { + const w = if (backdrop) @min(560, @max(200, if (stackedHero(width)) width - 24 else width * 0.49)) else @min(184, @max(112, width * 0.2)); + return .{ .w = w, .h = if (backdrop) w * 9 / 16 else w * 1.5 }; +} +pub fn retainSelection(identities: []const u64, selected: u64) ?usize { + if (identities.len == 0) return null; + for (identities, 0..) |identity, index| if (identity == selected and selected != 0) return index; + return 0; +} +pub fn shouldFeature(query: []const u8, title: []const u8, has_art: bool, selected: bool) bool { + if (selected) return true; + if (!has_art) return false; + return std.ascii.eqlIgnoreCase(std.mem.trim(u8, query, " \t\r\n"), std.mem.trim(u8, title, " \t\r\n")); +} +test "search cards retain meaningful aspect ratios at narrow and wide widths" { + for ([_]f32{ 320, 640, 1440 }) |width| { + const portrait = cardSize(.portrait, width); + const square = cardSize(.square, width); + const video = cardSize(.landscape, width); + try std.testing.expectApproxEqAbs(@as(f32, 1.5), portrait.h / portrait.w, 0.001); + try std.testing.expectEqual(square.w, square.h); + try std.testing.expectApproxEqAbs(@as(f32, 16.0 / 9.0), video.w / video.h, 0.001); + } +} +test "search hero stacks on narrow windows without expanding portrait artwork" { + try std.testing.expect(stackedHero(600)); + try std.testing.expect(!stackedHero(1000)); + try std.testing.expect(heroArtSize(1440, false).w <= 184); + try std.testing.expect(heroArtSize(320, true).w <= 320); + try std.testing.expectEqual(@as(f32, 0), heroCopyGravity(640)); + try std.testing.expectEqual(@as(f32, 0.5), heroCopyGravity(1360)); +} +test "search shelf reserves complete card actions instead of clipping Details" { + for ([_]f32{ 14, 20, 30 }) |body_height| { + const art = cardSize(.portrait, 1360); + try std.testing.expect(shelfHeight(art.h, body_height) >= art.h + body_height * 4 + 48); + } +} +test "progressive search publications preserve selected content by identity" { + try std.testing.expectEqual(@as(?usize, 2), retainSelection(&.{ 1, 9, 5 }, 5)); + try std.testing.expectEqual(@as(?usize, 0), retainSelection(&.{ 1, 9 }, 5)); + try std.testing.expectEqual(@as(?usize, null), retainSelection(&.{}, 5)); +} +test "broad query does not invent a confident featured match" { + try std.testing.expect(!shouldFeature("space", "Space Jam", true, false)); + try std.testing.expect(shouldFeature(" Reacher ", "reacher", true, false)); + try std.testing.expect(!shouldFeature("Reacher", "Reacher", false, false)); + try std.testing.expect(shouldFeature("space", "Space Jam", false, true)); +} +test "search artwork crops centrally instead of stretching square covers" { + const crop = coverCrop(.{ .w = 400, .h = 400 }, .{ .w = 160, .h = 240 }); + try std.testing.expectApproxEqAbs(@as(f32, 2.0 / 3.0), crop.w, 0.001); + try std.testing.expectApproxEqAbs(@as(f32, 1.0 / 6.0), crop.x, 0.001); + const wide = coverCrop(.{ .w = 100, .h = 150 }, .{ .w = 272, .h = 153 }); + try std.testing.expect(wide.h < 1 and wide.y > 0); + try std.testing.expectEqual(Crop{}, coverCrop(.{ .w = 0, .h = 0 }, .{ .w = 1, .h = 1 })); +} +test "ready artwork must not refetch every rendered frame" { + try std.testing.expect(shouldFetchCover(true, false, false, false, false)); + try std.testing.expect(!shouldFetchCover(true, false, false, false, true)); + try std.testing.expect(!shouldFetchCover(true, false, false, true, false)); + try std.testing.expect(!shouldFetchCover(true, false, true, false, false)); + try std.testing.expect(!shouldFetchCover(true, true, false, false, false)); +} diff --git a/src/ui/shell.zig b/src/ui/shell.zig index 60e3cc79..69e21df1 100644 --- a/src/ui/shell.zig +++ b/src/ui/shell.zig @@ -672,10 +672,8 @@ fn omnibox(narrow: bool) void { }, .open => openOmniboxTarget(text), .search => { - if (!browser.searchCurrentBrowse(text)) { - search_mod.submitQuery(text); - state.app.router.navigate(.search); - } + search_mod.submitQuery(text); + state.app.router.navigate(.search); }, } } diff --git a/tests/features/test_library.py b/tests/features/test_library.py index 60333b1c..2c2412b5 100644 --- a/tests/features/test_library.py +++ b/tests/features/test_library.py @@ -220,7 +220,7 @@ def movie_preferences_persist(): "movie detail rating": "personalRatingOptions" in web and "Rated ${rating.value} / 10" in web, "mutations use POST helper": "apiMutation('/library/item/action?kind=movie" in web, "favorite can reopen": "opal://search/{s}" in remote and '"opal://search/"' in browser - and "triggerSearch(query)" in browser, + and "submitQuery(query)" in browser, } missing = [name for name, ok in checks.items() if not ok] if missing: diff --git a/tests/features/test_search_v2.py b/tests/features/test_search_v2.py index f8b43caf..1f601386 100644 --- a/tests/features/test_search_v2.py +++ b/tests/features/test_search_v2.py @@ -45,14 +45,15 @@ def test_search_v2_resolver_projection(): "real torrent properties retained": all(field in projection for field in (".quality", ".seeds", ".leech", ".size_bytes", ".provider")), "personal library sources": all(source in projection for source in (".local", ".jellyfin", ".plex")), "catalog and reader classifications": "search_view.kindFor(source" in projection and all(source in pure for source in (".tmdb", ".anime", ".comics", ".novels", ".opds", ".vndb", ".audiobooks")), - "provider metadata survives cache": "search:v8:" in resolver and "w.blob(it.provider.name())" in resolver + "provider metadata survives cache": "search:v10:" in _src("src/services/search_content_pure.zig") + and "content.cacheIdentity(" in resolver and "w.blob(it.provider.name())" in resolver and "search_view.Provider.init(r.blob()" in resolver, "actual torrent adapter providers": all(f'Provider.init("{name}")' in resolver for name in ("rss", "yts", "eztv")) and "Provider.init(eng_name)" in resolver and "Provider.init(src_id)" in resolver, }, "Owned resolver snapshots preserve stable actions, content policy and actual torrent metadata") -@test("Search v2 native view is compact virtualized and snapshot-owned", "Search") +@test("Search v2 native view uses artwork shelves and owned snapshots", "Search") def test_search_v2_native_view(): search = _src("src/services/search.zig") shell = _src("src/ui/shell.zig") @@ -87,9 +88,11 @@ def test_search_v2_native_view(): "sort popup closes on selection": "popup.close();" in _between(search, "fn searchSelectImpl(", "const CONTENT_LABELS"), "compact sort popup": "searchSelectImpl(91300, &SORT_LABELS, @intFromEnum(view_sort), dense)" in controls and "&SORT_LABELS" in controls and "components.segment(" not in controls and "searchSelect(91300" not in renderer, - "filtered totals": "shown of {d} loaded" in renderer and "view_cache.count" in renderer and "view_cache.loaded" in renderer, - "visible range only": "search_view.visibleRange(" in renderer and "for (range.start..range.end)" in renderer, - "keyboard traversal retained": "keyboardLayoutMode(" in renderer and ".tab" in renderer, + "filtered content totals": "{d} titles · {d} other releases" in renderer and "galleryGroupVisible(group)" in renderer, + "visible artwork only": "art.data().visible()" in renderer and "components.galleryCoverArt(" in renderer, + "keyboard details action retained": 'actionButton(@src(), "Details"' in renderer, + "shared content grouping": "search_content.projectInto(" in renderer and "galleryGroupVisible(" in renderer, + "global search regardless browse route": "browser.searchCurrentBrowse(text)" not in omnibox and "search_mod.submitQuery(text)" in omnibox, "snapshot-owned action": "view_cache.rows.?[action.idx]" in renderer and "resolver.playResolvedItem(item)" in renderer, "safe torrent queue": "torrent_risk_pure.zig" in renderer and "risk.risk == .block" in renderer and "addToQueue(" in renderer, "stable result widget identities": "resolver.actionKey(item)" in row and ".id_extra = row_key" in row, @@ -158,3 +161,34 @@ def test_search_shell_dense_and_query_ownership(): "compact input avoids inherited button and field margins": ".margin = dvui.Rect.all(0)" in icons and ".margin = dvui.Rect.all(0)" in omnibox, }, "Dense icons keep named menus; query aliases are owned and Clear cancels both pipelines") + + +@test("Visual search content grouping previews and stable artwork lifetimes", "Search") +def test_visual_search_gallery(): + search = _src("src/services/search.zig") + grouping = _src("src/services/search_content_pure.zig") + preview = _src("src/services/search_preview.zig") + pure_preview = _src("src/services/search_preview_pure.zig") + main = _src("src/main.zig") + build = _src("build.zig") + return _checked({ + "bounded grouped content projection": "pub fn projectInto(" in grouping and "pub fn evictionIndex(" in grouping, + "artwork card detail and hero": all(name in search for name in ("renderGalleryCard", "renderGalleryHero", "galleryCategoryLabel", "Available sources")), + "stable poster worker addresses": "gallery_covers: [196]GalleryCover" in search and "!entry.slot.fetching" in search, + "progressive selection identity": "gallery_layout.retainSelection(" in search, + "explicit isolated preview": "preview.start()" in search and "preview.select(" in search and "state.app.players.append" not in preview, + "hidden or empty feature stops preview": 'view_cache.count == 0) @import("search_preview.zig").stop()' in search + and 'else @import("search_preview.zig").stop()' in search, + "filtered categories use wrapping native grid": "if (view_filters.content != .all)" in search + and "var grid = dvui.flexbox" in search, + "preview shares typed load transport": "loadDetached(handle" in preview and '"loadfile"' not in preview, + "verified embed allowed by production CSP": "frame-src 'self' https://www.youtube-nocookie.com" in _src("src/services/remote_static.zig"), + "preview stop before worker barrier": main.index("search.shutdown();") < main.index("workers.beginShutdownAndDrain(800)"), + "artwork cleanup after worker barrier": main.index("workers.beginShutdownAndDrain(800)") < main.index("search.deinitGallery();"), + "verified preview parser on production path": "pure.parseForId(" in preview and "pub fn parseForId(" in pure_preview, + "ready artwork fetch decisions use tested policy": "shouldFetchCover(" in _src("src/ui/components.zig"), + "saved titles reopen universal query": '.search =>' in _src("src/ui/home.zig") and 'submitQuery(query)' in _src("src/services/browser.zig"), + "saved native title uses portable search link": '"opal://search/{s}"' in search + and 'item.poster_url[0..item.poster_url_len], reopen)' in search, + "regressions registered": all(f'b.path("{file}")' in build for file in ("src/ui/search_gallery_pure.zig", "src/services/search_content_pure.zig", "src/services/search_preview_pure.zig")), + }, "Content shelves use stable artwork, explicit isolated previews and strict grouped sources") diff --git a/tests/features/test_source_layer.py b/tests/features/test_source_layer.py index 60b49410..a12a71ac 100644 --- a/tests/features/test_source_layer.py +++ b/tests/features/test_source_layer.py @@ -719,7 +719,8 @@ def test_resolver_sink(): "sink carries a query": "query: []const u8 = \"\"," in rv, # Real consumer — the sink is not a speculative abstraction. "warm exists": "pub fn warmQuery(" in rv, - "warm writes to the cache": "content_cache.put(cacheKey(&key_buf, query)" in rv, + "warm writes to the scoped cache": "content_cache.put(cacheKey(&key_buf, query, cache_scope)" in rv + and "current_scope.fingerprint != cache_scope.fingerprint" in rv, # Narrow on purpose: speculative work must not cost a full search. "warm is narrow": "resolveEztv(qbuf, query.len)" in rv and "resolveTorznab(qbuf" in rv, # Superseded-wave guard applies to the live path only; a warm has no wave. diff --git a/tests/features/test_universal_search.py b/tests/features/test_universal_search.py index 368e0f22..8dd7c424 100644 --- a/tests/features/test_universal_search.py +++ b/tests/features/test_universal_search.py @@ -50,9 +50,10 @@ def test_universal_search_fanout(): "live tv uses tab-independent entry": "iptv.searchInto(" in res and "searchIptv(" not in res, "live tv carries play hints": "item.http_ua" in res and "item.http_referrer" in res, "play hints survive the cache": res.count("http_ua") >= 5, - "routes through pure parsers": all( - m in res for m in ("music_jiosaavn_pure.zig", "radio_pure.zig", "podcasts_pure.zig") - ), + "routes through pure parsers": all(m in res for m in ("music_subsonic.zig", "radio_pure.zig", "podcasts_pure.zig")) + and all(m in _src("src/services/music_subsonic.zig") for m in ( + "music_jiosaavn_pure.zig", "js_pure.parseSongValue(", "pure.parseSongValue(", + "jf_pure.parseSongValue(", "px_pure.parseSongValue(", "au_pure.parseTrack(")), "workers spawned": all( f"if (sourceOn(.{b})) Spawn.go(resolve" in res for b in ("livetv", "music", "radio", "podcast") ), @@ -83,12 +84,18 @@ def test_universal_search_fanout(): ), # ── Result cap widened so late finishers aren't starved ── - "MAX_RESULTS constant": "pub const MAX_RESULTS: usize = 96;" in res, + "MAX_RESULTS constant": "pub const MAX_RESULTS: usize = content.MAX_ROWS;" in res + and "pub const MAX_ROWS: usize = 192;" in _src("src/services/search_content_pure.zig"), "no stale 64 cap": "result_count >= 64" not in res, "cache blob sized for fallback candidates": "SEARCH_BLOB_CAP: usize = MAX_RESULTS * @sizeOf(ResolvedItem)" in res, # ── Playback routing ── - "music/radio play direct": ".youtube, .stremio, .local, .music, .radio => {" in res, + "public video/radio play direct": ".youtube, .stremio, .local, .radio => {" in res, + "private music preserves connection identity": ".music => {" in res + and 'std.mem.startsWith(u8, url, "opal://music/")' in res + and "playUniversalRoute(url" in res, + "private music classified as library": '.music => std.mem.startsWith(u8, url, "opal://music/")' in res, + "podcasts resolve exact feed": "podcasts.searchPodcasts(item.url[0..item.url_len])" in res, "live tv replays its headers": ".livetv => {" in res and "loadContentDirectMetaHeaders(" in res and "originFromReferer(" in res, "podcast opens its tab": ".podcast => {" in res and "state.navigateToTab(.Podcasts)" in res, "plex resumes with alternate": ".plex => @import(\"plex.zig\").playSearchItem(" in res @@ -139,7 +146,7 @@ def test_universal_search_fanout(): missing = [k for k, ok in checks.items() if not ok] if missing: return "fail", "Fan-out incomplete: " + ", ".join(missing) - return "pass", "Universal search reaches Plex/live TV/music/radio/podcasts; cap 96; personal libraries rank first" + return "pass", "Universal search reaches Plex/live TV/music/radio/podcasts; cap 192; personal libraries rank first" @test("Synced lyrics (lrclib)", "Audio") diff --git a/tests/features/test_web_shell.py b/tests/features/test_web_shell.py index 23d8cf77..04fc4ed5 100644 --- a/tests/features/test_web_shell.py +++ b/tests/features/test_web_shell.py @@ -190,7 +190,7 @@ def test_web_discovery_and_party_workflows(): checks = { "merged search": "function renderUnifiedResults(" in ui and "api('/unified_search?q='" in ui and "api('/unified_search')" in ui, - "opaque source actions": "r.key" in ui and "r.queueable" in ui + "opaque source actions": "r.key" in ui and ("r.queueable" in ui or "row.queueable" in ui or "variant.queueable" in ui) and "apiMutation('/unified_search/' + action" in ui and "resolver.resolve(dq" in remote and "resolver.lockRemoteSnapshot()" in remote diff --git a/tests/features/test_web_ui.py b/tests/features/test_web_ui.py index 50b67902..b2b499bd 100644 --- a/tests/features/test_web_ui.py +++ b/tests/features/test_web_ui.py @@ -1146,7 +1146,7 @@ def test_torrent_files_and_hls(): "hls feature-tested": "window.Hls" in ui and "function openHls(" in ui, "hls loaded from vendor": 'src="vendor/hls.min.js"' in ui, # 543KB inlined would swamp the page — index.html must stay small. - "hls not inlined": len(ui) < 400_000, + "hls not inlined": len(_src("web/index.html")) < 400_000, "vendor file present": os.path.exists(os.path.join(PROJECT_DIR, "web/vendor/hls.min.js")), "bundled into the app": 'cp -R "$ROOT/web/."' in _src("scripts/build-app.sh"), # A live shim must be torn down or it keeps fetching segments. diff --git a/tests/test_content_maturity_live.py b/tests/test_content_maturity_live.py index 703ca7e8..d96957bb 100644 --- a/tests/test_content_maturity_live.py +++ b/tests/test_content_maturity_live.py @@ -180,6 +180,30 @@ def test_opds_advertised_authenticated_search_is_independent(self): rendered = json.dumps(result) self.assertNotIn('local-only',rendered) self.assertNotIn('fixture:local-only@',rendered) + def test_search_cancel_and_preview_bind_to_current_generation(self): + before = self.api('unified_search') + generation = before['generation'] + self.api('unified_search/cancel?generation='+str(generation), status=405) + self.api('unified_search/cancel', 'POST', status=400) + self.api('unified_search/cancel?generation='+str(generation), 'POST') + after = self.api('unified_search') + self.assertEqual(after['generation'], (generation+1) % (2**32)) + self.assertFalse(after['loading']) + self.api('unified_search/cancel?generation='+str(generation), 'POST', status=409) + self.assertEqual(after['generation'], self.api('unified_search')['generation']) + self.api('unified_search/preview', status=400) + self.api(f'unified_search/preview?generation={generation}&key=abc', status=409) + self.api(f"unified_search/preview?generation={after['generation']}&key=abc", status=404) + self.api(f"unified_search/preview?generation={after['generation']}&key=abc", 'POST', status=405) + self.assertNotIn('trailer_url', self.api('status')) + + def test_saved_search_title_uses_stable_library_identity(self): + identity = 'search&id=fixture-work' + self.api('library/item/action?kind='+identity+'&action=favorite&enabled=true&title=Fixture%20work', 'POST') + self.assertTrue(self.api('library/item?kind='+identity)['favorite']) + self.api('library/item/action?kind='+identity+'&action=favorite&enabled=false', 'POST') + self.assertFalse(self.api('library/item?kind='+identity)['favorite']) + def open_audiobook(self): self.api('abs/login','POST',{'server':self.fixture.base,'user':'fixture','pass':'local-only'}) self.wait('abs',lambda d:d['connected'] and not d['loading'] and len(d['libraries'])==1) diff --git a/tests/test_web_lifecycle.mjs b/tests/test_web_lifecycle.mjs index cd9be9c7..a6bf45d7 100644 --- a/tests/test_web_lifecycle.mjs +++ b/tests/test_web_lifecycle.mjs @@ -871,3 +871,221 @@ test('offline shell includes every script loaded by the production document', () const scripts = [...html.matchAll(/',summary:'