diff --git a/.changeset/library-membership-sync.md b/.changeset/library-membership-sync.md new file mode 100644 index 00000000..66488af4 --- /dev/null +++ b/.changeset/library-membership-sync.md @@ -0,0 +1,5 @@ +--- +"@ent-mcp/server": minor +--- + +Added a media library that tracks the titles you own and keeps the set in sync with your collection providers. diff --git a/.changeset/library-real-data.md b/.changeset/library-real-data.md new file mode 100644 index 00000000..1dcf3f79 --- /dev/null +++ b/.changeset/library-real-data.md @@ -0,0 +1,5 @@ +--- +"@ent-mcp/client": minor +--- + +The library page now browses your real owned collection across all five lenses with infinite scroll, quality chips, and faceted filters. diff --git a/.changeset/plugin-sdk-collection-membership.md b/.changeset/plugin-sdk-collection-membership.md new file mode 100644 index 00000000..382cb779 --- /dev/null +++ b/.changeset/plugin-sdk-collection-membership.md @@ -0,0 +1,5 @@ +--- +"@ent-mcp/plugin-sdk": minor +--- + +Metadata items can now carry collection membership. diff --git a/.changeset/tmdb-collection-membership.md b/.changeset/tmdb-collection-membership.md new file mode 100644 index 00000000..7140501d --- /dev/null +++ b/.changeset/tmdb-collection-membership.md @@ -0,0 +1,5 @@ +--- +"@ent-mcp/plugin-tmdb": minor +--- + +TMDB now reports a movie's franchise so it can be grouped with the rest of its collection. diff --git a/.fallowrc.json b/.fallowrc.json index 92bb7b98..5a025405 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -257,6 +257,14 @@ "name": "server-mod-home-internal", "patterns": ["apps/server/src/home/**"] }, + { + "name": "server-mod-library", + "patterns": ["apps/server/src/library/index.ts"] + }, + { + "name": "server-mod-library-internal", + "patterns": ["apps/server/src/library/**"] + }, { "name": "server-mod-media", "patterns": ["apps/server/src/media/index.ts"] @@ -313,6 +321,10 @@ "name": "server-schema-home", "patterns": ["apps/server/src/db/schema/home/**"] }, + { + "name": "server-schema-library", + "patterns": ["apps/server/src/db/schema/library/**"] + }, { "name": "server-schema-media", "patterns": ["apps/server/src/db/schema/media/**"] @@ -466,7 +478,14 @@ }, { "from": "client-feat-library", - "allow": ["client-shared-ui", "client-shared-components", "client-shared-lib", "shared-pkg"] + "allow": [ + "client-shared-ui", + "client-shared-components", + "client-shared-virtualized", + "client-shared-lib", + "client-shared-media", + "shared-pkg" + ] }, { "from": "client-feat-auth", @@ -725,6 +744,7 @@ "server-mod-auth", "server-mod-catalog", "server-mod-home", + "server-mod-library", "server-mod-media", "server-mod-notifications", "server-mod-preferences", @@ -744,6 +764,7 @@ "server-mod-auth", "server-mod-catalog", "server-mod-home", + "server-mod-library", "server-mod-media", "server-mod-notifications", "server-mod-preferences", @@ -770,6 +791,7 @@ "server-mod-auth", "server-mod-catalog", "server-mod-home", + "server-mod-library", "server-mod-media", "server-mod-notifications", "server-mod-preferences", @@ -925,6 +947,42 @@ "plugin-sdk" ] }, + { + "from": "server-mod-library", + "allow": [ + "server-mod-library-internal", + "server-mod-artwork", + "server-mod-auth", + "server-mod-catalog", + "server-mod-home", + "server-mod-media", + "server-mod-notifications", + "server-mod-preferences", + "server-mod-plugin-runtime", + "server-infra", + "shared-pkg", + "plugin-sdk" + ] + }, + { + "from": "server-mod-library-internal", + "allow": [ + "server-mod-library", + "server-mod-artwork", + "server-mod-auth", + "server-mod-catalog", + "server-mod-home", + "server-mod-media", + "server-mod-notifications", + "server-mod-preferences", + "server-mod-plugin-runtime", + "server-infra", + "server-schema-library", + "server-schema-infra", + "shared-pkg", + "plugin-sdk" + ] + }, { "from": "server-mod-watchlist", "allow": [ @@ -1114,6 +1172,7 @@ "server-schema-auth", "server-schema-catalog", "server-schema-home", + "server-schema-library", "server-schema-notifications", "server-schema-plugin-runtime", "server-schema-preferences", @@ -1135,6 +1194,10 @@ "from": "server-schema-home", "allow": ["server-schema-auth", "shared-pkg"] }, + { + "from": "server-schema-library", + "allow": ["server-schema-auth", "shared-pkg"] + }, { "from": "server-schema-notifications", "allow": ["server-schema-auth", "server-schema-plugin-runtime", "shared-pkg"] @@ -1157,6 +1220,7 @@ "server-schema-auth", "server-schema-catalog", "server-schema-home", + "server-schema-library", "server-schema-notifications", "server-schema-plugin-runtime", "server-schema-preferences", diff --git a/apps/client/messages/library/en.json b/apps/client/messages/library/en.json index a5c0836d..614eb5ae 100644 --- a/apps/client/messages/library/en.json +++ b/apps/client/messages/library/en.json @@ -60,15 +60,17 @@ ], "library_section_count": "{count} titles", "library_timeline_unknown": "Unknown year", - "library_row_prev": "Previous {decade}", - "library_row_next": "Next {decade}", "library_az_rail_label": "Jump to letter", "library_az_jump": "Jump to {letter}", + "library_decade_rail_label": "Jump to decade", + "library_decade_jump": "Jump to {decade}", "library_empty_title": "Nothing matches", "library_empty_description": "Try clearing a filter to see more titles.", "library_empty_reset": "Reset filters", "library_card_open": "Open details for {title}", "library_card_progress": "{percent}% watched", "library_load_error_title": "Library couldn't load", - "library_load_error_retry": "Retry" + "library_load_error_retry": "Retry", + "library_load_more": "Load more", + "library_loading_more": "Loading…" } diff --git a/apps/client/messages/library/fa.json b/apps/client/messages/library/fa.json index d2c4428a..aa08a243 100644 --- a/apps/client/messages/library/fa.json +++ b/apps/client/messages/library/fa.json @@ -60,15 +60,17 @@ ], "library_section_count": "{count} عنوان", "library_timeline_unknown": "سال نامشخص", - "library_row_prev": "{decade} قبلی", - "library_row_next": "{decade} بعدی", "library_az_rail_label": "پرش به حرف", "library_az_jump": "پرش به {letter}", + "library_decade_rail_label": "پرش به دهه", + "library_decade_jump": "پرش به {decade}", "library_empty_title": "چیزی یافت نشد", "library_empty_description": "برای دیدن عنوان‌های بیشتر یک فیلتر را حذف کنید.", "library_empty_reset": "بازنشانی فیلترها", "library_card_open": "نمایش جزئیات {title}", "library_card_progress": "{percent}٪ تماشا شده", "library_load_error_title": "کتابخانه بارگذاری نشد", - "library_load_error_retry": "تلاش مجدد" + "library_load_error_retry": "تلاش مجدد", + "library_load_more": "بارگذاری بیشتر", + "library_loading_more": "در حال بارگذاری…" } diff --git a/apps/client/src/features/library/__fixtures__/library-items.fixture.ts b/apps/client/src/features/library/__fixtures__/library-items.fixture.ts deleted file mode 100644 index 4b074c2e..00000000 --- a/apps/client/src/features/library/__fixtures__/library-items.fixture.ts +++ /dev/null @@ -1,482 +0,0 @@ -import type { MediaType } from "@ent-mcp/shared/media"; -import type { LibraryCollection, LibraryData, LibraryItem } from "../lib/types"; - -/** Deterministic placeholder artwork so the mock renders without a backend. */ -function poster(seed: string): string { - return `https://picsum.photos/seed/${seed}-p/400/600`; -} -function backdrop(seed: string): string { - return `https://picsum.photos/seed/${seed}-b/800/450`; -} - -interface Seed { - slug: string; - title: string; - year: number; - type: MediaType; - genres: string[]; - rating: number; - quality: string[]; - servers: string[]; - /** [watched, total] within-content position; omit for an untouched title. */ - progress?: [number, number]; -} - -const SEEDS: Seed[] = [ - { - slug: "arrival-tide", - title: "Arrival Tide", - year: 2021, - type: "movie", - genres: ["Sci-Fi", "Drama"], - rating: 8.1, - quality: ["4K HDR", "Atmos"], - servers: ["Plex", "Jellyfin"], - progress: [42, 100], - }, - { - slug: "amber-room", - title: "The Amber Room", - year: 2018, - type: "movie", - genres: ["Mystery", "Thriller"], - rating: 7.4, - quality: ["4K"], - servers: ["Plex"], - }, - { - slug: "blue-hour", - title: "Blue Hour", - year: 2024, - type: "tv", - genres: ["Drama"], - rating: 8.6, - quality: ["4K HDR"], - servers: ["Jellyfin"], - progress: [3, 8], - }, - { - slug: "border-songs", - title: "Border Songs", - year: 2016, - type: "movie", - genres: ["Drama", "Romance"], - rating: 7.0, - quality: ["HDR"], - servers: ["Emby"], - progress: [100, 100], - }, - { - slug: "cinder-fall", - title: "Cinder Fall", - year: 2022, - type: "tv", - genres: ["Horror", "Mystery"], - rating: 7.8, - quality: ["4K", "Atmos"], - servers: ["Plex"], - progress: [12, 20], - }, - { - slug: "copper-line", - title: "Copper Line", - year: 2009, - type: "movie", - genres: ["Crime", "Drama"], - rating: 8.3, - quality: ["HDR"], - servers: ["Plex", "Emby"], - }, - { - slug: "delta-bloom", - title: "Delta Bloom", - year: 2025, - type: "tv", - genres: ["Sci-Fi"], - rating: 8.9, - quality: ["4K HDR", "Atmos"], - servers: ["Jellyfin"], - }, - { - slug: "dust-and-light", - title: "Dust and Light", - year: 2013, - type: "movie", - genres: ["Documentary"], - rating: 7.6, - quality: ["4K"], - servers: ["Plex"], - progress: [100, 100], - }, - { - slug: "echo-meridian", - title: "Echo Meridian", - year: 2020, - type: "movie", - genres: ["Sci-Fi", "Thriller"], - rating: 7.2, - quality: ["4K HDR"], - servers: ["Plex", "Jellyfin"], - progress: [18, 100], - }, - { - slug: "ember-court", - title: "Ember Court", - year: 2019, - type: "tv", - genres: ["Drama", "Crime"], - rating: 8.0, - quality: ["HDR"], - servers: ["Emby"], - progress: [40, 40], - }, - { - slug: "fallow-fields", - title: "Fallow Fields", - year: 2011, - type: "movie", - genres: ["Drama"], - rating: 6.9, - quality: ["4K"], - servers: ["Plex"], - }, - { - slug: "frost-signal", - title: "Frost Signal", - year: 2023, - type: "tv", - genres: ["Sci-Fi", "Mystery"], - rating: 8.4, - quality: ["4K HDR", "Atmos"], - servers: ["Jellyfin", "Plex"], - progress: [6, 10], - }, - { - slug: "glass-harbor", - title: "Glass Harbor", - year: 2017, - type: "movie", - genres: ["Romance", "Drama"], - rating: 7.1, - quality: ["HDR"], - servers: ["Emby"], - }, - { - slug: "gravel-roads", - title: "Gravel Roads", - year: 2008, - type: "movie", - genres: ["Crime"], - rating: 7.9, - quality: ["4K"], - servers: ["Plex"], - progress: [100, 100], - }, - { - slug: "harrow-vale", - title: "Harrow Vale", - year: 2024, - type: "tv", - genres: ["Horror"], - rating: 7.5, - quality: ["4K HDR"], - servers: ["Jellyfin"], - }, - { - slug: "hollow-crown", - title: "The Hollow Crown", - year: 2015, - type: "tv", - genres: ["Drama"], - rating: 8.7, - quality: ["HDR", "Atmos"], - servers: ["Plex", "Emby"], - progress: [22, 30], - }, - { - slug: "ivory-static", - title: "Ivory Static", - year: 2022, - type: "movie", - genres: ["Thriller"], - rating: 6.8, - quality: ["4K"], - servers: ["Plex"], - }, - { - slug: "iron-meridian", - title: "Iron Meridian", - year: 1998, - type: "movie", - genres: ["Sci-Fi", "Crime"], - rating: 8.5, - quality: ["HDR"], - servers: ["Emby"], - progress: [100, 100], - }, - { - slug: "junewood", - title: "Junewood", - year: 2021, - type: "tv", - genres: ["Comedy", "Drama"], - rating: 8.2, - quality: ["4K"], - servers: ["Jellyfin"], - progress: [5, 16], - }, - { - slug: "kestrel-down", - title: "Kestrel Down", - year: 2019, - type: "movie", - genres: ["Documentary", "Drama"], - rating: 7.7, - quality: ["4K HDR"], - servers: ["Plex"], - }, - { - slug: "lantern-mile", - title: "Lantern Mile", - year: 2003, - type: "movie", - genres: ["Romance"], - rating: 7.3, - quality: ["HDR"], - servers: ["Plex", "Emby"], - progress: [100, 100], - }, - { - slug: "low-tide", - title: "Low Tide", - year: 2025, - type: "tv", - genres: ["Drama", "Mystery"], - rating: 8.8, - quality: ["4K HDR", "Atmos"], - servers: ["Jellyfin"], - progress: [1, 6], - }, - { - slug: "marble-halls", - title: "Marble Halls", - year: 2021, - type: "movie", - genres: ["Sci-Fi", "Drama", "Mystery"], - rating: 8.4, - quality: ["4K HDR", "Atmos"], - servers: ["Plex", "Jellyfin"], - progress: [42, 100], - }, - { - slug: "midnight-archive", - title: "Midnight Archive", - year: 2014, - type: "tv", - genres: ["Crime", "Thriller"], - rating: 8.1, - quality: ["4K"], - servers: ["Emby"], - progress: [48, 48], - }, - { - slug: "north-passage", - title: "North Passage", - year: 2007, - type: "movie", - genres: ["Documentary"], - rating: 7.5, - quality: ["HDR"], - servers: ["Plex"], - }, - { - slug: "open-water", - title: "Open Water", - year: 2020, - type: "movie", - genres: ["Thriller", "Drama"], - rating: 6.6, - quality: ["4K"], - servers: ["Jellyfin"], - progress: [70, 100], - }, - { - slug: "pale-engine", - title: "Pale Engine", - year: 2023, - type: "tv", - genres: ["Sci-Fi"], - rating: 8.3, - quality: ["4K HDR"], - servers: ["Plex"], - progress: [9, 12], - }, - { - slug: "quarry-light", - title: "Quarry Light", - year: 2012, - type: "movie", - genres: ["Drama"], - rating: 7.0, - quality: ["HDR"], - servers: ["Emby"], - }, - { - slug: "river-glass", - title: "River Glass", - year: 2018, - type: "movie", - genres: ["Romance", "Comedy"], - rating: 7.2, - quality: ["4K"], - servers: ["Plex", "Jellyfin"], - progress: [100, 100], - }, - { - slug: "salt-flats", - title: "Salt Flats", - year: 1989, - type: "movie", - genres: ["Crime", "Drama"], - rating: 8.0, - quality: ["HDR"], - servers: ["Emby"], - }, - { - slug: "silent-orbit", - title: "Silent Orbit", - year: 2024, - type: "tv", - genres: ["Sci-Fi", "Horror"], - rating: 8.5, - quality: ["4K HDR", "Atmos"], - servers: ["Jellyfin", "Plex"], - progress: [2, 8], - }, - { - slug: "tidal-court", - title: "Tidal Court", - year: 2016, - type: "tv", - genres: ["Drama"], - rating: 7.9, - quality: ["4K"], - servers: ["Plex"], - progress: [33, 33], - }, - { - slug: "umbra-line", - title: "Umbra Line", - year: 2022, - type: "movie", - genres: ["Mystery", "Thriller"], - rating: 7.6, - quality: ["4K HDR"], - servers: ["Emby"], - }, - { - slug: "violet-static", - title: "Violet Static", - year: 2010, - type: "movie", - genres: ["Sci-Fi"], - rating: 7.4, - quality: ["HDR"], - servers: ["Plex"], - progress: [55, 100], - }, - { - slug: "winter-harbor", - title: "Winter Harbor", - year: 2025, - type: "tv", - genres: ["Drama", "Crime"], - rating: 8.6, - quality: ["4K HDR", "Atmos"], - servers: ["Jellyfin"], - }, - { - slug: "zephyr-road", - title: "Zephyr Road", - year: 2006, - type: "movie", - genres: ["Documentary", "Drama"], - rating: 7.8, - quality: ["4K"], - servers: ["Plex", "Emby"], - progress: [100, 100], - }, -]; - -function toItem(seed: Seed): LibraryItem { - const item: LibraryItem = { - id: `${seed.type}:${seed.slug}`, - tmdbId: seed.slug, - mediaType: seed.type, - title: seed.title, - year: seed.year, - poster: poster(seed.slug), - backdrop: backdrop(seed.slug), - genres: seed.genres, - rating: seed.rating, - tags: seed.quality, - status: "available", - availability: { - hasAnyServerCopy: true, - requestEligible: false, - servers: seed.servers.map((label) => ({ id: label.toLowerCase(), label })), - }, - }; - if (seed.progress) { - item.progress = { watched: seed.progress[0], total: seed.progress[1] }; - } - return item; -} - -export const SAMPLE_LIBRARY_ITEMS: LibraryItem[] = SEEDS.map(toItem); - -/** - * Franchise/saga collections (the TMDB "belongs to collection" sense, e.g. the - * Dune or Blade Runner sets) — each groups the entries of one fictional series, - * not a mood. Every set keeps ≥4 members so the poster fan always renders four. - */ -export const SAMPLE_LIBRARY_COLLECTIONS: LibraryCollection[] = [ - { - id: "coll:meridian-saga", - title: "The Meridian Saga", - itemIds: [ - "movie:echo-meridian", - "movie:iron-meridian", - "tv:delta-bloom", - "tv:silent-orbit", - "tv:pale-engine", - ], - }, - { - id: "coll:tideline", - title: "Tideline", - itemIds: [ - "movie:arrival-tide", - "tv:low-tide", - "tv:tidal-court", - "movie:open-water", - "movie:glass-harbor", - ], - }, - { - id: "coll:static", - title: "Static", - itemIds: ["movie:ivory-static", "movie:violet-static", "tv:frost-signal", "movie:umbra-line"], - }, - { - id: "coll:hollow-crown", - title: "The Hollow Crown", - itemIds: ["tv:hollow-crown", "tv:ember-court", "tv:midnight-archive", "tv:harrow-vale"], - }, -]; - -/** The full mocked library payload backing the page until the API lands. */ -export const SAMPLE_LIBRARY: LibraryData = { - items: SAMPLE_LIBRARY_ITEMS, - collections: SAMPLE_LIBRARY_COLLECTIONS, -}; diff --git a/apps/client/src/features/library/__tests__/library-data.test.ts b/apps/client/src/features/library/__tests__/library-data.test.ts index 92e1b38b..5c1514db 100644 --- a/apps/client/src/features/library/__tests__/library-data.test.ts +++ b/apps/client/src/features/library/__tests__/library-data.test.ts @@ -1,29 +1,28 @@ import { describe, expect, it } from "vite-plus/test"; -import { - applyLibraryFilters, - computeFacetCounts, - countActiveFilters, - watchedStateOf, -} from "../lib/filtering"; -import { groupByDecade, groupByLetter, groupByQuality, groupByServer } from "../lib/grouping"; -import { EMPTY_FILTERS, type LibraryFilters, type LibraryItem } from "../lib/types"; +import type { CompactMediaItem } from "@ent-mcp/shared/media"; +import { countActiveFilters, watchedStateOf } from "../lib/filtering"; +import { type LibrarySectionEntry, toSectionEntries, toSections } from "../lib/section-groups"; +import { EMPTY_FILTERS, type LibraryFilters } from "../lib/types"; /** Minimal item builder so each test states only the fields it exercises. */ -function item(overrides: Partial & Pick): LibraryItem { +function item( + overrides: Partial & Pick, +): CompactMediaItem { return { tmdbId: overrides.id, mediaType: "movie", year: 2020, ...overrides, - } as LibraryItem; + } as CompactMediaItem; } -function withServers(labels: string[]): LibraryItem["availability"] { - return { - hasAnyServerCopy: true, - requestEligible: false, - servers: labels.map((label) => ({ id: label, label })), - }; +/** Collapse an entry list to a comparable `[type, key|id, sectionKey?]` shape. */ +function shape(entries: LibrarySectionEntry[]) { + return entries.map((e) => + e.type === "header" + ? { type: "header", key: e.key, label: e.label } + : { type: "item", id: e.item.id, sectionKey: e.sectionKey }, + ); } const filters = (overrides: Partial): LibraryFilters => ({ @@ -33,7 +32,9 @@ const filters = (overrides: Partial): LibraryFilters => ({ describe("watchedStateOf", () => { // The watched facet depends on a correct three-way split; an off-by-one - // here would mis-bucket half-watched series as finished. + // here would mis-bucket half-watched series as finished. The card's own + // watched badge reads this directly even though the server now drives the + // filter axis, so the classification must stay correct on the client. it("classifies untouched, partial, and finished progress", () => { expect(watchedStateOf(item({ id: "a", title: "A" }))).toBe("unwatched"); expect(watchedStateOf(item({ id: "b", title: "B", progress: { watched: 0, total: 10 } }))).toBe( @@ -48,156 +49,152 @@ describe("watchedStateOf", () => { }); }); -describe("applyLibraryFilters", () => { - const items = [ - item({ - id: "tv:dune", - title: "Dune", - mediaType: "tv", - genres: ["Sci-Fi"], - tags: ["4K HDR"], - availability: withServers(["Plex"]), - }), - item({ - id: "movie:heat", - title: "Heat", - mediaType: "movie", - genres: ["Crime"], - tags: ["HDR"], - availability: withServers(["Jellyfin"]), - }), - item({ - id: "movie:drive", - title: "Drive", - mediaType: "movie", - genres: ["Crime", "Drama"], - tags: ["4K"], - availability: withServers(["Plex", "Jellyfin"]), - }), - ]; - - it("filters by kind, genre, quality and server independently", () => { - expect(applyLibraryFilters(items, filters({ kinds: ["tv"] })).map((i) => i.id)).toEqual([ - "tv:dune", - ]); - expect(applyLibraryFilters(items, filters({ genres: ["Crime"] })).map((i) => i.id)).toEqual([ - "movie:heat", - "movie:drive", - ]); - expect(applyLibraryFilters(items, filters({ qualities: ["4K HDR"] })).map((i) => i.id)).toEqual( - ["tv:dune"], - ); - expect(applyLibraryFilters(items, filters({ servers: ["Jellyfin"] })).map((i) => i.id)).toEqual( - ["movie:heat", "movie:drive"], - ); - }); - - it("filters by watched state — the one axis routed through watchedStateOf", () => { - const byProgress = [ - item({ id: "unwatched", title: "U", progress: { watched: 0, total: 10 } }), - item({ id: "partial", title: "P", progress: { watched: 4, total: 10 } }), - item({ id: "watched", title: "W", progress: { watched: 10, total: 10 } }), - ]; - expect( - applyLibraryFilters(byProgress, filters({ watched: ["partial"] })).map((i) => i.id), - ).toEqual(["partial"]); - expect( - applyLibraryFilters(byProgress, filters({ watched: ["unwatched", "watched"] })).map( - (i) => i.id, - ), - ).toEqual(["unwatched", "watched"]); - }); -}); - -describe("computeFacetCounts", () => { - it("counts each option once per item, even with multi-valued facets", () => { - const items = [ - item({ - id: "movie:a", - title: "A", - genres: ["Drama"], - tags: ["4K", "HDR"], - availability: withServers(["Plex"]), - }), - item({ - id: "movie:b", - title: "B", - genres: ["Drama"], - tags: ["HDR"], - availability: withServers(["Plex"]), - }), - ]; - const counts = computeFacetCounts(items); - expect(counts.genres.Drama).toBe(2); - expect(counts.qualities.HDR).toBe(2); - expect(counts.qualities["4K"]).toBe(1); - expect(counts.servers.Plex).toBe(2); - expect(counts.watched.unwatched).toBe(2); - }); -}); - describe("countActiveFilters", () => { + // The trigger badge and the "clear all" enabled state both key off this sum; + // dropping an axis here would silently hide active filters from the user. it("sums selections across every axis", () => { expect(countActiveFilters(EMPTY_FILTERS)).toBe(0); expect(countActiveFilters(filters({ kinds: ["movie"], genres: ["Drama", "Crime"] }))).toBe(3); }); }); -describe("grouping", () => { - it("buckets titles A–Z with a trailing # group, alphabetically sorted", () => { - const groups = groupByLetter([ - item({ id: "1", title: "Zephyr" }), - item({ id: "2", title: "9 Songs" }), - item({ id: "3", title: "Arrival" }), +describe("toSectionEntries", () => { + // The server now returns a flat sorted stream and the client splices headers + // on group-key change. These tests pin the per-lens key derivation and the + // "header only when the key changes" invariant — the visual grouping the old + // server-side `groupBy*` functions used to produce now lives entirely here. + + it("inserts an A→Z header on first-letter change, article-stripped", () => { + const entries = toSectionEntries( + [ + item({ id: "1", title: "An Anvil" }), + item({ id: "2", title: "Avalanche" }), + item({ id: "3", title: "Bridge" }), + ], + "az", + ); + // "An Anvil" files under A (article stripped); A then B, one header each. + expect(shape(entries)).toEqual([ + { type: "header", key: "A", label: "A" }, + { type: "item", id: "1", sectionKey: "A" }, + { type: "item", id: "2", sectionKey: "A" }, + { type: "header", key: "B", label: "B" }, + { type: "item", id: "3", sectionKey: "B" }, ]); - expect(groups.map((g) => g.key)).toEqual(["A", "Z", "#"]); }); - it("buckets and sorts by the title minus a leading article, like media servers", () => { - const groups = groupByLetter([ - item({ id: "1", title: "The Amber Room" }), - item({ id: "2", title: "An Anvil" }), - item({ id: "3", title: "Avalanche" }), - ]); - // "The Amber Room" files under A (not T); within A the stripped titles sort - // Amber < Anvil < Avalanche. - expect(groups.map((g) => g.key)).toEqual(["A"]); - expect(groups[0]?.items.map((i) => i.id)).toEqual(["1", "2", "3"]); + it("buckets non-alphabetic leads under # for the A→Z lens", () => { + const entries = toSectionEntries([item({ id: "1", title: "9 Songs" })], "az"); + expect(entries[0]).toEqual({ type: "header", key: "#", label: "#" }); }); - it("orders decades newest-first", () => { - const groups = groupByDecade([ - item({ id: "1", title: "Old", year: 1994 }), - item({ id: "2", title: "New", year: 2021 }), - item({ id: "3", title: "Mid", year: 2008 }), + it("buckets timeline headers by stable, i18n-free decade keys (label === key)", () => { + // section-groups stays locale-free: the timeline header key AND label are + // the same stable token (the decade's lead year, or "unknown"). The visible + // "2020s" / "Unknown year" text is resolved at the render boundary by + // `timelineSectionLabel`, so this layer must NOT carry display copy. + const entries = toSectionEntries( + [ + item({ id: "1", title: "New", year: 2021 }), + item({ id: "2", title: "Old", year: 1994 }), + item({ id: "3", title: "Yearless", year: undefined }), + ], + "timeline", + ); + const headers = entries.filter((e) => e.type === "header"); + expect(headers).toEqual([ + { type: "header", key: "2020", label: "2020" }, + { type: "header", key: "1990", label: "1990" }, + { type: "header", key: "unknown", label: "unknown" }, ]); - expect(groups.map((g) => g.label)).toEqual(["2020s", "2000s", "1990s"]); }); - it("collects yearless titles into a trailing unknown bucket so they stay visible", () => { - const groups = groupByDecade([ - item({ id: "1", title: "Dated", year: 2021 }), - item({ id: "2", title: "Yearless", year: undefined }), + it("uses the server-supplied section and keys repeats by id+section for server/quality", () => { + // The server/quality lenses repeat a title once per section (json_each), + // so the same id appears under two headers — the list MUST key on + // id+sectionKey, not id alone, or React would collapse the duplicate. + const entries = toSectionEntries( + [ + item({ id: "tv:1", title: "Dune", section: { id: "plex", label: "Plex" } }), + item({ id: "tv:1", title: "Dune", section: { id: "jelly", label: "Jellyfin" } }), + ], + "server", + ); + expect(shape(entries)).toEqual([ + { type: "header", key: "plex", label: "Plex" }, + { type: "item", id: "tv:1", sectionKey: "plex" }, + { type: "header", key: "jelly", label: "Jellyfin" }, + { type: "item", id: "tv:1", sectionKey: "jelly" }, ]); - expect(groups.map((g) => g.key)).toEqual(["2020", "unknown"]); - expect(groups.at(-1)?.items.map((i) => i.id)).toEqual(["2"]); }); - it("renders a yearless-only set instead of dropping every row", () => { - const groups = groupByDecade([item({ id: "1", title: "Yearless", year: undefined })]); - expect(groups.map((g) => g.key)).toEqual(["unknown"]); + it("emits no duplicate header while the group key holds steady", () => { + const entries = toSectionEntries( + [ + item({ id: "1", title: "Apple" }), + item({ id: "2", title: "Acorn" }), + item({ id: "3", title: "Apex" }), + ], + "az", + ); + expect(entries.filter((e) => e.type === "header")).toHaveLength(1); + }); +}); + +describe("toSections", () => { + // `toSections` re-shapes the flat header-delimited entry stream into discrete + // sections so each lens renders a `SectionHead` over its own virtualized grid. + // The split must keep every item under the right header and carry `sectionKey` + // so the grid keys repeated titles (server/quality) by `id + sectionKey`. + + it("groups items under their preceding header in stream order", () => { + const sections = toSections( + toSectionEntries( + [ + item({ id: "1", title: "Apple" }), + item({ id: "2", title: "Acorn" }), + item({ id: "3", title: "Bridge" }), + ], + "az", + ), + ); + expect( + sections.map((section) => ({ + key: section.key, + label: section.label, + ids: section.items.map((entry) => entry.item.id), + })), + ).toEqual([ + { key: "A", label: "A", ids: ["1", "2"] }, + { key: "B", label: "B", ids: ["3"] }, + ]); }); - it("orders quality tiers by descending fidelity and lists an item under each tag", () => { - const groups = groupByQuality([item({ id: "1", title: "A", tags: ["Atmos", "4K HDR"] })]); - expect(groups.map((g) => g.key)).toEqual(["4K HDR", "Atmos"]); - expect(groups.every((g) => g.items.length === 1)).toBe(true); + it("keeps a title in both sections it repeats across for server/quality", () => { + // The json_each expansion emits one row per (title, section); the split must + // preserve the repeat so the same title appears under each server section. + const sections = toSections( + toSectionEntries( + [ + item({ id: "tv:1", title: "Dune", section: { id: "plex", label: "Plex" } }), + item({ id: "tv:1", title: "Dune", section: { id: "jelly", label: "Jellyfin" } }), + ], + "server", + ), + ); + expect(sections).toHaveLength(2); + expect(sections[0]).toMatchObject({ key: "plex", label: "Plex" }); + expect(sections[0]?.items[0]).toEqual({ + item: expect.objectContaining({ id: "tv:1" }), + sectionKey: "plex", + }); + expect(sections[1]?.items[0]).toEqual({ + item: expect.objectContaining({ id: "tv:1" }), + sectionKey: "jelly", + }); }); - it("lists a title under every server that hosts it", () => { - const groups = groupByServer([ - item({ id: "1", title: "A", availability: withServers(["Plex", "Jellyfin"]) }), - ]); - expect(groups.map((g) => g.key)).toEqual(["Jellyfin", "Plex"]); + it("returns no sections for an empty stream", () => { + expect(toSections([])).toEqual([]); }); }); diff --git a/apps/client/src/features/library/__tests__/library-facets.test.ts b/apps/client/src/features/library/__tests__/library-facets.test.ts new file mode 100644 index 00000000..15c48a98 --- /dev/null +++ b/apps/client/src/features/library/__tests__/library-facets.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { LibraryFacetCounts } from "@ent-mcp/shared/library"; +import { deriveFacetValues, libraryOwnedTotal } from "../lib/facets"; +import { shouldFetchNext } from "../components/lenses/library-section-grid"; + +/** + * Build a partial facet-counts payload with the fields a test exercises; the + * untouched axes default to empty so each case states only what it asserts. + */ +function counts(overrides: Partial): LibraryFacetCounts { + return { + kinds: { movie: 0, tv: 0 }, + genres: {}, + qualities: {}, + servers: {}, + watched: { watched: 0, partial: 0, unwatched: 0 }, + letters: [], + decades: [], + ...overrides, + } as LibraryFacetCounts; +} + +describe("shouldFetchNext", () => { + // The infinite-scroll sentinel and the keyboard "load more" button both route + // through this predicate, so the two affordances can never disagree. The guard + // exists to stop a second page firing while the first is mid-flight; if it ever + // returned true while fetching, the list would double-request and duplicate + // rows. The truth table below is the whole contract — every cell must hold. + it("fetches ONLY when another page exists and none is in flight", () => { + expect(shouldFetchNext(true, false)).toBe(true); + // A fetch already running: holding the cursor is what prevents the dupe. + expect(shouldFetchNext(true, true)).toBe(false); + // No further cursor: nothing to fetch regardless of in-flight state. + expect(shouldFetchNext(false, false)).toBe(false); + expect(shouldFetchNext(false, true)).toBe(false); + }); +}); + +describe("deriveFacetValues", () => { + // The filter popover's option lists are the sorted key sets of the count maps + // (the server only emits a bucket that has at least one owned title). Sorting + // is pinned to `en` collation so the fa build keeps the same option order, and + // an empty axis must yield `[]` so the popover renders no stray group. + it("returns each axis's keys sorted by key, empty axes as []", () => { + const values = deriveFacetValues( + counts({ genres: { Drama: 3, Crime: 1 }, qualities: {}, servers: { Plex: 2 } }), + ); + // Sorted by KEY (Crime < Drama), NOT by count — a count-sort would put Drama + // first here, so the order pins the key-collation contract. + expect(values.genres).toEqual(["Crime", "Drama"]); + expect(values.servers).toEqual(["Plex"]); + expect(values.qualities).toEqual([]); + }); + + it("returns all-empty before the (non-blocking) facets read lands", () => { + // The facets query is non-blocking, so the popover renders while `counts` + // is still undefined; it must show no options rather than throw. + expect(deriveFacetValues(undefined)).toEqual({ genres: [], qualities: [], servers: [] }); + }); +}); + +describe("libraryOwnedTotal", () => { + // The header eyebrow shows the whole-library owned total — the sum of the + // per-KIND facet counts, matching the unfiltered facets semantics. It must NOT + // sum genres/servers/etc. (those double-count titles across multi-valued axes), + // and it must read 0 before the facets land so the eyebrow shows nothing + // rather than a partial number. + it("sums the per-kind counts only", () => { + expect(libraryOwnedTotal(counts({ kinds: { movie: 2, tv: 1 } }))).toBe(3); + }); + + it("ignores the multi-valued axes (not a sum of genres)", () => { + // Genres/servers expand per title, so summing them would over-count; the + // total stays kinds-only even when those axes carry larger numbers. + const total = libraryOwnedTotal( + counts({ kinds: { movie: 2, tv: 1 }, genres: { Drama: 9, Crime: 9 }, servers: { Plex: 9 } }), + ); + expect(total).toBe(3); + }); + + it("returns 0 before the facets land", () => { + expect(libraryOwnedTotal(undefined)).toBe(0); + }); +}); diff --git a/apps/client/src/features/library/__tests__/library-fetchers.test.ts b/apps/client/src/features/library/__tests__/library-fetchers.test.ts new file mode 100644 index 00000000..05dd7641 --- /dev/null +++ b/apps/client/src/features/library/__tests__/library-fetchers.test.ts @@ -0,0 +1,165 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import type { Page } from "@ent-mcp/shared/media"; + +// Mock the Hono client (rule 11: mock the fetchers' transport, never React +// Query). Both library transports are covered: the unified media-source +// resolver the four item lenses ride, and the library-owned collections + +// facets endpoints. +const apiMock = vi.hoisted(() => ({ + sourceGet: vi.fn(), + collectionsGet: vi.fn(), + facetsGet: vi.fn(), +})); + +vi.mock("@/shared/lib/api", () => ({ + api: { + media: { sources: { ":sourceId": { $get: (args: unknown) => apiMock.sourceGet(args) } } }, + library: { + collections: { $get: (args: unknown) => apiMock.collectionsGet(args) }, + facets: { $get: () => apiMock.facetsGet() }, + }, + }, +})); + +const { defineLensSource, fetchCollectionsPage, fetchFacets, filtersToQuery } = + await import("../lib/fetchers"); +const { LibraryApiError } = await import("../lib/types"); +const { EMPTY_FILTERS } = await import("../lib/types"); + +const PAGE: Page = { items: [], cursor: null, partial: false }; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +beforeEach(() => { + apiMock.sourceGet.mockReset(); + apiMock.collectionsGet.mockReset(); + apiMock.facetsGet.mockReset(); +}); + +afterEach(() => vi.restoreAllMocks()); + +describe("defineLensSource", () => { + // The lens-page hook reads through this descriptor; the cache key and the + // request both derive from its sourceId + params, so a wrong sourceId or a + // dropped filter axis would silently read the wrong list. + it("targets the library- media source with firstPage cursor semantics", () => { + const source = defineLensSource("az", EMPTY_FILTERS); + expect(source.sourceId).toBe("library-az"); + expect(source.mode).toBe("infinite"); + // Bad/absent cursor must fall to page one (matches the server registration), + // not 400 the route. + expect(source.cursorOnNull).toBe("firstPage"); + }); + + it("builds the per-lens id for each item lens", () => { + expect(defineLensSource("timeline", EMPTY_FILTERS).sourceId).toBe("library-timeline"); + expect(defineLensSource("server", EMPTY_FILTERS).sourceId).toBe("library-server"); + expect(defineLensSource("quality", EMPTY_FILTERS).sourceId).toBe("library-quality"); + }); + + it("threads the active filters (first value per axis) into the request and folds the cursor", async () => { + apiMock.sourceGet.mockResolvedValueOnce({ ok: true, json: async () => PAGE }); + const source = defineLensSource("az", { + ...EMPTY_FILTERS, + kinds: ["movie"], + genres: ["Drama", "Crime"], + watched: ["partial"], + }); + await source.fetchPage(source.params, "cursor-2"); + // The unified resolver collapses repeated params to one, so each axis is sent + // as its FIRST selected value; the cursor rides as a query param. + expect(apiMock.sourceGet).toHaveBeenCalledWith({ + param: { sourceId: "library-az" }, + query: { kinds: "movie", genres: "Drama", watched: "partial", cursor: "cursor-2" }, + }); + }); + + it("omits the cursor on the first page", async () => { + apiMock.sourceGet.mockResolvedValueOnce({ ok: true, json: async () => PAGE }); + const source = defineLensSource("timeline", EMPTY_FILTERS); + await source.fetchPage(source.params, null); + expect(apiMock.sourceGet).toHaveBeenCalledWith({ + param: { sourceId: "library-timeline" }, + query: {}, + }); + }); +}); + +describe("filtersToQuery", () => { + // Collections + facets ride their own routes (validated with c.req.queries()), + // so they honor multi-value via repeated params — the encoding must keep arrays + // and drop empty axes (an absent param is an open axis server-side). + it("keeps multi-value axes as arrays and drops empty ones", () => { + expect( + filtersToQuery({ + ...EMPTY_FILTERS, + kinds: ["movie", "tv"], + genres: ["Drama"], + }), + ).toEqual({ kinds: ["movie", "tv"], genres: ["Drama"] }); + }); + + it("returns a bare query for a fully open library", () => { + expect(filtersToQuery(EMPTY_FILTERS)).toEqual({}); + }); +}); + +describe("fetchCollectionsPage", () => { + it("requests /library/collections with the filter query and threads the cursor", async () => { + const body = { + collections: [{ id: "collection:10", title: "Saga", count: 3, preview: [] }], + cursor: "next", + }; + apiMock.collectionsGet.mockResolvedValueOnce(jsonResponse(body)); + const page = await fetchCollectionsPage({ ...EMPTY_FILTERS, servers: ["Plex"] }, "cur-1"); + expect(apiMock.collectionsGet).toHaveBeenCalledWith({ + query: { servers: ["Plex"], cursor: "cur-1" }, + }); + expect(page).toEqual(body); + }); + + it("omits the cursor on the first page", async () => { + apiMock.collectionsGet.mockResolvedValueOnce(jsonResponse({ collections: [], cursor: null })); + await fetchCollectionsPage(EMPTY_FILTERS, null); + expect(apiMock.collectionsGet).toHaveBeenCalledWith({ query: {} }); + }); + + it("wraps a non-OK response in a LibraryApiError carrying status + code", async () => { + apiMock.collectionsGet.mockResolvedValueOnce( + jsonResponse({ code: "library.bad_cursor", message: "nope" }, 400), + ); + const err = await fetchCollectionsPage(EMPTY_FILTERS, "bad").catch((e: unknown) => e); + expect(err).toBeInstanceOf(LibraryApiError); + expect(err).toMatchObject({ status: 400, code: "library.bad_cursor" }); + }); +}); + +describe("fetchFacets", () => { + it("requests /library/facets and returns the parsed totals", async () => { + const facets = { + kinds: { movie: 2, tv: 1 }, + genres: { Drama: 3 }, + qualities: {}, + servers: {}, + watched: { watched: 0, partial: 1, unwatched: 2 }, + letters: ["A", "D"], + decades: [2020, 1990], + }; + apiMock.facetsGet.mockResolvedValueOnce(jsonResponse(facets)); + await expect(fetchFacets()).resolves.toEqual(facets); + expect(apiMock.facetsGet).toHaveBeenCalledTimes(1); + }); + + it("wraps a non-OK response in a LibraryApiError", async () => { + apiMock.facetsGet.mockResolvedValueOnce(jsonResponse({}, 503)); + const err = await fetchFacets().catch((e: unknown) => e); + expect(err).toBeInstanceOf(LibraryApiError); + expect((err as InstanceType).status).toBe(503); + }); +}); diff --git a/apps/client/src/features/library/__tests__/library-lenses.test.tsx b/apps/client/src/features/library/__tests__/library-lenses.test.tsx new file mode 100644 index 00000000..b7e1b5af --- /dev/null +++ b/apps/client/src/features/library/__tests__/library-lenses.test.tsx @@ -0,0 +1,263 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import type { ReactNode } from "react"; +import { cleanup, render, screen, within } from "@testing-library/react"; +import type { LibraryCollection } from "@ent-mcp/shared/library"; +import type { CompactMediaItem } from "@ent-mcp/shared/media"; + +/** + * Mock the shared `VirtualGrid` so these tests exercise the lenses' own wiring + * without a real virtualizer or layout (happy-dom reports zero sizes, which the + * real grid turns into zero mounted rows). The mock renders EVERY item through + * the lens-supplied `renderItem`/`getKey` synchronously and records the props + * each grid instance was handed, so a test can assert which section wired + * `onEndReached` and what key shape `getKey` produced. We never mock React + * Query — the lenses take their data as props. + */ +interface CapturedGrid { + items: readonly unknown[]; + getKey: (item: unknown, index: number) => string; + onEndReached?: () => void; +} +const grids = vi.hoisted(() => ({ captured: [] as CapturedGrid[] })); + +vi.mock("@/shared/components/virtualized", () => ({ + VirtualGrid: ({ + items, + getKey, + renderItem, + onEndReached, + }: { + items: readonly T[]; + getKey: (item: T, index: number) => string; + renderItem: (item: T, index: number) => ReactNode; + onEndReached?: () => void; + }) => { + grids.captured.push({ + items, + getKey: getKey as (item: unknown, index: number) => string, + onEndReached, + }); + return ( +
+ {items.map((item, index) => ( +
+ {renderItem(item, index)} +
+ ))} +
+ ); + }, +})); + +const { LibrarySectionGrid } = await import("../components/lenses/library-section-grid"); +const { CollectionsLens } = await import("../components/lenses/collections-lens"); +const { AzLens } = await import("../components/lenses/az-lens"); +const { TimelineLens } = await import("../components/lenses/timeline-lens"); +const { LibraryCard } = await import("../components/library-card"); +const { toSectionEntries } = await import("../lib/section-groups"); + +/** Minimal compact item builder — each test names only the fields it exercises. */ +function item( + overrides: Partial & Pick, +): CompactMediaItem { + return { + tmdbId: overrides.id, + mediaType: "movie", + year: 2020, + ...overrides, + } as CompactMediaItem; +} + +beforeEach(() => { + grids.captured.length = 0; +}); +afterEach(() => cleanup()); + +describe("LibrarySectionGrid infinite-scroll wiring", () => { + // Only the LAST section's grid may carry `onEndReached`, so the single + // end-of-stream sentinel drives the next-page fetch; if a middle section wired + // it, scrolling past it would fire spurious fetches. And the sentinel must be + // guarded — invoking it while a fetch is in flight (or with no next page) must + // NOT call `fetchNextPage`, or the list would double-request. + it("wires onEndReached on the last section only and keys cells by id+section", () => { + const entries = toSectionEntries( + [ + item({ id: "tv:1", title: "Dune", section: { id: "plex", label: "Plex" } }), + item({ id: "tv:1", title: "Dune", section: { id: "jelly", label: "Jellyfin" } }), + ], + "server", + ); + const fetchNextPage = vi.fn(async () => undefined); + render( + , + ); + + // Two sections (Plex, Jellyfin) → two grids; only the last gets the sentinel. + expect(grids.captured).toHaveLength(2); + expect(grids.captured[0]?.onEndReached).toBeUndefined(); + expect(grids.captured[1]?.onEndReached).toBeTypeOf("function"); + + // getKey salts the repeated title with its section so the two Dune rows stay + // distinct DOM cells (server/quality lenses repeat a title per section). + const plexEntry = grids.captured[0]!.items[0]; + const jellyEntry = grids.captured[1]!.items[0]; + expect(grids.captured[0]!.getKey(plexEntry, 0)).toBe("tv:1-plex"); + expect(grids.captured[1]!.getKey(jellyEntry, 0)).toBe("tv:1-jelly"); + + // The sentinel fires the next page when a cursor exists and none is in flight. + grids.captured[1]!.onEndReached!(); + expect(fetchNextPage).toHaveBeenCalledTimes(1); + }); + + it("guards the sentinel: no fetch while a page is already in flight", () => { + const entries = toSectionEntries([item({ id: "1", title: "Apple" })], "az"); + const fetchNextPage = vi.fn(async () => undefined); + render( + , + ); + grids.captured[grids.captured.length - 1]!.onEndReached!(); + expect(fetchNextPage).not.toHaveBeenCalled(); + }); +}); + +describe("CollectionsLens card", () => { + // The card fans the server preview but caps the visible posters at four + // (`preview.slice(0, 4)`), while the count badge shows the franchise's FULL + // owned size from the server — not the fanned subset. Pinning preview.length=6 + // and count=7 catches a regression that swaps the badge to `preview.length` + // (it would read 6 or 4) or drops the slice cap (it would fan 6 posters). + it("fans exactly 4 posters and shows the server count, not the preview length", () => { + const preview = Array.from({ length: 6 }, (_, i) => + item({ id: `movie:${i}`, title: `Part ${i}`, poster: `https://img/${i}.jpg` }), + ); + const collection: LibraryCollection = { + id: "collection:10", + title: "The Saga", + count: 7, + preview, + }; + const { container } = render( + undefined)} + />, + ); + + // The fan renders each poster as an `` (alt="" — decorative). Six + // preview items, but only four make it onto the card. + const posters = container.querySelectorAll("img"); + expect(posters).toHaveLength(4); + + // Badge reads the server total (7 titles), never the preview length (6) or + // the capped fan size (4). + expect(screen.getByText("7 titles")).toBeTruthy(); + expect(screen.queryByText("6 titles")).toBeNull(); + expect(screen.queryByText("4 titles")).toBeNull(); + }); +}); + +describe("AzLens rail", () => { + // The rail is driven by the whole-library `letters` facet, NOT the loaded + // entries: a facet letter links even before its section has scrolled into the + // infinite stream, and a letter absent from the facet renders inert. Here only + // an "A" section is loaded but the facet lists A and C — so both must be live + // jump buttons while B (absent from the facet) is an aria-hidden span. + it("renders facet letters as jump buttons and absent letters inert", () => { + const entries = toSectionEntries([item({ id: "1", title: "Apple" })], "az"); + render( + undefined)} + />, + ); + const rail = screen.getByRole("navigation", { name: "Jump to letter" }); + + // A and C are live buttons (facet-driven) even though only A is loaded. + expect(within(rail).getByRole("button", { name: "Jump to A" })).toBeTruthy(); + expect(within(rail).getByRole("button", { name: "Jump to C" })).toBeTruthy(); + // B is absent from the facet → inert: no button, and rendered aria-hidden. + expect(within(rail).queryByRole("button", { name: "Jump to B" })).toBeNull(); + const bGlyph = within(rail) + .getAllByText("B") + .find((el) => el.getAttribute("aria-hidden") === "true"); + expect(bGlyph).toBeTruthy(); + }); +}); + +describe("TimelineLens rail + yearless label", () => { + // The decade rail is built from the `decades` facet (numeric, newest-first), + // and the yearless bucket — which has no decade button — must still localize + // its header through `timelineSectionLabel`/`m.library_timeline_unknown`, NOT + // leak the raw "unknown" section key. This guards the render-boundary seam: + // section-groups stays locale-free, the lens localizes. + it("renders decade buttons from facets and resolves the yearless header label", () => { + const entries = toSectionEntries( + [ + item({ id: "1", title: "New", year: 2021 }), + item({ id: "2", title: "Yearless", year: undefined }), + ], + "timeline", + ); + render( + undefined)} + />, + ); + const rail = screen.getByRole("navigation", { name: "Jump to decade" }); + // The 2020 decade is present in the loaded stream → a live jump button whose + // aria-label localizes via timelineSectionLabel ("2020s"). + expect(within(rail).getByRole("button", { name: "Jump to 2020s" })).toBeTruthy(); + + // The yearless section header reads the localized label, never the raw key. + expect(screen.getByText("Unknown year")).toBeTruthy(); + expect(screen.queryByText("unknown")).toBeNull(); + }); +}); + +describe("LibraryCard quality chips", () => { + // The footer caps quality chips at MAX_TAGS (3) so it stays tidy; a fourth tag + // must be dropped. And when `tags` is undefined the chip container must not + // render at all (no empty wrapper), so the migration-era mock that left tags + // unset shows nothing rather than an empty strip. + it("renders at most three chips and drops the overflow", () => { + const { container } = render( + , + ); + expect(screen.getByText("4K HDR")).toBeTruthy(); + expect(screen.getByText("Atmos")).toBeTruthy(); + expect(screen.getByText("HDR10")).toBeTruthy(); + // The fourth tag is over the cap and must not render. + expect(screen.queryByText("DV")).toBeNull(); + // The chip wrapper holds exactly three chip spans. + const wrapper = container.querySelector(".flex-wrap"); + expect(wrapper).toBeTruthy(); + expect(wrapper!.children).toHaveLength(3); + }); + + it("renders no chip container when tags are undefined", () => { + const { container } = render(); + expect(container.querySelector(".flex-wrap")).toBeNull(); + }); +}); diff --git a/apps/client/src/features/library/components/lenses/az-lens-page.tsx b/apps/client/src/features/library/components/lenses/az-lens-page.tsx index 885f6cbc..64b319d6 100644 --- a/apps/client/src/features/library/components/lenses/az-lens-page.tsx +++ b/apps/client/src/features/library/components/lenses/az-lens-page.tsx @@ -1,7 +1,26 @@ +import { useLibraryFacets } from "../../hooks/use-library-facets"; import { AzLens } from "./az-lens"; import { LensPage } from "./lens-page"; /** `/library` (index) — the alphabetical index lens. */ export function AzLensPage() { - return } />; + // The present-only letter set comes from the non-blocking facets read (rule + // 5: facets never suspend), so the rail paints its live letters independent of + // which pages of the infinite stream have loaded. + const { facetCounts } = useLibraryFacets(); + const letters = facetCounts?.letters ?? []; + return ( + ( + + )} + /> + ); } diff --git a/apps/client/src/features/library/components/lenses/az-lens.tsx b/apps/client/src/features/library/components/lenses/az-lens.tsx index 15e10adc..7aac9efc 100644 --- a/apps/client/src/features/library/components/lenses/az-lens.tsx +++ b/apps/client/src/features/library/components/lenses/az-lens.tsx @@ -7,16 +7,19 @@ import { SectionHeadTitle, } from "@/shared/components/section-head"; import { cn } from "@/shared/lib/utils"; -import { buildAlphabet, groupByLetter } from "../../lib/grouping"; -import type { LibraryItem } from "../../lib/types"; -import { LibraryGrid } from "../library-grid"; +import { toSections } from "../../lib/section-groups"; +import { LibrarySectionGrid, type LibrarySectionGridProps } from "./library-section-grid"; // Load-bearing coupling: this id format is the only link between the `
` below and the `getElementById` scroll-spy lookup in the effect. Both -// sides go through this helper, so changing it here keeps them in step — but -// the connection is invisible to the type system, so keep them co-located. +// id>` rendered below and the `getElementById` scroll-spy lookup in the effect. +// Both sides go through this helper, so changing it here keeps them in step — +// but the connection is invisible to the type system, so keep them co-located. const anchorId = (letter: string) => `lib-letter-${letter === "#" ? "hash" : letter}`; +// The fixed A→Z rail order; `#` collects non-alphabetic leads. The `letters` +// facet (present-only) decides which entries are live links vs inert. +const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("").concat("#"); + // Top inset for the scroll-spy band — matches the rail's `top-24` (96px) so a // section only counts as active once it clears the sticky nav. Named so it's // findable if the nav height changes (there's no shared layout token yet). @@ -32,37 +35,48 @@ function trackVisibleSections(visible: Set, entries: IntersectionObserve } } +interface AzLensProps extends Omit { + /** Present-only first letters from `/facets`, driving which rail letters link. */ + letters: string[]; +} + /** - * Alphabetical index: a sticky letter rail beside per-letter sections. Clicking - * a populated letter smooth-scrolls to its section; empty letters are inert. An - * IntersectionObserver tracks which section sits at the top of the viewport and - * highlights the matching rail letter. + * Alphabetical index: a sticky letter rail beside per-letter sections. The + * server returns the stream sorted `(sortTitle, id)` and `toSectionEntries` + * splices a letter header on each boundary; this lens reuses the shared + * `LibrarySectionGrid` for the virtualized, infinitely-scrolling body and only + * adds the rail + scroll-spy on top. Clicking a populated letter smooth-scrolls + * to its section; letters absent from the `letters` facet are inert. An + * IntersectionObserver highlights the section at the top of the viewport. */ -export function AzLens({ items }: { items: LibraryItem[] }) { - const groups = useMemo(() => groupByLetter(items), [items]); - const alphabet = useMemo(() => buildAlphabet(items), [items]); - // Highlight the first section from the start; the observer below corrects it - // as soon as the user scrolls. Without this the rail shows nothing active on - // load even though section `A` is already at the top of the viewport. - const [activeKey, setActiveKey] = useState(groups[0]?.key ?? null); +export function AzLens({ letters, entries, ...gridProps }: AzLensProps) { + // The populated set drives the rail's live vs inert letters and is sourced + // from the whole-library `letters` facet (not the loaded pages) so a letter + // links even before its section has scrolled into the infinite stream. + const populated = useMemo(() => new Set(letters), [letters]); + // The section keys currently spliced into the loaded stream — the observer + // only tracks sections that exist in the DOM. Derived from the same entries + // the grid renders so the two never disagree. + const sectionKeys = useMemo(() => toSections(entries).map((section) => section.key), [entries]); + const [activeKey, setActiveKey] = useState(null); // Scroll-spy via IntersectionObserver (works regardless of which ancestor // scrolls). The active letter is the topmost section intersecting a band just - // below the app nav; while the viewport sits in a gap between sections the last - // active letter holds. Only re-renders when the active letter actually changes. + // below the app nav; while the viewport sits in a gap between sections the + // last active letter holds. Re-runs when the loaded section set grows. useEffect(() => { - const keys = groups.map((group) => group.key); - const sections = keys + const sections = sectionKeys .map((key) => document.getElementById(anchorId(key))) .filter((el): el is HTMLElement => el !== null); if (sections.length === 0) return; + setActiveKey((prev) => prev ?? sectionKeys[0] ?? null); const visible = new Set(); const observer = new IntersectionObserver( - (entries) => { - trackVisibleSections(visible, entries); + (observed) => { + trackVisibleSections(visible, observed); if (visible.size === 0) return; - const next = keys.find((key) => visible.has(key)) ?? null; + const next = sectionKeys.find((key) => visible.has(key)) ?? null; setActiveKey((prev) => (prev === next ? prev : next)); }, // Top inset clears the sticky app nav so a section only counts as active @@ -72,7 +86,7 @@ export function AzLens({ items }: { items: LibraryItem[] }) { ); for (const el of sections) observer.observe(el); return () => observer.disconnect(); - }, [groups]); + }, [sectionKeys]); const jump = (letter: string) => { document @@ -86,8 +100,8 @@ export function AzLens({ items }: { items: LibraryItem[] }) { aria-label={m.library_az_rail_label()} className="sticky top-24 flex max-h-[calc(100vh-7rem)] flex-col items-stretch self-start overflow-y-auto" > - {alphabet.map(({ letter, populated }) => - populated ? ( + {ALPHABET.map((letter) => + populated.has(letter) ? ( + + ) : null} ); } diff --git a/apps/client/src/features/library/components/lenses/grouped-lens.tsx b/apps/client/src/features/library/components/lenses/grouped-lens.tsx deleted file mode 100644 index f6320645..00000000 --- a/apps/client/src/features/library/components/lenses/grouped-lens.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { - SectionHead, - SectionHeadCount, - SectionHeadHeading, - SectionHeadTitle, -} from "@/shared/components/section-head"; -import type { LibraryGroup } from "../../lib/grouping"; -import { LibraryGrid } from "../library-grid"; - -/** Renders pre-computed groups as stacked sections, each a header over a poster grid. */ -export function GroupedLens({ groups }: { groups: LibraryGroup[] }) { - return ( -
- {groups.map((group) => ( -
- - - - {group.label} - - - - - -
- ))} -
- ); -} diff --git a/apps/client/src/features/library/components/lenses/lens-page.tsx b/apps/client/src/features/library/components/lenses/lens-page.tsx index d6819f7b..385ffac2 100644 --- a/apps/client/src/features/library/components/lenses/lens-page.tsx +++ b/apps/client/src/features/library/components/lenses/lens-page.tsx @@ -1,17 +1,28 @@ import type { ReactNode } from "react"; +import type { LibraryLens } from "@ent-mcp/shared/library"; import { useLibraryContent } from "../../hooks/use-library-content"; import { LibraryEmpty } from "../library-empty"; +type ItemLens = Exclude; type LensContent = ReturnType; /** - * The guard every lens route shares: reads the filtered library content, shows - * the empty state when nothing survives the active filters, and otherwise hands - * the resolved content to the lens. Keeps each `*-lens-page` a one-liner so the - * empty-state wiring lives in exactly one place. + * The guard every item-lens route shares: reads THIS lens's Suspense infinite + * content, shows the empty state when nothing survives the active filters, and + * otherwise hands the resolved content to the lens presenter. Keeps each + * `*-lens-page` a one-liner so the empty-state wiring lives in exactly one + * place. The `lens` is fixed per route, so the inner `useLibraryContent(lens)` + * is one infinite query per mounted lens (skill rule 7) — collections has its + * own page (group-first shape), so it never routes through here. */ -export function LensPage({ render }: { render: (content: LensContent) => ReactNode }) { - const content = useLibraryContent(); +export function LensPage({ + lens, + render, +}: { + lens: ItemLens; + render: (content: LensContent) => ReactNode; +}) { + const content = useLibraryContent(lens); if (content.isEmpty) return ; return <>{render(content)}; } diff --git a/apps/client/src/features/library/components/lenses/library-section-grid.tsx b/apps/client/src/features/library/components/lenses/library-section-grid.tsx new file mode 100644 index 00000000..0f6d4cea --- /dev/null +++ b/apps/client/src/features/library/components/lenses/library-section-grid.tsx @@ -0,0 +1,120 @@ +import { useCallback, useMemo, type ReactNode } from "react"; +import * as m from "@/paraglide/messages"; +import { + SectionHead, + SectionHeadCount, + SectionHeadHeading, + SectionHeadTitle, +} from "@/shared/components/section-head"; +import { VirtualGrid } from "@/shared/components/virtualized"; +import { Button } from "@/shared/ui/button"; +import { toSections, type LibrarySectionEntry } from "../../lib/section-groups"; +import { LibraryCard } from "../library-card"; + +/** + * Tile geometry shared with `LIBRARY_GRID_CLASS` so the virtualized grid packs + * the same column track the non-virtual lenses (and the loading skeleton) use: + * `minmax(8rem, 1fr)` columns at the base breakpoint with the `gap-x-3.5` (14px) + * gutter. `EST_ROW_HEIGHT` is the empirical poster-card row height (2/3 poster + + * title/year footer + gap) the virtualizer seeds each row with before measuring. + */ +const MIN_COLUMN_PX = 128; +const GAP_PX = 14; +const EST_ROW_HEIGHT = 296; + +/** + * Whether the `onEndReached` sentinel should kick off the next page fetch: only + * when another cursor exists AND no fetch is already in flight. Extracted as a + * pure predicate so the infinite-scroll guard is unit-testable without rendering + * the virtualizer (the grid's `onEndReached` and the "load more" button both go + * through it, so the two affordances never disagree). + */ +export function shouldFetchNext(hasNextPage: boolean, isFetchingNextPage: boolean): boolean { + return hasNextPage && !isFetchingNextPage; +} + +export interface LibrarySectionGridProps { + /** The flat, server-sorted stream with section headers already spliced in. */ + entries: LibrarySectionEntry[]; + hasNextPage: boolean; + isFetchingNextPage: boolean; + fetchNextPage: () => Promise; + /** + * Optional per-section header override (the A→Z lens anchors its sections for + * the letter rail). Defaults to the shared `SectionHead` treatment so every + * other lens reads the same. + */ + renderHeader?: (section: { key: string; label: string; count: number }) => ReactNode; +} + +/** + * The infinite-scroll body shared by the four item lenses. Splits the flat + * header-delimited stream into sections and renders each as a `SectionHead` + * over its own window-virtualized poster grid — the exact "header over a grid" + * look the client-side `groupBy*` produced, now fed by the server's sorted + * stream. The shared `VirtualGrid` is reused unchanged (rule: reuse, don't + * reinvent); only the LAST section wires `onEndReached`, so the single + * end-of-stream nears the viewport and fetches the next cursor (guarded by + * `hasNextPage && !isFetchingNextPage` per the grid contract). Each cell keys on + * `${id}-${sectionKey}` so the `server`/`quality` lenses' repeated titles keep + * distinct DOM rows. A "load more" button mirrors watchlist as the keyboard / + * non-scroll affordance. + */ +export function LibrarySectionGrid({ + entries, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + renderHeader, +}: LibrarySectionGridProps) { + const sections = useMemo(() => toSections(entries), [entries]); + const onEndReached = useCallback(() => { + if (shouldFetchNext(hasNextPage, isFetchingNextPage)) void fetchNextPage(); + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + + return ( +
+ {sections.map((section, index) => { + const isLast = index === sections.length - 1; + return ( +
+ {renderHeader ? ( + renderHeader({ key: section.key, label: section.label, count: section.items.length }) + ) : ( + + + + {section.label} + + + + + )} + `${entry.item.id}-${entry.sectionKey}`} + minColumnWidthPx={MIN_COLUMN_PX} + gapPx={GAP_PX} + estimateRowHeight={() => EST_ROW_HEIGHT} + renderItem={(entry) => } + onEndReached={isLast ? onEndReached : undefined} + /> +
+ ); + })} + + {hasNextPage ? ( +
+ +
+ ) : null} +
+ ); +} diff --git a/apps/client/src/features/library/components/lenses/quality-lens-page.tsx b/apps/client/src/features/library/components/lenses/quality-lens-page.tsx index 270c0edf..308fe54f 100644 --- a/apps/client/src/features/library/components/lenses/quality-lens-page.tsx +++ b/apps/client/src/features/library/components/lenses/quality-lens-page.tsx @@ -3,5 +3,17 @@ import { QualityLens } from "./quality-lens"; /** `/library/quality` — the quality-tier lens. */ export function QualityLensPage() { - return } />; + return ( + ( + + )} + /> + ); } diff --git a/apps/client/src/features/library/components/lenses/quality-lens.tsx b/apps/client/src/features/library/components/lenses/quality-lens.tsx index 92ef6052..b6202554 100644 --- a/apps/client/src/features/library/components/lenses/quality-lens.tsx +++ b/apps/client/src/features/library/components/lenses/quality-lens.tsx @@ -1,10 +1,13 @@ -import { useMemo } from "react"; -import { groupByQuality } from "../../lib/grouping"; -import type { LibraryItem } from "../../lib/types"; -import { GroupedLens } from "./grouped-lens"; +import { LibrarySectionGrid, type LibrarySectionGridProps } from "./library-section-grid"; -/** Technical-tier view: one section per quality tag (4K HDR → Atmos), titles within. */ -export function QualityLens({ items }: { items: LibraryItem[] }) { - const groups = useMemo(() => groupByQuality(items), [items]); - return ; +/** + * Technical-tier view: one section per quality tier (4K HDR → SD), titles + * within. The server expands each title once per tier (`json_each`), sorts by + * descending fidelity, and stamps the `section` ({ id, label }) onto every row, + * so `toSectionEntries` splices a tier header on each boundary and the shared + * section grid keys repeats by `id + sectionKey`. A thin pass — the grouping is + * server-side. + */ +export function QualityLens(props: Omit) { + return ; } diff --git a/apps/client/src/features/library/components/lenses/servers-lens-page.tsx b/apps/client/src/features/library/components/lenses/servers-lens-page.tsx index 42958ee4..82c47c9c 100644 --- a/apps/client/src/features/library/components/lenses/servers-lens-page.tsx +++ b/apps/client/src/features/library/components/lenses/servers-lens-page.tsx @@ -3,5 +3,17 @@ import { ServersLens } from "./servers-lens"; /** `/library/server` — the per-server availability lens. */ export function ServersLensPage() { - return } />; + return ( + ( + + )} + /> + ); } diff --git a/apps/client/src/features/library/components/lenses/servers-lens.tsx b/apps/client/src/features/library/components/lenses/servers-lens.tsx index 139dd396..5e3a7558 100644 --- a/apps/client/src/features/library/components/lenses/servers-lens.tsx +++ b/apps/client/src/features/library/components/lenses/servers-lens.tsx @@ -1,10 +1,12 @@ -import { useMemo } from "react"; -import { groupByServer } from "../../lib/grouping"; -import type { LibraryItem } from "../../lib/types"; -import { GroupedLens } from "./grouped-lens"; +import { LibrarySectionGrid, type LibrarySectionGridProps } from "./library-section-grid"; -/** Availability view: one section per media server, listing the titles it hosts. */ -export function ServersLens({ items }: { items: LibraryItem[] }) { - const groups = useMemo(() => groupByServer(items), [items]); - return ; +/** + * Availability view: one section per media server, listing the titles it hosts. + * The server expands each title once per server (`json_each`) and stamps the + * `section` ({ id, label }) onto every row, so `toSectionEntries` splices a + * server header on each boundary and the shared section grid keys repeats by + * `id + sectionKey`. A thin pass — the grouping is server-side. + */ +export function ServersLens(props: Omit) { + return ; } diff --git a/apps/client/src/features/library/components/lenses/timeline-lens-page.tsx b/apps/client/src/features/library/components/lenses/timeline-lens-page.tsx index d74b66e4..5fc65b98 100644 --- a/apps/client/src/features/library/components/lenses/timeline-lens-page.tsx +++ b/apps/client/src/features/library/components/lenses/timeline-lens-page.tsx @@ -1,7 +1,26 @@ +import { useLibraryFacets } from "../../hooks/use-library-facets"; import { LensPage } from "./lens-page"; import { TimelineLens } from "./timeline-lens"; /** `/library/timeline` — the release-decade lens. */ export function TimelineLensPage() { - return } />; + // The present-only decade set comes from the non-blocking facets read (rule + // 5: facets never suspend), so the jump rail paints its live decades + // independent of which pages of the infinite stream have loaded. + const { facetCounts } = useLibraryFacets(); + const decades = facetCounts?.decades ?? []; + return ( + ( + + )} + /> + ); } diff --git a/apps/client/src/features/library/components/lenses/timeline-lens.tsx b/apps/client/src/features/library/components/lenses/timeline-lens.tsx index ba7f0a8b..6db78d8a 100644 --- a/apps/client/src/features/library/components/lenses/timeline-lens.tsx +++ b/apps/client/src/features/library/components/lenses/timeline-lens.tsx @@ -1,67 +1,159 @@ -import { useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import * as m from "@/paraglide/messages"; import { SectionHead, - SectionHeadActions, SectionHeadCount, SectionHeadHeading, SectionHeadTitle, } from "@/shared/components/section-head"; -import { - POSTER_VARS, - ScrollRow, - ScrollRowNextButton, - ScrollRowPrevButton, - ScrollRowTrack, - ScrollRowViewport, -} from "@/shared/components/scroll-row"; -import { groupByDecade } from "../../lib/grouping"; -import type { LibraryItem } from "../../lib/types"; -import { LibraryCard } from "../library-card"; +import { cn } from "@/shared/lib/utils"; +import { timelineSectionLabel } from "../../lib/labels"; +import { toSections } from "../../lib/section-groups"; +import { LibrarySectionGrid, type LibrarySectionGridProps } from "./library-section-grid"; + +// Load-bearing coupling: this id format is the only link between the `
` rendered below and the `getElementById` scroll-spy / jump lookup. Both +// sides go through this helper so they stay in step — the connection is +// invisible to the type system, so keep them co-located (mirrors the A→Z lens). +const anchorId = (decade: string) => `lib-decade-${decade}`; + +// Top inset for the scroll-spy band — matches the rail's `top-24` (96px) so a +// section only counts as active once it clears the sticky nav. Named so it's +// findable if the nav height changes (there's no shared layout token yet). +const SCROLL_SPY_TOP_INSET = "-96px"; + +/** Fold a batch of observer entries into the running set of visible section keys. */ +function trackVisibleSections(visible: Set, entries: IntersectionObserverEntry[]) { + for (const entry of entries) { + const key = (entry.target as HTMLElement).dataset.decade; + if (key === undefined) continue; + if (entry.isIntersecting) visible.add(key); + else visible.delete(key); + } +} + +interface TimelineLensProps extends Omit { + /** Present-only decades from `/facets`, newest-first, driving the jump rail. */ + decades: number[]; +} /** - * Release timeline: one row per decade (newest first), each ordered - * newest-to-oldest within. Composes the shared `ScrollRow` primitives so the - * scroll behaviour, edge fades, and card sizing match the home feed and - * watchlist rows exactly rather than re-implementing a horizontal strip. + * Release timeline: a sticky decade rail beside per-decade sections (newest + * first). The server returns the stream sorted `(year DESC, id)` and + * `toSectionEntries` splices a decade header on each boundary; this lens reuses + * the shared `LibrarySectionGrid` for the virtualized, infinitely-scrolling body + * and only adds the rail + scroll-spy on top — mirroring the A→Z lens. Clicking + * a decade smooth-scrolls to its section; decades absent from the loaded stream + * are still listed by the whole-library `decades` facet (so the rail is complete + * before a section scrolls in) but render inert. An IntersectionObserver + * highlights the section at the top of the viewport. + * + * The section keys are i18n-free tokens (`"2020"`, `"unknown"`) emitted by + * `section-groups`; the visible header text is localized here via + * `timelineSectionLabel` so the yearless bucket reads "Unknown year" and a + * decade reads "2020s" in every locale. */ -export function TimelineLens({ items }: { items: LibraryItem[] }) { - const decades = useMemo(() => groupByDecade(items), [items]); +export function TimelineLens({ decades, entries, ...gridProps }: TimelineLensProps) { + // The rail's whole-library decade keys, newest-first, as section-key strings. + // Sourced from the `decades` facet (not the loaded pages) so a decade links + // even before its section has scrolled into the infinite stream. + const railKeys = useMemo(() => decades.map(String), [decades]); + // The decade keys currently spliced into the loaded stream — the observer only + // tracks sections that exist in the DOM, and the rail marks a key live only + // when it is present here. Derived from the same entries the grid renders so + // the two never disagree. Includes the `"unknown"` bucket when present. + const presentKeys = useMemo( + () => new Set(toSections(entries).map((section) => section.key)), + [entries], + ); + const [activeKey, setActiveKey] = useState(null); + + // Scroll-spy via IntersectionObserver (works regardless of which ancestor + // scrolls). The active decade is the topmost section intersecting a band just + // below the app nav; while the viewport sits in a gap between sections the + // last active decade holds. Re-runs when the loaded section set grows. + useEffect(() => { + const orderedKeys = [...presentKeys]; + const sections = orderedKeys + .map((key) => document.getElementById(anchorId(key))) + .filter((el): el is HTMLElement => el !== null); + if (sections.length === 0) return; + setActiveKey((prev) => prev ?? orderedKeys[0] ?? null); + + const visible = new Set(); + const observer = new IntersectionObserver( + (observed) => { + trackVisibleSections(visible, observed); + if (visible.size === 0) return; + const next = orderedKeys.find((key) => visible.has(key)) ?? null; + setActiveKey((prev) => (prev === next ? prev : next)); + }, + // Top inset clears the sticky app nav so a section only counts as active + // once it's below it; the -55% bottom inset narrows the "active band" to + // the upper ~45% of the viewport. + { rootMargin: `${SCROLL_SPY_TOP_INSET} 0px -55% 0px`, threshold: 0 }, + ); + for (const el of sections) observer.observe(el); + return () => observer.disconnect(); + }, [presentKeys]); + + const jump = (decade: string) => { + document + .getElementById(anchorId(decade)) + ?.scrollIntoView({ behavior: "smooth", block: "start" }); + }; return ( -
- {decades.map((decade) => { - // Yearless titles land in the `unknown` bucket grouping appends last; its - // label is localized here so the lib layer can stay i18n-free. - const label = decade.key === "unknown" ? m.library_timeline_unknown() : decade.label; - return ( - - - +
+ + + ( + + +
- {label} - + {timelineSectionLabel(section.label)} + - - - - - - - - item.id} - estimateItemWidth={200} - renderItem={(item) => } - /> - - - ); - })} +
+
+
+ )} + />
); } diff --git a/apps/client/src/features/library/components/library-card.tsx b/apps/client/src/features/library/components/library-card.tsx index 0677b3ad..8dfbc4e8 100644 --- a/apps/client/src/features/library/components/library-card.tsx +++ b/apps/client/src/features/library/components/library-card.tsx @@ -1,5 +1,6 @@ import { memo } from "react"; import * as m from "@/paraglide/messages"; +import { MediaCardMeta, MediaCardSubtitle, MediaCardTitle } from "@/shared/components/media-card"; import { MediaRowCard } from "@/shared/components/media-row-card"; import { buildMediaHref } from "@/shared/lib/media-id"; import { kindLabel } from "../lib/labels"; @@ -7,10 +8,36 @@ import type { LibraryItem } from "../lib/types"; const progressLabel = (percent: number) => m.library_card_progress({ percent: String(percent) }); +/** How many quality chips a tile shows before truncating (keeps the footer tidy). */ +const MAX_TAGS = 3; + +/** + * Quality-tier chips read off `CompactMediaItem.tags` (e.g. `4K HDR`, `Atmos`). + * The mock left `tags` undefined so nothing rendered; the real endpoints now + * surface them. Bordered monospaced chips match the home feed's tag treatment + * so the library inherits the same token styling. + */ +function QualityChips({ tags }: { tags: string[] | undefined }) { + if (!tags?.length) return null; + return ( +
+ {tags.slice(0, MAX_TAGS).map((tag) => ( + + {tag} + + ))} +
+ ); +} + /** * One library tile. Reuses the shared `MediaRowCard` (grid variant: 2/3 poster * with a title + year footer) and links to the existing detail route, so the - * library inherits the exact card treatment the home feed and watchlist use. + * library inherits the exact card treatment the home feed and watchlist use. The + * footer adds the quality chips beneath the title/year. * * `memo` because the lenses render many of these; the props are stable per item. */ @@ -23,6 +50,13 @@ export const LibraryCard = memo(function LibraryCard({ item }: { item: LibraryIt openLabel={m.library_card_open({ title: item.title })} kindLabel={kindLabel(item.mediaType)} progressLabel={progressLabel} + meta={ + + {item.title} + {item.year ? {item.year} : null} + + + } /> ); }); diff --git a/apps/client/src/features/library/components/library-filter-popover.tsx b/apps/client/src/features/library/components/library-filter-popover.tsx index 83bd6a91..7147e2d1 100644 --- a/apps/client/src/features/library/components/library-filter-popover.tsx +++ b/apps/client/src/features/library/components/library-filter-popover.tsx @@ -1,5 +1,10 @@ import type { ReactNode } from "react"; import { SlidersHorizontal } from "lucide-react"; +import { + WATCHED_STATES, + type LibraryFacetCounts, + type WatchedState, +} from "@ent-mcp/shared/library"; import type { MediaType } from "@ent-mcp/shared/media"; import * as m from "@/paraglide/messages"; import { Badge } from "@/shared/ui/badge"; @@ -8,20 +13,15 @@ import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from "@/shared/ import { ToggleGroup, ToggleGroupItem } from "@/shared/ui/toggle-group"; import { countActiveFilters } from "../lib/filtering"; import { facetSectionLabel, kindLabel, watchedLabel } from "../lib/labels"; -import { - EMPTY_FILTERS, - WATCHED_STATES, - type LibraryFacetCounts, - type LibraryFilters, - type WatchedState, -} from "../lib/types"; +import { EMPTY_FILTERS, type LibraryFilters } from "../lib/types"; const KINDS: MediaType[] = ["movie", "tv"]; interface LibraryFilterPopoverProps { filters: LibraryFilters; facetValues: { genres: string[]; qualities: string[]; servers: string[] }; - facetCounts: LibraryFacetCounts; + /** Whole-library facet totals; undefined until the non-blocking facets read lands. */ + facetCounts: LibraryFacetCounts | undefined; onChange: (filters: LibraryFilters) => void; } @@ -103,7 +103,7 @@ export function LibraryFilterPopover({ {KINDS.map((kind) => ( key={kind} value={kind} variant="primary"> {kindLabel(kind)} - + ))} @@ -118,7 +118,7 @@ export function LibraryFilterPopover({ {WATCHED_STATES.map((state: WatchedState) => ( key={state} value={state} variant="primary"> {watchedLabel(state)} - + ))} @@ -133,7 +133,7 @@ export function LibraryFilterPopover({ {facetValues.genres.map((genre) => ( {genre} - + ))} @@ -148,7 +148,7 @@ export function LibraryFilterPopover({ {facetValues.qualities.map((quality) => ( {quality} - + ))} @@ -163,7 +163,7 @@ export function LibraryFilterPopover({ {facetValues.servers.map((server) => ( {server} - + ))} diff --git a/apps/client/src/features/library/components/library-grid.tsx b/apps/client/src/features/library/components/library-grid.tsx index 45c5e119..a7ca3acf 100644 --- a/apps/client/src/features/library/components/library-grid.tsx +++ b/apps/client/src/features/library/components/library-grid.tsx @@ -1,24 +1,9 @@ -import { LibraryCard } from "./library-card"; -import type { LibraryItem } from "../lib/types"; - /** - * The auto-fill poster grid layout shared by `LibraryGrid` and its loading + * The auto-fill poster grid track shared by the lens grids and their loading * skeleton, so both reserve the exact same column track and gaps and the page - * doesn't reflow when real cards replace the placeholders. + * doesn't reflow when real cards replace the placeholders. The lens bodies now + * window-virtualize their grids (`LibrarySectionGrid` mirrors these metrics in + * JS via `VirtualGrid`), so only the skeleton renders this class directly. */ export const LIBRARY_GRID_CLASS = "grid grid-cols-[repeat(auto-fill,minmax(8rem,1fr))] gap-x-3.5 gap-y-5 sm:grid-cols-[repeat(auto-fill,minmax(9.5rem,1fr))]"; - -/** - * The poster grid shared by the A→Z, Servers and Quality lenses. Auto-fills - * columns at a fixed minimum tile width so density scales with the viewport. - */ -export function LibraryGrid({ items }: { items: LibraryItem[] }) { - return ( -
- {items.map((item) => ( - - ))} -
- ); -} diff --git a/apps/client/src/features/library/components/library-header.tsx b/apps/client/src/features/library/components/library-header.tsx index 6486fddf..9e91d60b 100644 --- a/apps/client/src/features/library/components/library-header.tsx +++ b/apps/client/src/features/library/components/library-header.tsx @@ -5,29 +5,31 @@ import { SectionHeadHeading, SectionHeadTitle, } from "@/shared/components/section-head"; -import { useLibrary } from "../hooks/use-library"; +import { useLibraryFacets } from "../hooks/use-library-facets"; import { useLibraryFilters } from "../hooks/use-library-filters"; -import { useLibraryView } from "../hooks/use-library-view"; +import { libraryOwnedTotal } from "../lib/facets"; import { LibraryFilterPopover } from "./library-filter-popover"; import { LibraryLensTabs } from "./library-lens-tabs"; /** * The library header region: eyebrow + title and the control bar (lens tabs on * the lead edge; filters on the trail edge). Rendered once in the layout, it - * reads the shared payload and URL filters itself so it stays - * mounted while the lens routes swap below it. + * reads the non-blocking facet totals and the URL filters itself so it stays + * mounted while the lens routes swap below it. The eyebrow count is the + * whole-library owned total (sum of the per-kind facet totals), matching the + * unfiltered facets semantics; it shows nothing until the facets land. */ export function LibraryHeader() { - const { data } = useLibrary(); const { filters, setFilters } = useLibraryFilters(); - const { filtered, facetValues, facetCounts } = useLibraryView({ data, filters }); + const { facetValues, facetCounts } = useLibraryFacets(); + const count = libraryOwnedTotal(facetCounts); return (
- {m.library_eyebrow({ count: String(filtered.length) })} + {m.library_eyebrow({ count: String(count) })} {m.library_title()} diff --git a/apps/client/src/features/library/components/library-layout.tsx b/apps/client/src/features/library/components/library-layout.tsx index d44f8b4a..55ead1c1 100644 --- a/apps/client/src/features/library/components/library-layout.tsx +++ b/apps/client/src/features/library/components/library-layout.tsx @@ -8,11 +8,12 @@ interface LibraryLayoutProps { /** * Shared shell for the `/library/*` route family. The header (lens tabs, filter - * popover) renders once here; each lens route mounts its grouped - * content inside ``. The header reads the library payload via - * `useSuspenseQuery` and the parent route loader prefetches it, so it paints on - * first mount. The inner `` covers only the swappable content area, - * keeping the header in place while a lens revalidates (skill rule 5). + * popover) renders once here; each lens route mounts its content inside + * ``. The header reads the facet totals via a non-blocking `useQuery` + * (the layout loader warms them) so a slow/failing facets read never suspends + * the shell. The inner `` covers only the swappable content area, + * keeping the header in place while a lens's suspense read revalidates (skill + * rule 5). */ export function LibraryLayout({ children }: LibraryLayoutProps) { return ( diff --git a/apps/client/src/features/library/components/library-lens-tabs.tsx b/apps/client/src/features/library/components/library-lens-tabs.tsx index 5b8fbdbc..90513d54 100644 --- a/apps/client/src/features/library/components/library-lens-tabs.tsx +++ b/apps/client/src/features/library/components/library-lens-tabs.tsx @@ -1,7 +1,7 @@ +import { LIBRARY_LENSES, type LibraryLens } from "@ent-mcp/shared/library"; import * as m from "@/paraglide/messages"; import { RouteTab, RouteTabs } from "@/shared/components/route-tabs"; import { lensLabel, lensNote } from "../lib/labels"; -import { LIBRARY_LENSES, type LibraryLens } from "../lib/types"; /** Each lens is its own route; A→Z is the index. Order mirrors `LIBRARY_LENSES`. */ const LENS_TO = { diff --git a/apps/client/src/features/library/hooks/use-filtered-library-items.ts b/apps/client/src/features/library/hooks/use-filtered-library-items.ts deleted file mode 100644 index 128e1898..00000000 --- a/apps/client/src/features/library/hooks/use-filtered-library-items.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { useMemo } from "react"; -import { applyLibraryFilters } from "../lib/filtering"; -import type { LibraryData, LibraryFilters } from "../lib/types"; - -/** - * The one place the active filters are applied to the raw payload. Both the - * header (which needs the count) and the lens pages (which group the set) read - * through this so the filtered array is derived — and memoed — once per shape. - */ -export function useFilteredLibraryItems(data: LibraryData, filters: LibraryFilters) { - return useMemo(() => applyLibraryFilters(data.items, filters), [data.items, filters]); -} diff --git a/apps/client/src/features/library/hooks/use-library-collections.ts b/apps/client/src/features/library/hooks/use-library-collections.ts new file mode 100644 index 00000000..b03ea6e1 --- /dev/null +++ b/apps/client/src/features/library/hooks/use-library-collections.ts @@ -0,0 +1,67 @@ +import { + type InfiniteData, + type QueryClient, + infiniteQueryOptions, + useSuspenseInfiniteQuery, +} from "@tanstack/react-query"; +import type { LibraryCollection, LibraryCollectionsResponse } from "@ent-mcp/shared/library"; +import { fetchCollectionsPage } from "../lib/fetchers"; +import { libraryKeys } from "../lib/query-keys"; +import type { LibraryFilters } from "../lib/types"; + +/** + * Flatten the loaded collection pages into one list. A module-level reference + * keeps React Query's `select` memoization stable (re-runs only when the page + * set changes, not every render), mirroring the shared media hook's + * `selectMediaRows`. + */ +const selectCollections = (data: InfiniteData): LibraryCollection[] => + data.pages.flatMap((page) => page.collections); + +/** + * Infinite-query options for the Collections lens. Unlike the four item lenses, + * collections is group-first server-side (its own `/api/library/collections` + * endpoint returns `{ collections, cursor }`, not a media `Page`), so it does + * not flow through the shared media source — it gets its own options keyed by + * `libraryKeys.collections(filters)` (filters in the key, rule 4). The opaque + * `cursor` threads as `pageParam`; a null response cursor ends the set. + */ +export function libraryCollectionsQueryOptions(filters: LibraryFilters) { + return infiniteQueryOptions({ + queryKey: libraryKeys.collections(filters), + queryFn: ({ pageParam }) => fetchCollectionsPage(filters, pageParam ?? null), + initialPageParam: undefined as string | undefined, + getNextPageParam: (last) => last.cursor ?? undefined, + select: selectCollections, + }); +} + +/** + * Loader-side warm for the Collections lens (skill rule 5). Threads the same + * options the hook reads so the cache key matches and the suspense boundary + * paints with data on first mount. + */ +export function prefetchLibraryCollections( + queryClient: QueryClient, + filters: LibraryFilters, +): Promise { + return queryClient.ensureInfiniteQueryData(libraryCollectionsQueryOptions(filters)); +} + +/** + * The Suspense infinite read for the Collections lens (skill rule 5: primary + * lens reads suspend). Returns the flat `collections` list plus the + * infinite-scroll controls the `VirtualGrid` consumes. One hook, one query + * (rule 7). The cards fan each collection's `preview` posters directly (no + * second fetch) — no client-side section headers here (the endpoint is + * group-first). + */ +export function useLibraryCollections(filters: LibraryFilters) { + const query = useSuspenseInfiniteQuery(libraryCollectionsQueryOptions(filters)); + return { + collections: query.data, + hasNextPage: query.hasNextPage, + isFetchingNextPage: query.isFetchingNextPage, + fetchNextPage: query.fetchNextPage, + }; +} diff --git a/apps/client/src/features/library/hooks/use-library-content.ts b/apps/client/src/features/library/hooks/use-library-content.ts index fed347c7..b3ab3211 100644 --- a/apps/client/src/features/library/hooks/use-library-content.ts +++ b/apps/client/src/features/library/hooks/use-library-content.ts @@ -1,16 +1,43 @@ -import { useFilteredLibraryItems } from "./use-filtered-library-items"; -import { useLibrary } from "./use-library"; +import { useMemo } from "react"; +import type { LibraryLens } from "@ent-mcp/shared/library"; +import { toSectionEntries } from "../lib/section-groups"; import { useLibraryFilters } from "./use-library-filters"; +import { useLibraryLens } from "./use-library-lens"; + +/** The four lenses that share the flat-item-stream shape (collections is group-first). */ +type ItemLens = Exclude; /** - * Data + filter plumbing every lens page shares: the filtered item set the - * lens groups, the collections passthrough, and the reset handler the empty - * state calls. The lens components stay dumb (props only); this hook is the - * single seam each lens route mounts on. + * The per-lens content seam every item lens page (`az`/`timeline`/`server`/ + * `quality`) mounts on. It composes the URL filters, the Suspense infinite read + * for THIS lens, and the section-header insertion into the single thing the lens + * presenter renders: a flat `entries` list (headers spliced in on group-key + * change) plus the infinite-scroll controls the `VirtualGrid` consumes and the + * reset handler the empty state calls. + * + * The lens is fixed per route (each `*-lens-page` renders exactly one), so it is + * not a conditional hook call — the rules-of-hooks-safe way to keep one infinite + * query per mounted lens (rule 7) rather than reading all five at once. The + * `entries` projection is memoed off the flat `items` stream so headers re-splice + * only when the loaded page set changes, not on every render. + * + * Collections is intentionally NOT routed here — it has a different response + * shape (`{ collections }`, group-first) and its own `useLibraryCollections`. */ -export function useLibraryContent() { - const { data } = useLibrary(); +export function useLibraryContent(lens: ItemLens) { const { filters, resetFilters } = useLibraryFilters(); - const items = useFilteredLibraryItems(data, filters); - return { items, collections: data.collections, isEmpty: items.length === 0, resetFilters }; + const { items, partial, hasNextPage, isFetchingNextPage, fetchNextPage } = useLibraryLens( + lens, + filters, + ); + const entries = useMemo(() => toSectionEntries(items, lens), [items, lens]); + return { + entries, + partial, + isEmpty: items.length === 0, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + resetFilters, + }; } diff --git a/apps/client/src/features/library/hooks/use-library-facets.ts b/apps/client/src/features/library/hooks/use-library-facets.ts new file mode 100644 index 00000000..c7781357 --- /dev/null +++ b/apps/client/src/features/library/hooks/use-library-facets.ts @@ -0,0 +1,42 @@ +import { useMemo } from "react"; +import { queryOptions, useQuery } from "@tanstack/react-query"; +import { deriveFacetValues, type LibraryFacetValues } from "../lib/facets"; +import { fetchFacets } from "../lib/fetchers"; +import { libraryKeys } from "../lib/query-keys"; + +export type { LibraryFacetValues }; + +/** + * The one facets query definition, shared by the hook and the layout route + * loader (which warms it via `ensureQueryData`) so the cache key matches and the + * header never refetches. Totals are whole-library, so the key is unscoped. + */ +export const facetsQueryOptions = () => + queryOptions({ + queryKey: libraryKeys.facets(), + queryFn: fetchFacets, + staleTime: 60 * 1000, + }); + +/** + * The non-blocking facets read (skill rule 5: facets use `useQuery`, never a + * Suspense reader, so a slow/failing facet fetch degrades the header to empty + * pills instead of suspending the whole library route). One hook, one query + * (rule 7). The count maps drive the popover badges + the A→Z letter rail + + * the timeline decade markers; `facetValues` is the derived option list the + * popover iterates, memoed off the query data so the popover does not re-derive + * on unrelated re-renders. + * + * The query takes no filters — the totals are whole-library (not filter-aware, + * matching the mock look) — so its key is a bare `libraryKeys.facets()`. + */ +export function useLibraryFacets() { + const query = useQuery(facetsQueryOptions()); + const facetValues = useMemo(() => deriveFacetValues(query.data), [query.data]); + return { + facetCounts: query.data, + facetValues, + isLoading: query.isLoading, + error: query.error, + }; +} diff --git a/apps/client/src/features/library/hooks/use-library-lens.ts b/apps/client/src/features/library/hooks/use-library-lens.ts new file mode 100644 index 00000000..2b243e3a --- /dev/null +++ b/apps/client/src/features/library/hooks/use-library-lens.ts @@ -0,0 +1,44 @@ +import { useMemo } from "react"; +import type { QueryClient } from "@tanstack/react-query"; +import type { LibraryLens } from "@ent-mcp/shared/library"; +import { prefetchMediaRows, useMediaRows } from "@/shared/media/use-media-rows"; +import { defineLensSource } from "../lib/fetchers"; +import type { LibraryFilters } from "../lib/types"; + +/** The lens that has its own endpoint (collections), excluded from the media source path. */ +type ItemLens = Exclude; + +/** + * Loader-side warm for an item lens (skill rule 5: loaders warm Suspense reads). + * Threads the SAME `defineLensSource` the hook reads, so the cache key matches + * and the suspense boundary paints with data on first mount instead of a + * fallback. The route loader reads the active filters off the URL search and + * passes them here. + */ +export function prefetchLibraryLens( + queryClient: QueryClient, + lens: ItemLens, + filters: LibraryFilters, +): Promise { + return prefetchMediaRows(queryClient, defineLensSource(lens, filters)); +} + +/** + * The Suspense infinite read for one item lens (skill rule 5: primary lens reads + * are `useSuspenseInfiniteQuery`). It REUSES the shared media-source infinite + * hook home + watchlist read through (`useMediaRows` → `mediaRowsQueryOptions`) + * rather than reinventing cursor threading / flatten / cache keying — the four + * item lenses are just media sources (`library-`) parameterized by the + * active filters. One hook, one query (rule 7). + * + * The source descriptor is memoed on `lens + filters` so the same filter combo + * reuses one `ClientMediaSource` reference (and so one stable query key); a + * filter change mints a new source and the shared hook keys a fresh cache entry. + * Returns the flat sorted `items` stream plus the infinite-scroll controls the + * `VirtualGrid` consumes — the page inserts section headers off this flat stream + * via `toSectionEntries`. + */ +export function useLibraryLens(lens: ItemLens, filters: LibraryFilters) { + const source = useMemo(() => defineLensSource(lens, filters), [lens, filters]); + return useMediaRows(source); +} diff --git a/apps/client/src/features/library/hooks/use-library-view.ts b/apps/client/src/features/library/hooks/use-library-view.ts deleted file mode 100644 index ce0f601e..00000000 --- a/apps/client/src/features/library/hooks/use-library-view.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { useMemo } from "react"; -import { collectFacetValues, computeFacetCounts } from "../lib/filtering"; -import type { LibraryData, LibraryFilters } from "../lib/types"; -import { useFilteredLibraryItems } from "./use-filtered-library-items"; - -interface UseLibraryViewArgs { - data: LibraryData; - filters: LibraryFilters; -} - -/** - * Derives every value the page renders from the raw payload plus the current - * filter state: the filtered item set (what the lenses group), the available - * facet values, and the per-option counts. Memoed so re-renders driven by - * unrelated state (e.g. lens switch) stay cheap. - */ -export function useLibraryView({ data, filters }: UseLibraryViewArgs) { - const filtered = useFilteredLibraryItems(data, filters); - const facetValues = useMemo(() => collectFacetValues(data.items), [data.items]); - const facetCounts = useMemo(() => computeFacetCounts(data.items), [data.items]); - return { filtered, facetValues, facetCounts }; -} diff --git a/apps/client/src/features/library/hooks/use-library.ts b/apps/client/src/features/library/hooks/use-library.ts deleted file mode 100644 index e35a35f9..00000000 --- a/apps/client/src/features/library/hooks/use-library.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { useSuspenseQuery } from "@tanstack/react-query"; -import { libraryDataQueryOptions } from "../lib/queries"; - -/** Primary library read (skill rule 5: Suspense). The route prefetches the same query. */ -export function useLibrary() { - return useSuspenseQuery(libraryDataQueryOptions()); -} diff --git a/apps/client/src/features/library/index.ts b/apps/client/src/features/library/index.ts index 5227e884..223b9c02 100644 --- a/apps/client/src/features/library/index.ts +++ b/apps/client/src/features/library/index.ts @@ -7,5 +7,19 @@ export { TimelineLensPage } from "./components/lenses/timeline-lens-page"; export { CollectionsLensPage } from "./components/lenses/collections-lens-page"; export { ServersLensPage } from "./components/lenses/servers-lens-page"; export { QualityLensPage } from "./components/lenses/quality-lens-page"; -export { libraryDataQueryOptions } from "./lib/queries"; -export { librarySearchSchema } from "./lib/search"; +export { librarySearchSchema, searchToFilters } from "./lib/search"; + +// Data layer (Phase 4): per-lens infinite reads, collections, and facets. Lens +// pages read through these; route loaders warm them via the exported query +// options before the suspense boundaries mount. +export { useLibraryContent } from "./hooks/use-library-content"; +export { useLibraryLens, prefetchLibraryLens } from "./hooks/use-library-lens"; +export { + useLibraryCollections, + libraryCollectionsQueryOptions, + prefetchLibraryCollections, +} from "./hooks/use-library-collections"; +export { useLibraryFacets, facetsQueryOptions } from "./hooks/use-library-facets"; +export { defineLensSource } from "./lib/fetchers"; +export { toSectionEntries, type LibrarySectionEntry } from "./lib/section-groups"; +export { libraryKeys } from "./lib/query-keys"; diff --git a/apps/client/src/features/library/lib/facets.ts b/apps/client/src/features/library/lib/facets.ts new file mode 100644 index 00000000..c528da1b --- /dev/null +++ b/apps/client/src/features/library/lib/facets.ts @@ -0,0 +1,38 @@ +import type { LibraryFacetCounts } from "@ent-mcp/shared/library"; + +/** The value lists the filter popover offers per multi-valued axis. */ +export interface LibraryFacetValues { + genres: string[]; + qualities: string[]; + servers: string[]; +} + +/** + * Derive the popover's option lists from the facet count maps. The keys of each + * count record ARE the present values (the server only emits a bucket that has + * at least one owned title), so the option list is the sorted key set — no + * separate value endpoint needed. Pinned to `en` collation so the fa build keeps + * the same option order. Pure (no React) so it is unit-testable on its own. + */ +export function deriveFacetValues(counts: LibraryFacetCounts | undefined): LibraryFacetValues { + if (!counts) return { genres: [], qualities: [], servers: [] }; + const sorted = (record: Record) => + Object.keys(record).sort((a, b) => a.localeCompare(b, "en")); + return { + genres: sorted(counts.genres), + qualities: sorted(counts.qualities), + servers: sorted(counts.servers), + }; +} + +/** + * The whole-library owned-title total: the sum of the per-kind facet counts. + * This is the header eyebrow's count, matching the unfiltered facets semantics + * (totals are whole-library, not filter-aware). Returns `0` when the facets have + * not landed yet so the eyebrow shows nothing rather than a partial number. + * Pure so it is unit-testable without rendering the header. + */ +export function libraryOwnedTotal(counts: LibraryFacetCounts | undefined): number { + if (!counts) return 0; + return Object.values(counts.kinds).reduce((sum, n) => sum + n, 0); +} diff --git a/apps/client/src/features/library/lib/fetchers.ts b/apps/client/src/features/library/lib/fetchers.ts index e47ff75a..78d2a122 100644 --- a/apps/client/src/features/library/lib/fetchers.ts +++ b/apps/client/src/features/library/lib/fetchers.ts @@ -1,12 +1,139 @@ -import { SAMPLE_LIBRARY } from "../__fixtures__/library-items.fixture"; -import type { LibraryData } from "./types"; +import type { InferRequestType } from "hono/client"; +import type { + LibraryCollectionsResponse, + LibraryFacetCounts, + LibraryLens, +} from "@ent-mcp/shared/library"; +import type { MediaSourceId } from "@ent-mcp/shared/media"; +import { api } from "@/shared/lib/api"; +import { defineMediaSource } from "@/shared/media/source"; +import type { LibraryFilters } from "./types"; +import { throwOnError } from "./types"; + +/** The query input the Hono RPC client infers for `GET /api/library/collections`. */ +type CollectionsQueryInput = InferRequestType["query"]; + +/** + * The query shape every library read accepts: the active facet filters flattened + * to the repeated-param encoding the server's tolerant `arrayParam` schema + * parses (`?genres=Drama&genres=Crime`). Each axis is optional (an empty axis is + * dropped entirely so a fully-open library hits a bare endpoint and shares one + * cache entry) plus the paginated callers' `cursor`. Named optional keys (not an + * index signature) so the object is assignable to the Hono client's inferred + * query type for the collections route. `limit` is threaded by the callers that + * paginate. + */ +type LibraryQuery = { + [K in keyof LibraryFilters]?: string[]; +} & { cursor?: string }; + +/** + * Flatten the URL-derived filter state into the repeated-param query the library + * endpoints expect. Hono serializes a string-array value as repeated params, and + * the shared `libraryLensQuerySchema` / `libraryCollectionsQuerySchema` coerce a + * lone value back to a one-element array — so multi-value axes round-trip without + * the single-value collapse the phase-2 sketch warned about. Empty axes are + * omitted (an absent param is an open axis server-side). + */ +export function filtersToQuery(filters: LibraryFilters): LibraryQuery { + const query: LibraryQuery = {}; + if (filters.kinds.length > 0) query.kinds = filters.kinds; + if (filters.genres.length > 0) query.genres = filters.genres; + if (filters.qualities.length > 0) query.qualities = filters.qualities; + if (filters.servers.length > 0) query.servers = filters.servers; + if (filters.watched.length > 0) query.watched = filters.watched; + return query; +} + +/** + * Build the shared `ClientMediaSource` for one item lens (`az`/`timeline`/ + * `server`/`quality`). The four lenses serve through the unified + * `GET /api/media/sources/:sourceId` resolver, so they reuse `defineMediaSource` + * (the one bound media-read fetcher) rather than a per-lens fetch — the cursor + * rides as a query param and the resolver re-parses the filter params off the + * query. `cursorOnNull: "firstPage"` matches the server registrations (a bad/ + * absent cursor falls to page one instead of 400-ing). + * + * `params` carries the flattened filters so `mediaRowsQueryOptions` folds them + * into the cache key — each filter combination gets its own entry. The + * `library-collections` id is excluded: collections is not a media source (it + * has its own endpoint + response shape). + * + * MULTI-VALUE LIMITATION (carried over from phase 2, surfaced loudly per rule + * 12): the unified source route parses its filters with `c.req.query()` + * (single-value) inside the handler — NOT `c.req.queries()` — so a repeated + * param collapses to one value server-side, and the shared `defineMediaSource` + * `toQuery` forwards only string/number values (it drops arrays). So each lens + * axis is sent as its FIRST selected value; selecting two genres filters by the + * first only. The collections + facets endpoints (their own routes, validated + * with `c.req.queries()`) DO honor multi-value. Settling true multi-value on the + * lens path is a server change (read `queries()` + split) tracked as a followup. + */ +export function defineLensSource( + lens: Exclude, + filters: LibraryFilters, +) { + const sourceId = `library-${lens}` as MediaSourceId; + return defineMediaSource({ + sourceId, + params: lensSourceParams(filters), + mode: "infinite", + cursorOnNull: "firstPage", + }); +} + +/** + * The flat single-value params `defineMediaSource.toQuery` forwards. Each axis + * sends its first selected value (the unified source route collapses repeated + * params to one anyway — see {@link defineLensSource}); an empty axis is left + * undefined so `toQuery` drops it. The whole object is still folded into the + * cache key, so two different first-values are two cache entries. + */ +function lensSourceParams(filters: LibraryFilters): Record { + const first = (values: string[]): string | undefined => values[0]; + return { + kinds: first(filters.kinds), + genres: first(filters.genres), + qualities: first(filters.qualities), + servers: first(filters.servers), + watched: first(filters.watched), + }; +} + +/** + * Fetch one page of the Collections lens (`GET /api/library/collections`). + * Group-first server-side: the response is `{ collections, cursor }`, not a + * media `Page`, so it has its own thin fetcher (the shared media source only + * speaks `Page`). The opaque `cursor` threads forward; a null + * `cursor` in the response signals the last group. + */ +export async function fetchCollectionsPage( + filters: LibraryFilters, + cursor: string | null, +): Promise { + const query = filtersToQuery(filters); + if (cursor) query.cursor = cursor; + // The Hono RPC types every filter axis as a required query key (the zod + // `arrayParam` is output-optional but the inferred input is not), while + // `filtersToQuery` omits open axes by design. The server schema tolerates an + // absent axis (`arrayParam` → undefined → no filter), so the omission is + // correct on the wire — cast to the inferred input to bridge the + // required-key/optional-key gap without sending empty params. + const res = await api.library.collections.$get({ + query: query as CollectionsQueryInput, + }); + if (!res.ok) await throwOnError(res); + return (await res.json()) as LibraryCollectionsResponse; +} /** - * Library data source. The unified media API (epic #491) does not yet expose a - * catalog endpoint, so this resolves the mock fixture; the React Query wiring, - * grouping, and filtering are all real and will keep working unchanged once - * this swaps to an `api.*` call. Async to model the eventual network fetch. + * Fetch the unfiltered facet totals (`GET /api/library/facets`). Non-paginated + * and not filter-aware (totals match the mock look), so it takes no params; the + * header reads it once via a non-blocking `useQuery` to drive the popover badges, + * the A→Z letter rail, and the timeline decade markers. */ -export async function fetchLibrary(): Promise { - return Promise.resolve(SAMPLE_LIBRARY); +export async function fetchFacets(): Promise { + const res = await api.library.facets.$get(); + if (!res.ok) await throwOnError(res); + return (await res.json()) as LibraryFacetCounts; } diff --git a/apps/client/src/features/library/lib/filtering.ts b/apps/client/src/features/library/lib/filtering.ts index 8860408a..843fbfcf 100644 --- a/apps/client/src/features/library/lib/filtering.ts +++ b/apps/client/src/features/library/lib/filtering.ts @@ -1,107 +1,43 @@ -import { countBy, uniq } from "es-toolkit"; -import type { LibraryFacetCounts, LibraryFilters, LibraryItem, WatchedState } from "./types"; +import type { WatchedState } from "@ent-mcp/shared/library"; +import type { CompactMediaItem } from "@ent-mcp/shared/media"; +import type { LibraryFilters } from "./types"; -/** The quality tiers and servers a facet can offer, derived from the item set. */ -export function qualitiesOf(item: LibraryItem): string[] { +/** + * The multi-valued facet axes read off a single item. Filtering itself is now + * server-side (SQL `WHERE` + `json_each`), so these no longer feed a client + * filter pass — they remain for the facet display + the card chip badges that + * still read a title's quality tiers, servers, and genres directly. + */ +export function qualitiesOf(item: CompactMediaItem): string[] { return item.tags ?? []; } -export function serversOf(item: LibraryItem): string[] { +export function serversOf(item: CompactMediaItem): string[] { return item.availability?.servers.map((server) => server.label) ?? []; } -export function genresOf(item: LibraryItem): string[] { +export function genresOf(item: CompactMediaItem): string[] { return item.genres ?? []; } /** Whether an item carries started-but-meaningful progress worth classifying. */ function hasProgress( - progress: LibraryItem["progress"], -): progress is NonNullable { + progress: CompactMediaItem["progress"], +): progress is NonNullable { return progress != null && progress.total > 0 && progress.watched > 0; } -/** Classify a title by how far through it the user is. */ -export function watchedStateOf(item: LibraryItem): WatchedState { +/** + * Classify a title by how far through it the user is. The server now drives the + * `watched` facet/filter axis, but this stays for the card's own watched badge, + * which reads the item's progress directly rather than a server-supplied flag. + */ +export function watchedStateOf(item: CompactMediaItem): WatchedState { const progress = item.progress; if (!hasProgress(progress)) return "unwatched"; return progress.watched >= progress.total ? "watched" : "partial"; } -/** Sorted unique values for a facet axis across the whole catalog. */ -export function collectFacetValues(items: LibraryItem[]): { - genres: string[]; - qualities: string[]; - servers: string[]; -} { - return { - genres: uniq(items.flatMap(genresOf)).sort(), - qualities: uniq(items.flatMap(qualitiesOf)).sort(), - servers: uniq(items.flatMap(serversOf)).sort(), - }; -} - -/** Does an item satisfy a single facet axis? An empty axis matches everything. */ -function matchesAxis(selected: readonly string[], values: readonly string[]): boolean { - return selected.length === 0 || values.some((value) => selected.includes(value)); -} - -function matchesFilters(item: LibraryItem, filters: LibraryFilters): boolean { - const axes: [readonly string[], readonly string[]][] = [ - [filters.kinds, [item.mediaType]], - [filters.genres, genresOf(item)], - [filters.qualities, qualitiesOf(item)], - [filters.servers, serversOf(item)], - [filters.watched, [watchedStateOf(item)]], - ]; - return axes.every(([selected, values]) => matchesAxis(selected, values)); -} - -/** Apply the facet filters to the catalog. */ -export function applyLibraryFilters(items: LibraryItem[], filters: LibraryFilters): LibraryItem[] { - return items.filter((item) => matchesFilters(item, filters)); -} - -/** Count how many items carry each distinct value on a multi-valued axis. */ -function countValues( - items: LibraryItem[], - valuesOf: (item: LibraryItem) => string[], -): Record { - const counts: Record = {}; - for (const item of items) { - for (const value of uniq(valuesOf(item))) { - counts[value] = (counts[value] ?? 0) + 1; - } - } - return counts; -} - -/** Tally the three watched buckets, filling any absent bucket with zero. */ -function countWatched(items: LibraryItem[]): Record { - const counts = countBy(items, watchedStateOf) as Partial>; - return { - watched: counts.watched ?? 0, - partial: counts.partial ?? 0, - unwatched: counts.unwatched ?? 0, - }; -} - -/** - * How many items match each facet option, counted across the whole catalog so - * the badges stay stable as the user toggles pills (design: facet count badges). - * Single-valued axes (kind, watched) fall straight out of `countBy`; the - * multi-valued axes count each distinct value an item carries exactly once. - */ -export function computeFacetCounts(items: LibraryItem[]): LibraryFacetCounts { - return { - kinds: countBy(items, (item) => item.mediaType), - genres: countValues(items, genresOf), - qualities: countValues(items, qualitiesOf), - servers: countValues(items, serversOf), - watched: countWatched(items), - }; -} - /** Total number of selected options across every facet axis. */ export function countActiveFilters(filters: LibraryFilters): number { return ( diff --git a/apps/client/src/features/library/lib/grouping.ts b/apps/client/src/features/library/lib/grouping.ts deleted file mode 100644 index 46f45bfb..00000000 --- a/apps/client/src/features/library/lib/grouping.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { groupBy, sortBy } from "es-toolkit"; -import { qualitiesOf, serversOf } from "./filtering"; -import type { LibraryItem } from "./types"; - -/** A titled bucket of items rendered as one section within a lens. */ -export interface LibraryGroup { - /** Stable key used for React keys and anchor ids. */ - key: string; - /** Display label for the section header. */ - label: string; - items: LibraryItem[]; -} - -/** - * Quality tiers in descending fidelity, used to order the Quality lens. A tag - * not listed here sorts to the end among its peers; extend this when the real - * API introduces new tiers (e.g. Dolby Vision, HDR10+) so their order is a - * deliberate decision rather than an alphabetical fallback. - */ -export const QUALITY_TIERS = ["4K HDR", "4K", "HDR", "Atmos"] as const; - -/** Drop a single leading article so titles bucket and sort by their real word. */ -function sortableTitle(title: string): string { - return title.trim().replace(/^(the|a|an)\s+/i, ""); -} - -function titleSort(items: LibraryItem[]): LibraryItem[] { - return sortBy(items, [(item) => sortableTitle(item.title).toLowerCase()]); -} - -/** - * The leading character used to bucket a title in the A→Z lens. A leading - * article (`The`/`A`/`An`) is stripped first so "The Amber Room" files under - * **A**, matching how Plex, Jellyfin, and Emby sort by letter. - */ -export function indexLetter(title: string): string { - const first = sortableTitle(title).charAt(0).toUpperCase(); - return /[A-Z]/.test(first) ? first : "#"; -} - -/** Group titles alphabetically (A–Z, with a trailing `#` bucket for the rest). */ -export function groupByLetter(items: LibraryItem[]): LibraryGroup[] { - const buckets = groupBy(items, (item) => indexLetter(item.title)); - const letters = Object.keys(buckets).sort((a, b) => { - if (a === "#") return 1; - if (b === "#") return -1; - // Pin to "en" so the A–Z rail keeps its English collation on the fa build. - return a.localeCompare(b, "en"); - }); - return letters.map((letter) => ({ - key: letter, - label: letter, - items: titleSort(buckets[letter] ?? []), - })); -} - -/** The full A–Z + `#` rail, with a flag for which letters actually have titles. */ -export function buildAlphabet(items: LibraryItem[]): { letter: string; populated: boolean }[] { - const populated = new Set(items.map((item) => indexLetter(item.title))); - const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split(""); - return [...letters, "#"].map((letter) => ({ letter, populated: populated.has(letter) })); -} - -function decadeOf(year: number | undefined): number | null { - if (year == null) return null; - return Math.floor(year / 10) * 10; -} - -/** - * Group by release decade, newest first. Items without a year collect into a - * trailing `unknown` bucket (key `"unknown"`, label resolved by the lens via - * i18n) so a yearless-only set still renders rather than leaving a blank route. - */ -export function groupByDecade(items: LibraryItem[]): LibraryGroup[] { - const buckets = groupBy(items, (item) => decadeOf(item.year) ?? "unknown"); - const decades = Object.keys(buckets) - .filter((key) => key !== "unknown") - .map(Number) - .sort((a, b) => b - a) - .map((decade) => ({ - key: String(decade), - label: `${decade}s`, - items: sortBy(buckets[decade] ?? [], [(item) => -(item.year ?? 0)]), - })); - const undated = buckets.unknown; - if (undated && undated.length > 0) { - decades.push({ key: "unknown", label: "unknown", items: titleSort(undated) }); - } - return decades; -} - -/** Bucket items by every value a multi-valued axis yields (a title can land in many buckets). */ -function bucketByValues( - items: LibraryItem[], - valuesOf: (item: LibraryItem) => string[], -): Map { - const buckets = new Map(); - for (const item of items) { - for (const value of valuesOf(item)) { - const bucket = buckets.get(value); - if (bucket) bucket.push(item); - else buckets.set(value, [item]); - } - } - return buckets; -} - -/** Group by server availability; a title appears under each server that hosts it. */ -export function groupByServer(items: LibraryItem[]): LibraryGroup[] { - const buckets = bucketByValues(items, serversOf); - return [...buckets.keys()].sort().map((server) => ({ - key: server, - label: server, - items: titleSort(buckets.get(server) ?? []), - })); -} - -/** Group by quality tier in descending fidelity; a title appears under each tier it carries. */ -export function groupByQuality(items: LibraryItem[]): LibraryGroup[] { - const buckets = bucketByValues(items, qualitiesOf); - const order = (tier: string) => { - const index = QUALITY_TIERS.indexOf(tier as (typeof QUALITY_TIERS)[number]); - return index === -1 ? QUALITY_TIERS.length : index; - }; - return [...buckets.keys()] - .sort((a, b) => order(a) - order(b) || a.localeCompare(b, "en")) - .map((quality) => ({ - key: quality, - label: quality, - items: titleSort(buckets.get(quality) ?? []), - })); -} diff --git a/apps/client/src/features/library/lib/labels.ts b/apps/client/src/features/library/lib/labels.ts index 13a16970..dce27a29 100644 --- a/apps/client/src/features/library/lib/labels.ts +++ b/apps/client/src/features/library/lib/labels.ts @@ -1,6 +1,6 @@ +import type { LibraryLens, WatchedState } from "@ent-mcp/shared/library"; import type { MediaType } from "@ent-mcp/shared/media"; import * as m from "@/paraglide/messages"; -import type { LibraryLens, WatchedState } from "./types"; /** Localized label functions for the feature's enums (skill rule 9). */ export const lensLabel = (lens: LibraryLens): string => m.library_lens_label({ lens }); @@ -10,3 +10,15 @@ export const kindLabel = (kind: MediaType): string => m.media_kind({ kind }); export const facetSectionLabel = ( facet: "kind" | "genre" | "quality" | "server" | "watched", ): string => m.library_filter_section({ facet }); + +/** + * Resolve a stable timeline section key to its display label. `section-groups` + * emits an i18n-free key — `"unknown"` for yearless titles or the decade's lead + * year (e.g. `"2020"`) — so this is the single render-boundary seam that turns + * the key into localized text: the `library_timeline_unknown` message for the + * yearless bucket, and `${decade}s` (e.g. "2020s") for a decade. Keeping the key + * locale-free lets grouping, scroll-spy, and anchors compare keys without a + * locale dependency while the visible header still localizes. + */ +export const timelineSectionLabel = (key: string): string => + key === "unknown" ? m.library_timeline_unknown() : `${key}s`; diff --git a/apps/client/src/features/library/lib/queries.ts b/apps/client/src/features/library/lib/queries.ts deleted file mode 100644 index 7c9ed13e..00000000 --- a/apps/client/src/features/library/lib/queries.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { queryOptions } from "@tanstack/react-query"; -import { fetchLibrary } from "./fetchers"; -import { libraryKeys } from "./query-keys"; - -/** Suspense-ready query for the full library payload; prefetched by the route loader. */ -export const libraryDataQueryOptions = () => - queryOptions({ - queryKey: libraryKeys.data(), - queryFn: fetchLibrary, - staleTime: 5 * 60 * 1000, - }); diff --git a/apps/client/src/features/library/lib/query-keys.ts b/apps/client/src/features/library/lib/query-keys.ts index b3f0416e..e17774b9 100644 --- a/apps/client/src/features/library/lib/query-keys.ts +++ b/apps/client/src/features/library/lib/query-keys.ts @@ -1,5 +1,20 @@ -/** Hierarchical query-key factory for the library feature (skill rule 4). */ +import type { LibraryFilters } from "./types"; + +/** + * Hierarchical query-key factory for the library feature (skill rule 4). Filters + * are part of the key on every filter-aware read, so each filter combination + * gets its own cache entry and toggling a pill never reads a stale page. + * + * The four item lenses (`az`/`timeline`/`server`/`quality`) do NOT key here — + * they serve through the shared media source and reuse `mediaKeys.source( + * sourceId, params)` so a post-mutation `invalidateQueries({ queryKey: + * mediaKeys.root })` sweeps them alongside home + watchlist. This factory owns + * only the two reads with their own endpoints: collections and facets. + */ export const libraryKeys = { all: ["library"] as const, - data: () => [...libraryKeys.all, "data"] as const, + /** Collections lens infinite query, scoped by the active filters. */ + collections: (filters: LibraryFilters) => [...libraryKeys.all, "collections", filters] as const, + /** Unfiltered facet totals; no filter scoping (totals are whole-library). */ + facets: () => [...libraryKeys.all, "facets"] as const, } as const; diff --git a/apps/client/src/features/library/lib/search.ts b/apps/client/src/features/library/lib/search.ts index f1658ccc..958547f1 100644 --- a/apps/client/src/features/library/lib/search.ts +++ b/apps/client/src/features/library/lib/search.ts @@ -1,6 +1,7 @@ import { z } from "zod"; +import { WATCHED_STATES } from "@ent-mcp/shared/library"; import { MEDIA_TYPES } from "@ent-mcp/shared/media"; -import { WATCHED_STATES, type LibraryFilters } from "./types"; +import type { LibraryFilters } from "./types"; /** * A tolerant array search param. A single value (`?kinds=movie`) is coerced to diff --git a/apps/client/src/features/library/lib/section-groups.ts b/apps/client/src/features/library/lib/section-groups.ts new file mode 100644 index 00000000..e8f782c7 --- /dev/null +++ b/apps/client/src/features/library/lib/section-groups.ts @@ -0,0 +1,140 @@ +import type { LibraryLens } from "@ent-mcp/shared/library"; +import type { CompactMediaItem } from "@ent-mcp/shared/media"; + +/** + * One entry in the flat render list a lens walks: either a section header + * (rendered as a `SectionHead`) or one item cell. The item entry carries its + * `sectionKey` so the list can key a row by `id + sectionKey` — the `server` + * and `quality` lenses repeat the same title once per server / tier (their + * `json_each` expansion), so `id` alone is not unique down the stream. + */ +export type LibrarySectionEntry = + | { type: "header"; key: string; label: string } + | { type: "item"; item: CompactMediaItem; sectionKey: string }; + +/** Drop a single leading article so a title buckets by its real first word. */ +function sortableTitle(title: string): string { + return title.trim().replace(/^(the|a|an)\s+/i, ""); +} + +/** + * The leading character used to bucket a title in the A→Z lens. A leading + * article (`The`/`A`/`An`) is stripped first so "The Amber Room" files under + * **A**, matching how Plex, Jellyfin, and Emby sort by letter. Non-alphabetic + * leads (digits, symbols) collect under `#`. + */ +export function indexLetter(title: string): string { + const first = sortableTitle(title).charAt(0).toUpperCase(); + return /[A-Z]/.test(first) ? first : "#"; +} + +/** The decade bucket for a release year; yearless titles collect under `unknown`. */ +function decadeKey(year: number | undefined): string { + return year == null ? "unknown" : String(Math.floor(year / 10) * 10); +} + +/** + * The (key, label) a single item contributes to its section, per lens. The key + * is the stable grouping discriminator (header inserted when it changes down the + * stream); the label is the header text. The server now sorts the stream so this + * is a pure read of each item — no client sorting or grouping. + * + * - `az`: first letter of the (article-stripped) title. + * - `timeline`: the stable decade key (the lead year, e.g. `"2020"`) or + * `"unknown"` for yearless titles — both key AND label are this i18n-free + * token; the visible header text ("2020s" / "Unknown year") is resolved at the + * render boundary by `timelineSectionLabel` so grouping stays locale-free. + * - `server` / `quality`: the server-supplied `item.section` (id = server/tier, + * label = display text). Falls back to an empty key when absent so a row + * without a section still renders (it just shares the prior header). + * - `collections`: not grouped here (the endpoint is group-first); callers + * render collection cards directly and never pass this lens in. + */ +function sectionOf(item: CompactMediaItem, lens: LibraryLens): { key: string; label: string } { + switch (lens) { + case "az": { + const key = indexLetter(item.title); + return { key, label: key }; + } + case "timeline": { + // The key IS the label here: a stable, i18n-free token (`"unknown"` or the + // decade's lead year). The render boundary localizes it via + // `timelineSectionLabel` — keeping grouping/anchors locale-free. + const key = decadeKey(item.year); + return { key, label: key }; + } + case "server": + case "quality": + return { key: item.section?.id ?? "", label: item.section?.label ?? "" }; + case "collections": + return { key: item.id, label: item.title }; + } +} + +/** + * Walk a flat, server-sorted item stream and splice in a section header each + * time the lens's group key changes (design §FE rewire — server groups, client + * inserts headers). Pure and allocation-light: one pass, tracking only the + * last-seen key. Returns a flat `LibrarySectionEntry[]` the lens maps to header + * + cell nodes; the header for the `unknown`/empty key still emits so a lead + * bucket is never silently dropped. + * + * The item entry's `sectionKey` is the same group key, so a list keys its rows + * on `${item.id}-${sectionKey}` — required for the `server`/`quality` lenses + * where one title appears under several sections. + */ +export function toSectionEntries( + items: readonly CompactMediaItem[], + lens: LibraryLens, +): LibrarySectionEntry[] { + const entries: LibrarySectionEntry[] = []; + let lastKey: string | null = null; + for (const item of items) { + const { key, label } = sectionOf(item, lens); + if (key !== lastKey) { + entries.push({ type: "header", key, label }); + lastKey = key; + } + entries.push({ type: "item", item, sectionKey: key }); + } + return entries; +} + +/** + * One contiguous section of the flat stream: a header plus the items that + * follow it until the next group-key change. The `key`/`label` come from the + * header; `sectionKey` rides on each row so a list keys on `${item.id}-${ + * sectionKey}` (the `server`/`quality` lenses repeat one title across sections, + * so `id` alone is not unique). + */ +export interface LibrarySection { + key: string; + label: string; + items: { item: CompactMediaItem; sectionKey: string }[]; +} + +/** + * Fold the flat `LibrarySectionEntry[]` into discrete sections so each renders + * as a `SectionHead` over its own poster grid — preserving the per-section look + * the client-side `groupBy*` used to produce, now that the server returns one + * sorted stream and headers are spliced on key change. A pure single pass over + * the entries; the stream is already header-delimited so this only re-shapes + * (no sort, no grouping). An item before its first header (defensive — the + * stream always leads with one) starts an unlabeled section so no row is lost. + */ +export function toSections(entries: readonly LibrarySectionEntry[]): LibrarySection[] { + const sections: LibrarySection[] = []; + for (const entry of entries) { + if (entry.type === "header") { + sections.push({ key: entry.key, label: entry.label, items: [] }); + continue; + } + let current = sections.at(-1); + if (current === undefined) { + current = { key: entry.sectionKey, label: "", items: [] }; + sections.push(current); + } + current.items.push({ item: entry.item, sectionKey: entry.sectionKey }); + } + return sections; +} diff --git a/apps/client/src/features/library/lib/types.ts b/apps/client/src/features/library/lib/types.ts index 195d9c67..a3aa6c70 100644 --- a/apps/client/src/features/library/lib/types.ts +++ b/apps/client/src/features/library/lib/types.ts @@ -1,25 +1,23 @@ +import type { WatchedState } from "@ent-mcp/shared/library"; import type { CompactMediaItem, MediaType } from "@ent-mcp/shared/media"; +import type { ApiErrorBody } from "@/shared/lib/diagnostics/api-error-body"; +import { throwOnApiError } from "@/shared/lib/api/throw-on-error"; /** - * The library renders the same wire shape as the home feed. Quality tiers ride - * along on `CompactMediaItem.tags` (e.g. `["4K HDR", "Atmos"]`) and server - * availability on `availability.servers`, so no feature-local extension of the - * media item is needed — the shared `MediaRowCard` consumes it directly. + * The library renders the shared wire item directly — quality tiers ride on + * `tags` and server availability on `availability.servers`, so no feature-local + * extension is needed. Kept as an alias the card/grid components read; new code + * should import `CompactMediaItem` from `@ent-mcp/shared/media` directly. */ export type LibraryItem = CompactMediaItem; /** - * The five viewing "lenses" the page slices its catalog through. Each is a - * different grouping of the same filtered item set (design: lens tabs). + * The facet axes a user can narrow the catalog by, in addition to free-text + * search. This is UI-local filter state (the URL search params hydrate it), so + * it stays in the feature; the lens/quality/watched tuples it draws from live + * in `@ent-mcp/shared/library` and are imported directly (never re-exported + * through this module — see the shared-package rules). */ -export const LIBRARY_LENSES = ["az", "timeline", "collections", "server", "quality"] as const; -export type LibraryLens = (typeof LIBRARY_LENSES)[number]; - -/** Watched-progress buckets used by the filter facet. */ -export const WATCHED_STATES = ["watched", "partial", "unwatched"] as const; -export type WatchedState = (typeof WATCHED_STATES)[number]; - -/** The facet axes a user can narrow the catalog by, in addition to free-text search. */ export interface LibraryFilters { kinds: MediaType[]; genres: string[]; @@ -37,25 +35,38 @@ export const EMPTY_FILTERS: LibraryFilters = { watched: [], }; -/** A curated, user-defined grouping of items, surfaced by the Collections lens. */ -export interface LibraryCollection { - id: string; - title: string; - /** Composite ids referencing items in the library set. */ - itemIds: string[]; -} +/** + * The one client-side library error (rule 3, mirrors the shared media + * `MediaApiError`). Every library read — the lens pages routed through the + * shared media source, the collections feed, and the facets query — surfaces + * the same typed envelope: the HTTP `status`, the parsed `body`, and the stable + * `code` the ErrorBoundary keys its retry copy off. + * + * The lens pages reuse the shared media source (`defineMediaSource`), which + * throws `MediaApiError`; the collections + facets fetchers below throw this. + * Both extend `Error` with the identical `{ status, body, code }` surface, so a + * shared boundary handles either uniformly. + */ +export class LibraryApiError extends Error { + readonly status: number; + readonly body: ApiErrorBody | null; + readonly code: string | undefined; -/** The full mock payload the (currently mocked) library fetch resolves. */ -export interface LibraryData { - items: LibraryItem[]; - collections: LibraryCollection[]; + constructor(status: number, body: ApiErrorBody | null) { + super(body?.message ?? body?.devMessage ?? `library request failed (${status})`); + this.name = "LibraryApiError"; + this.status = status; + this.body = body; + this.code = typeof body?.code === "string" ? body.code : undefined; + } } -/** Per-option match counts shown as badges next to each facet pill. */ -export interface LibraryFacetCounts { - kinds: Record; - genres: Record; - qualities: Record; - servers: Record; - watched: Record; +/** + * The one library `throwOnError` tail. Delegates to the shared + * `throwOnApiError` idiom (so this module carries no local copy of the + * read-envelope-and-throw dance) bound to {@link LibraryApiError}. The + * collections + facets fetchers call this on a non-OK response. + */ +export async function throwOnError(res: Response): Promise { + return throwOnApiError(res, LibraryApiError); } diff --git a/apps/client/src/routes/_authenticated/_app/library.collections.tsx b/apps/client/src/routes/_authenticated/_app/library.collections.tsx index f11d4d16..aea51622 100644 --- a/apps/client/src/routes/_authenticated/_app/library.collections.tsx +++ b/apps/client/src/routes/_authenticated/_app/library.collections.tsx @@ -4,11 +4,14 @@ import { CollectionsLensPage, LibraryContentSkeleton, LibraryRouteError, - libraryDataQueryOptions, + prefetchLibraryCollections, + searchToFilters, } from "@/features/library"; export const Route = createFileRoute("/_authenticated/_app/library/collections")({ - loader: ({ context: { queryClient } }) => queryClient.ensureQueryData(libraryDataQueryOptions()), + loaderDeps: ({ search }) => ({ search }), + loader: ({ context: { queryClient }, deps }) => + prefetchLibraryCollections(queryClient, searchToFilters(deps.search)), pendingComponent: LibraryContentSkeleton, errorComponent: LibraryRouteError, component: CollectionsLensPage, diff --git a/apps/client/src/routes/_authenticated/_app/library.index.tsx b/apps/client/src/routes/_authenticated/_app/library.index.tsx index 3ecb6371..15c260f8 100644 --- a/apps/client/src/routes/_authenticated/_app/library.index.tsx +++ b/apps/client/src/routes/_authenticated/_app/library.index.tsx @@ -4,11 +4,16 @@ import { AzLensPage, LibraryContentSkeleton, LibraryRouteError, - libraryDataQueryOptions, + prefetchLibraryLens, + searchToFilters, } from "@/features/library"; export const Route = createFileRoute("/_authenticated/_app/library/")({ - loader: ({ context: { queryClient } }) => queryClient.ensureQueryData(libraryDataQueryOptions()), + // Re-run the loader when the URL filters change so the warmed first page + // matches the active facet selection (the suspense hook keys on the same). + loaderDeps: ({ search }) => ({ search }), + loader: ({ context: { queryClient }, deps }) => + prefetchLibraryLens(queryClient, "az", searchToFilters(deps.search)), pendingComponent: LibraryContentSkeleton, errorComponent: LibraryRouteError, component: AzLensPage, diff --git a/apps/client/src/routes/_authenticated/_app/library.quality.tsx b/apps/client/src/routes/_authenticated/_app/library.quality.tsx index db919194..7d59f18d 100644 --- a/apps/client/src/routes/_authenticated/_app/library.quality.tsx +++ b/apps/client/src/routes/_authenticated/_app/library.quality.tsx @@ -4,11 +4,14 @@ import { LibraryContentSkeleton, LibraryRouteError, QualityLensPage, - libraryDataQueryOptions, + prefetchLibraryLens, + searchToFilters, } from "@/features/library"; export const Route = createFileRoute("/_authenticated/_app/library/quality")({ - loader: ({ context: { queryClient } }) => queryClient.ensureQueryData(libraryDataQueryOptions()), + loaderDeps: ({ search }) => ({ search }), + loader: ({ context: { queryClient }, deps }) => + prefetchLibraryLens(queryClient, "quality", searchToFilters(deps.search)), pendingComponent: LibraryContentSkeleton, errorComponent: LibraryRouteError, component: QualityLensPage, diff --git a/apps/client/src/routes/_authenticated/_app/library.server.tsx b/apps/client/src/routes/_authenticated/_app/library.server.tsx index 2a220fe9..8cafbe3d 100644 --- a/apps/client/src/routes/_authenticated/_app/library.server.tsx +++ b/apps/client/src/routes/_authenticated/_app/library.server.tsx @@ -4,11 +4,14 @@ import { LibraryContentSkeleton, LibraryRouteError, ServersLensPage, - libraryDataQueryOptions, + prefetchLibraryLens, + searchToFilters, } from "@/features/library"; export const Route = createFileRoute("/_authenticated/_app/library/server")({ - loader: ({ context: { queryClient } }) => queryClient.ensureQueryData(libraryDataQueryOptions()), + loaderDeps: ({ search }) => ({ search }), + loader: ({ context: { queryClient }, deps }) => + prefetchLibraryLens(queryClient, "server", searchToFilters(deps.search)), pendingComponent: LibraryContentSkeleton, errorComponent: LibraryRouteError, component: ServersLensPage, diff --git a/apps/client/src/routes/_authenticated/_app/library.timeline.tsx b/apps/client/src/routes/_authenticated/_app/library.timeline.tsx index 80c140aa..e9f196d6 100644 --- a/apps/client/src/routes/_authenticated/_app/library.timeline.tsx +++ b/apps/client/src/routes/_authenticated/_app/library.timeline.tsx @@ -4,11 +4,14 @@ import { LibraryContentSkeleton, LibraryRouteError, TimelineLensPage, - libraryDataQueryOptions, + prefetchLibraryLens, + searchToFilters, } from "@/features/library"; export const Route = createFileRoute("/_authenticated/_app/library/timeline")({ - loader: ({ context: { queryClient } }) => queryClient.ensureQueryData(libraryDataQueryOptions()), + loaderDeps: ({ search }) => ({ search }), + loader: ({ context: { queryClient }, deps }) => + prefetchLibraryLens(queryClient, "timeline", searchToFilters(deps.search)), pendingComponent: LibraryContentSkeleton, errorComponent: LibraryRouteError, component: TimelineLensPage, diff --git a/apps/client/src/routes/_authenticated/_app/library.tsx b/apps/client/src/routes/_authenticated/_app/library.tsx index 227b0e77..460ba383 100644 --- a/apps/client/src/routes/_authenticated/_app/library.tsx +++ b/apps/client/src/routes/_authenticated/_app/library.tsx @@ -5,7 +5,7 @@ import { LibraryLayout, LibraryRouteError, LibrarySkeleton, - libraryDataQueryOptions, + facetsQueryOptions, librarySearchSchema, } from "@/features/library"; @@ -13,7 +13,12 @@ export const Route = createFileRoute("/_authenticated/_app/library")({ // Filters live in the URL so the shared header and the active lens route read // one source of truth; the schema is inherited by every `/library/*` child. validateSearch: librarySearchSchema, - loader: ({ context: { queryClient } }) => queryClient.ensureQueryData(libraryDataQueryOptions()), + // Warm the (non-blocking, unfiltered) facets so the header's pills + counts + // paint on first mount; the per-lens first page is prefetched by each child + // lens route. `void`-fire so a slow facets read never blocks the route. + loader: ({ context: { queryClient } }) => { + void queryClient.ensureQueryData(facetsQueryOptions()); + }, pendingComponent: LibrarySkeleton, errorComponent: LibraryRouteError, component: LibraryLayoutRoute, diff --git a/apps/server/drizzle/0004_glamorous_grey_gargoyle.sql b/apps/server/drizzle/0004_glamorous_grey_gargoyle.sql new file mode 100644 index 00000000..ed8955e8 --- /dev/null +++ b/apps/server/drizzle/0004_glamorous_grey_gargoyle.sql @@ -0,0 +1,33 @@ +CREATE TABLE `library_items` ( + `id` text NOT NULL, + `user_id` text NOT NULL, + `tmdb_id` text NOT NULL, + `media_type` text NOT NULL, + `owned` integer DEFAULT true NOT NULL, + `owned_at` integer NOT NULL, + `unowned_at` integer, + `sort_title` text DEFAULT '' NOT NULL, + `year` integer, + `genres` text DEFAULT '[]' NOT NULL, + `servers` text DEFAULT '[]' NOT NULL, + `quality_tiers` text DEFAULT '[]' NOT NULL, + `watched_state` text, + `collection_id` text, + `collection_name` text, + `hydrated_at` integer, + PRIMARY KEY(`user_id`, `id`), + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `library_items_user_tmdb_type_uq` ON `library_items` (`user_id`,`tmdb_id`,`media_type`);--> statement-breakpoint +CREATE INDEX `library_items_user_owned_sort_id_idx` ON `library_items` (`user_id`,`owned`,`sort_title`,`id`);--> statement-breakpoint +CREATE INDEX `library_items_user_owned_year_id_idx` ON `library_items` (`user_id`,`owned`,`year`,`id`);--> statement-breakpoint +CREATE INDEX `library_items_user_owned_collection_idx` ON `library_items` (`user_id`,`owned`,`collection_id`);--> statement-breakpoint +CREATE TABLE `user_library_seed` ( + `user_id` text PRIMARY KEY NOT NULL, + `seeded_at` integer NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +ALTER TABLE `canonical_metadata` ADD `collection_id` text;--> statement-breakpoint +ALTER TABLE `canonical_metadata` ADD `collection_name` text; \ No newline at end of file diff --git a/apps/server/drizzle/meta/0004_snapshot.json b/apps/server/drizzle/meta/0004_snapshot.json new file mode 100644 index 00000000..c74e3546 --- /dev/null +++ b/apps/server/drizzle/meta/0004_snapshot.json @@ -0,0 +1,3663 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "91002cfb-995f-45b6-a9ce-db051e4b8c36", + "prevId": "6f5a2bc2-f8a9-4c6c-ab60-29ff11c6dbb7", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "jwks": { + "name": "jwks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_access_token": { + "name": "oauth_access_token", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "columns": ["token"], + "isUnique": true + }, + "oauth_access_token_user_client_idx": { + "name": "oauth_access_token_user_client_idx", + "columns": ["user_id", "client_id"], + "isUnique": false + } + }, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": ["refresh_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_client": { + "name": "oauth_client", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disabled": { + "name": "disabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contacts": { + "name": "contacts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "grant_types": { + "name": "grant_types", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_types": { + "name": "response_types", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "columns": ["client_id"], + "isUnique": true + } + }, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_consent": { + "name": "oauth_consent", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_consent_user_client_idx": { + "name": "oauth_consent_user_client_idx", + "columns": ["user_id", "client_id"], + "isUnique": false + } + }, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_refresh_token": { + "name": "oauth_refresh_token", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revoked": { + "name": "revoked", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_time": { + "name": "auth_time", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_refresh_token_user_client_idx": { + "name": "oauth_refresh_token_user_client_idx", + "columns": ["user_id", "client_id"], + "isUnique": false + } + }, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": ["token"], + "isUnique": true + }, + "session_userId_idx": { + "name": "session_userId_idx", + "columns": ["user_id"], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": ["email"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": ["identifier"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "role_permissions": { + "name": "role_permissions", + "columns": { + "role_id": { + "name": "role_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "role_permissions_role_id_roles_id_fk": { + "name": "role_permissions_role_id_roles_id_fk", + "tableFrom": "role_permissions", + "tableTo": "roles", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "role_permissions_role_id_permission_pk": { + "columns": ["role_id", "permission"], + "name": "role_permissions_role_id_permission_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "roles": { + "name": "roles", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_system": { + "name": "is_system", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "roles_name_unique": { + "name": "roles_name_unique", + "columns": ["name"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_roles": { + "name": "user_roles", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role_id": { + "name": "role_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_roles_user_id_unique": { + "name": "user_roles_user_id_unique", + "columns": ["user_id"], + "isUnique": true + } + }, + "foreignKeys": { + "user_roles_user_id_user_id_fk": { + "name": "user_roles_user_id_user_id_fk", + "tableFrom": "user_roles", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_roles_role_id_roles_id_fk": { + "name": "user_roles_role_id_roles_id_fk", + "tableFrom": "user_roles", + "tableTo": "roles", + "columnsFrom": ["role_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "canonical_metadata": { + "name": "canonical_metadata", + "columns": { + "tmdb_id": { + "name": "tmdb_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "year": { + "name": "year", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "runtime_minutes": { + "name": "runtime_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "poster_url": { + "name": "poster_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "backdrop_url": { + "name": "backdrop_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "clear_logo_url": { + "name": "clear_logo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "overview": { + "name": "overview", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "original_language": { + "name": "original_language", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "collection_name": { + "name": "collection_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "genres": { + "name": "genres", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "features": { + "name": "features", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "canonical_metadata_last_refreshed_idx": { + "name": "canonical_metadata_last_refreshed_idx", + "columns": ["last_refreshed_at"], + "isUnique": false + }, + "canonical_metadata_last_accessed_idx": { + "name": "canonical_metadata_last_accessed_idx", + "columns": ["last_accessed_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "canonical_metadata_tmdb_id_media_type_pk": { + "columns": ["tmdb_id", "media_type"], + "name": "canonical_metadata_tmdb_id_media_type_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "discover_snapshots": { + "name": "discover_snapshots", + "columns": { + "feed_kind": { + "name": "feed_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort": { + "name": "sort", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "day": { + "name": "day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "items": { + "name": "items", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "generated_at": { + "name": "generated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "discover_snapshots_day_idx": { + "name": "discover_snapshots_day_idx", + "columns": ["day"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "discover_snapshots_feed_kind_sort_day_pk": { + "columns": ["feed_kind", "sort", "day"], + "name": "discover_snapshots_feed_kind_sort_day_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "recommendation_lists": { + "name": "recommendation_lists", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "list_kind": { + "name": "list_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "items": { + "name": "items", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "profile_version": { + "name": "profile_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "generated_at": { + "name": "generated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "recommendation_lists_user_id_user_id_fk": { + "name": "recommendation_lists_user_id_user_id_fk", + "tableFrom": "recommendation_lists", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "recommendation_lists_user_id_list_kind_pk": { + "columns": ["user_id", "list_kind"], + "name": "recommendation_lists_user_id_list_kind_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_history_mirror": { + "name": "user_history_mirror", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "events": { + "name": "events", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_cursors": { + "name": "plugin_cursors", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_history_mirror_user_id_user_id_fk": { + "name": "user_history_mirror_user_id_user_id_fk", + "tableFrom": "user_history_mirror", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_ratings_mirror": { + "name": "user_ratings_mirror", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "events": { + "name": "events", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_cursors": { + "name": "plugin_cursors", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_ratings_mirror_user_id_user_id_fk": { + "name": "user_ratings_mirror_user_id_user_id_fk", + "tableFrom": "user_ratings_mirror", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "id_map": { + "name": "id_map", + "columns": { + "tmdb_id": { + "name": "tmdb_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imdb_id": { + "name": "imdb_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tvdb_id": { + "name": "tvdb_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trakt_id": { + "name": "trakt_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trakt_slug": { + "name": "trakt_slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "id_map_imdb_idx": { + "name": "id_map_imdb_idx", + "columns": ["imdb_id"], + "isUnique": false + }, + "id_map_tvdb_idx": { + "name": "id_map_tvdb_idx", + "columns": ["tvdb_id"], + "isUnique": false + }, + "id_map_trakt_idx": { + "name": "id_map_trakt_idx", + "columns": ["trakt_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "id_map_tmdb_id_media_type_pk": { + "columns": ["tmdb_id", "media_type"], + "name": "id_map_tmdb_id_media_type_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "home_layout_cache": { + "name": "home_layout_cache", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "blob": { + "name": "blob", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "generated_at": { + "name": "generated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "home_layout_cache_generated_at_idx": { + "name": "home_layout_cache_generated_at_idx", + "columns": ["generated_at"], + "isUnique": false + } + }, + "foreignKeys": { + "home_layout_cache_user_id_user_id_fk": { + "name": "home_layout_cache_user_id_user_id_fk", + "tableFrom": "home_layout_cache", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "job_config": { + "name": "job_config", + "columns": { + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "schedule_override": { + "name": "schedule_override", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "log_level": { + "name": "log_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'info'" + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "job_config_updated_by_user_id_fk": { + "name": "job_config_updated_by_user_id_fk", + "tableFrom": "job_config", + "tableTo": "user", + "columnsFrom": ["updated_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "job_runs": { + "name": "job_runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "triggered_by": { + "name": "triggered_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rows_total": { + "name": "rows_total", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rows_succeeded": { + "name": "rows_succeeded", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rows_failed": { + "name": "rows_failed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_record_id": { + "name": "error_record_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "logs": { + "name": "logs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "logs_truncated": { + "name": "logs_truncated", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "coalesced_count": { + "name": "coalesced_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "job_runs_job_started_idx": { + "name": "job_runs_job_started_idx", + "columns": ["job_id", "started_at"], + "isUnique": false + }, + "job_runs_started_idx": { + "name": "job_runs_started_idx", + "columns": ["started_at"], + "isUnique": false + }, + "job_runs_status_started_idx": { + "name": "job_runs_status_started_idx", + "columns": ["status", "started_at"], + "isUnique": false + }, + "job_runs_request_idx": { + "name": "job_runs_request_idx", + "columns": ["request_id"], + "isUnique": false + }, + "job_runs_scope_key_idx": { + "name": "job_runs_scope_key_idx", + "columns": ["scope_key"], + "isUnique": false + } + }, + "foreignKeys": { + "job_runs_triggered_by_user_id_user_id_fk": { + "name": "job_runs_triggered_by_user_id_user_id_fk", + "tableFrom": "job_runs", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_runs_error_record_id_error_records_id_fk": { + "name": "job_runs_error_record_id_error_records_id_fk", + "tableFrom": "job_runs", + "tableTo": "error_records", + "columnsFrom": ["error_record_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "app_config": { + "name": "app_config", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "error_retention_days": { + "name": "error_retention_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 30 + }, + "perf_retention_days": { + "name": "perf_retention_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 7 + }, + "inbox_retention_days": { + "name": "inbox_retention_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 90 + }, + "delivery_retention_days": { + "name": "delivery_retention_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 30 + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "error_records": { + "name": "error_records", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dev_message": { + "name": "dev_message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stack": { + "name": "stack", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "route": { + "name": "route", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "http_status": { + "name": "http_status", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "error_records_created_idx": { + "name": "error_records_created_idx", + "columns": ["created_at"], + "isUnique": false + }, + "error_records_request_id_idx": { + "name": "error_records_request_id_idx", + "columns": ["request_id"], + "isUnique": false + }, + "error_records_plugin_created_idx": { + "name": "error_records_plugin_created_idx", + "columns": ["plugin_id", "created_at"], + "isUnique": false + }, + "error_records_severity_created_idx": { + "name": "error_records_severity_created_idx", + "columns": ["severity", "created_at"], + "isUnique": false + } + }, + "foreignKeys": { + "error_records_user_id_user_id_fk": { + "name": "error_records_user_id_user_id_fk", + "tableFrom": "error_records", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "error_records_plugin_id_plugins_id_fk": { + "name": "error_records_plugin_id_plugins_id_fk", + "tableFrom": "error_records", + "tableTo": "plugins", + "columnsFrom": ["plugin_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "error_records_connection_id_service_connections_id_fk": { + "name": "error_records_connection_id_service_connections_id_fk", + "tableFrom": "error_records", + "tableTo": "service_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "perf_records": { + "name": "perf_records", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "route": { + "name": "route", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "perf_records_created_idx": { + "name": "perf_records_created_idx", + "columns": ["created_at"], + "isUnique": false + }, + "perf_records_kind_route_created_idx": { + "name": "perf_records_kind_route_created_idx", + "columns": ["kind", "route", "created_at"], + "isUnique": false + }, + "perf_records_kind_plugin_created_idx": { + "name": "perf_records_kind_plugin_created_idx", + "columns": ["kind", "plugin_id", "created_at"], + "isUnique": false + }, + "perf_records_request_id_idx": { + "name": "perf_records_request_id_idx", + "columns": ["request_id"], + "isUnique": false + } + }, + "foreignKeys": { + "perf_records_plugin_id_plugins_id_fk": { + "name": "perf_records_plugin_id_plugins_id_fk", + "tableFrom": "perf_records", + "tableTo": "plugins", + "columnsFrom": ["plugin_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "perf_records_user_id_user_id_fk": { + "name": "perf_records_user_id_user_id_fk", + "tableFrom": "perf_records", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "library_items": { + "name": "library_items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tmdb_id": { + "name": "tmdb_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owned": { + "name": "owned", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "owned_at": { + "name": "owned_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "unowned_at": { + "name": "unowned_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_title": { + "name": "sort_title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "year": { + "name": "year", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "genres": { + "name": "genres", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "servers": { + "name": "servers", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "quality_tiers": { + "name": "quality_tiers", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "watched_state": { + "name": "watched_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "collection_name": { + "name": "collection_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hydrated_at": { + "name": "hydrated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "library_items_user_tmdb_type_uq": { + "name": "library_items_user_tmdb_type_uq", + "columns": ["user_id", "tmdb_id", "media_type"], + "isUnique": true + }, + "library_items_user_owned_sort_id_idx": { + "name": "library_items_user_owned_sort_id_idx", + "columns": ["user_id", "owned", "sort_title", "id"], + "isUnique": false + }, + "library_items_user_owned_year_id_idx": { + "name": "library_items_user_owned_year_id_idx", + "columns": ["user_id", "owned", "year", "id"], + "isUnique": false + }, + "library_items_user_owned_collection_idx": { + "name": "library_items_user_owned_collection_idx", + "columns": ["user_id", "owned", "collection_id"], + "isUnique": false + } + }, + "foreignKeys": { + "library_items_user_id_user_id_fk": { + "name": "library_items_user_id_user_id_fk", + "tableFrom": "library_items", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "library_items_user_id_id_pk": { + "columns": ["user_id", "id"], + "name": "library_items_user_id_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_library_seed": { + "name": "user_library_seed", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "seeded_at": { + "name": "seeded_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_library_seed_user_id_user_id_fk": { + "name": "user_library_seed_user_id_user_id_fk", + "tableFrom": "user_library_seed", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_deliveries": { + "name": "notification_deliveries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_payload": { + "name": "event_payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_connection_id": { + "name": "recipient_connection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipient_user_id": { + "name": "recipient_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_message_id": { + "name": "provider_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "correlation_key": { + "name": "correlation_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "notification_deliveries_user_created_idx": { + "name": "notification_deliveries_user_created_idx", + "columns": ["recipient_user_id", "created_at"], + "isUnique": false + }, + "notification_deliveries_status_updated_idx": { + "name": "notification_deliveries_status_updated_idx", + "columns": ["status", "updated_at"], + "isUnique": false + }, + "notification_deliveries_correlation_key_idx": { + "name": "notification_deliveries_correlation_key_idx", + "columns": ["correlation_key"], + "isUnique": false + }, + "notification_deliveries_next_attempt_idx": { + "name": "notification_deliveries_next_attempt_idx", + "columns": ["next_attempt_at"], + "isUnique": false + } + }, + "foreignKeys": { + "notification_deliveries_recipient_connection_id_service_connections_id_fk": { + "name": "notification_deliveries_recipient_connection_id_service_connections_id_fk", + "tableFrom": "notification_deliveries", + "tableTo": "service_connections", + "columnsFrom": ["recipient_connection_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "notification_deliveries_recipient_user_id_user_id_fk": { + "name": "notification_deliveries_recipient_user_id_user_id_fk", + "tableFrom": "notification_deliveries", + "tableTo": "user", + "columnsFrom": ["recipient_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_subscriptions": { + "name": "notification_subscriptions", + "columns": { + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": { + "notification_subscriptions_connection_id_service_connections_id_fk": { + "name": "notification_subscriptions_connection_id_service_connections_id_fk", + "tableFrom": "notification_subscriptions", + "tableTo": "service_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "notification_subscriptions_connection_id_category_pk": { + "columns": ["connection_id", "category"], + "name": "notification_subscriptions_connection_id_category_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notifications_inbox": { + "name": "notifications_inbox", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "delivery_id": { + "name": "delivery_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action_url": { + "name": "action_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image_alt": { + "name": "image_alt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "read_at": { + "name": "read_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "notifications_inbox_user_created_idx": { + "name": "notifications_inbox_user_created_idx", + "columns": ["user_id", "created_at"], + "isUnique": false + }, + "notifications_inbox_user_read_created_idx": { + "name": "notifications_inbox_user_read_created_idx", + "columns": ["user_id", "read_at", "created_at"], + "isUnique": false + } + }, + "foreignKeys": { + "notifications_inbox_delivery_id_notification_deliveries_id_fk": { + "name": "notifications_inbox_delivery_id_notification_deliveries_id_fk", + "tableFrom": "notifications_inbox", + "tableTo": "notification_deliveries", + "columnsFrom": ["delivery_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "notifications_inbox_user_id_user_id_fk": { + "name": "notifications_inbox_user_id_user_id_fk", + "tableFrom": "notifications_inbox", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pending_auth": { + "name": "pending_auth", + "columns": { + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state_iv": { + "name": "state_iv", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "pending_auth_user_id_user_id_fk": { + "name": "pending_auth_user_id_user_id_fk", + "tableFrom": "pending_auth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_auth_plugin_id_plugins_id_fk": { + "name": "pending_auth_plugin_id_plugins_id_fk", + "tableFrom": "pending_auth", + "tableTo": "plugins", + "columnsFrom": ["plugin_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_store": { + "name": "plugin_store", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "plugin_store_expires_idx": { + "name": "plugin_store_expires_idx", + "columns": ["expires_at"], + "isUnique": false + } + }, + "foreignKeys": { + "plugin_store_plugin_id_plugins_id_fk": { + "name": "plugin_store_plugin_id_plugins_id_fk", + "tableFrom": "plugin_store", + "tableTo": "plugins", + "columnsFrom": ["plugin_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "plugin_store_user_id_user_id_fk": { + "name": "plugin_store_user_id_user_id_fk", + "tableFrom": "plugin_store", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "plugin_store_plugin_id_user_id_key_pk": { + "columns": ["plugin_id", "user_id", "key"], + "name": "plugin_store_plugin_id_user_id_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugins": { + "name": "plugins", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "manifest": { + "name": "manifest", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "global_config": { + "name": "global_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "personal_key_fallback": { + "name": "personal_key_fallback", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'off'" + }, + "admin_allowlist": { + "name": "admin_allowlist", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "admin_headers_encrypted": { + "name": "admin_headers_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "admin_headers_iv": { + "name": "admin_headers_iv", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "installed_by": { + "name": "installed_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "installed_at": { + "name": "installed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "plugins_installed_by_user_id_fk": { + "name": "plugins_installed_by_user_id_fk", + "tableFrom": "plugins", + "tableTo": "user", + "columnsFrom": ["installed_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_shared_credentials": { + "name": "plugin_shared_credentials", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "iv": { + "name": "iv", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "last_exhausted_at": { + "name": "last_exhausted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retry_after": { + "name": "retry_after", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "psc_plugin_enabled_idx": { + "name": "psc_plugin_enabled_idx", + "columns": ["plugin_id", "enabled"], + "isUnique": false + } + }, + "foreignKeys": { + "plugin_shared_credentials_plugin_id_plugins_id_fk": { + "name": "plugin_shared_credentials_plugin_id_plugins_id_fk", + "tableFrom": "plugin_shared_credentials", + "tableTo": "plugins", + "columnsFrom": ["plugin_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "service_connections": { + "name": "service_connections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_config": { + "name": "user_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_credentials": { + "name": "encrypted_credentials", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credentials_iv": { + "name": "credentials_iv", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_verified_at": { + "name": "last_verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_exhausted_at": { + "name": "last_exhausted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retry_after": { + "name": "retry_after", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "service_connections_user_plugin_idx": { + "name": "service_connections_user_plugin_idx", + "columns": ["user_id", "plugin_id"], + "isUnique": false + } + }, + "foreignKeys": { + "service_connections_user_id_user_id_fk": { + "name": "service_connections_user_id_user_id_fk", + "tableFrom": "service_connections", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "service_connections_plugin_id_plugins_id_fk": { + "name": "service_connections_plugin_id_plugins_id_fk", + "tableFrom": "service_connections", + "tableTo": "plugins", + "columnsFrom": ["plugin_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "preference_profiles": { + "name": "preference_profiles", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "features": { + "name": "features", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sample_size": { + "name": "sample_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "confidence": { + "name": "confidence", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_rebuilt_at": { + "name": "last_rebuilt_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_updated_at": { + "name": "last_updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "embedding": { + "name": "embedding", + "type": "blob", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "preference_profiles_user_id_user_id_fk": { + "name": "preference_profiles_user_id_user_id_fk", + "tableFrom": "preference_profiles", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "preference_profiles_user_id_media_type_pk": { + "columns": ["user_id", "media_type"], + "name": "preference_profiles_user_id_media_type_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "primary_connections": { + "name": "primary_connections", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "capability_key": { + "name": "capability_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'_'" + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "primary_connections_user_id_user_id_fk": { + "name": "primary_connections_user_id_user_id_fk", + "tableFrom": "primary_connections", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "primary_connections_connection_id_service_connections_id_fk": { + "name": "primary_connections_connection_id_service_connections_id_fk", + "tableFrom": "primary_connections", + "tableTo": "service_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "primary_connections_user_id_capability_key_media_type_pk": { + "columns": ["user_id", "capability_key", "media_type"], + "name": "primary_connections_user_id_capability_key_media_type_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feedback": { + "name": "feedback", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tmdb_id": { + "name": "tmdb_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rating": { + "name": "rating", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note_sentiment": { + "name": "note_sentiment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note_keywords": { + "name": "note_keywords", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "feedback_user_created_at_idx": { + "name": "feedback_user_created_at_idx", + "columns": ["user_id", "created_at"], + "isUnique": false + }, + "feedback_user_item_idx": { + "name": "feedback_user_item_idx", + "columns": ["user_id", "tmdb_id", "media_type"], + "isUnique": false + } + }, + "foreignKeys": { + "feedback_user_id_user_id_fk": { + "name": "feedback_user_id_user_id_fk", + "tableFrom": "feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_watchlist_seed": { + "name": "user_watchlist_seed", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "seeded_at": { + "name": "seeded_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_watchlist_seed_user_id_user_id_fk": { + "name": "user_watchlist_seed_user_id_user_id_fk", + "tableFrom": "user_watchlist_seed", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "watchlist_items": { + "name": "watchlist_items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tmdb_id": { + "name": "tmdb_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "added_at": { + "name": "added_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "removed_at": { + "name": "removed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seeded": { + "name": "seeded", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "watchlist_items_user_tmdb_type_uq": { + "name": "watchlist_items_user_tmdb_type_uq", + "columns": ["user_id", "tmdb_id", "media_type"], + "isUnique": true + }, + "watchlist_items_user_state_added_idx": { + "name": "watchlist_items_user_state_added_idx", + "columns": ["user_id", "state", "added_at"], + "isUnique": false + }, + "watchlist_items_user_state_idx": { + "name": "watchlist_items_user_state_idx", + "columns": ["user_id", "state"], + "isUnique": false + } + }, + "foreignKeys": { + "watchlist_items_user_id_user_id_fk": { + "name": "watchlist_items_user_id_user_id_fk", + "tableFrom": "watchlist_items", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index 1903d02d..157e07d8 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1779257060546, "tag": "0003_breezy_wallow", "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1780431342101, + "tag": "0004_glamorous_grey_gargoyle", + "breakpoints": true } ] } diff --git a/apps/server/src/__tests__/boot.test.ts b/apps/server/src/__tests__/boot.test.ts index 817373f3..8b16fde5 100644 --- a/apps/server/src/__tests__/boot.test.ts +++ b/apps/server/src/__tests__/boot.test.ts @@ -17,6 +17,7 @@ const INDEX_EXPECTED_ORDER = [ "auth", "catalog", "home", + "library", "media", "notifications", "plugin-runtime", @@ -45,6 +46,7 @@ const NAMESPACE_TO_MODULE: Record = { auth: "auth", catalog: "catalog", home: "home", + library: "library", media: "media", notifications: "notifications", pluginRuntime: "plugin-runtime", diff --git a/apps/server/src/api/procedures/__tests__/media-registry.test.ts b/apps/server/src/api/procedures/__tests__/media-registry.test.ts index 635591df..fff51154 100644 --- a/apps/server/src/api/procedures/__tests__/media-registry.test.ts +++ b/apps/server/src/api/procedures/__tests__/media-registry.test.ts @@ -8,16 +8,18 @@ vi.mock("../../../env", () => ({ const { homeMediaSources } = await import("../../../home"); const { watchlistMediaSources } = await import("../../../watchlist"); +const { libraryMediaSources } = await import("../../../library"); /** - * The adapter composes one registry from the two consumer barrels exactly as - * `api/procedures/media.ts` will (design §A4) — `media` never imports a concrete + * The adapter composes one registry from the three consumer barrels exactly as + * `api/procedures/media.ts` does (design §A4) — `media` never imports a concrete * source (invariant V.RG1); the registry lives adapter-side. */ -const REGISTRY = { ...homeMediaSources, ...watchlistMediaSources }; +const REGISTRY = { ...homeMediaSources, ...watchlistMediaSources, ...libraryMediaSources }; const HOME_IDS = Object.keys(homeMediaSources); const WATCHLIST_IDS = Object.keys(watchlistMediaSources); +const LIBRARY_IDS = Object.keys(libraryMediaSources); // A stub context is safe: `build` is pure construction (it captures the context // in the lazy enrich closure but never runs `fetchRawSet`), so no DB/plugin @@ -44,7 +46,7 @@ describe("media source registry (US-002)", () => { expect(Object.keys(REGISTRY).sort()).toEqual([...MEDIA_SOURCE_IDS].sort()); }); - it("partitions the tuple cleanly across the two consumer barrels", () => { + it("partitions the tuple cleanly across the three consumer barrels", () => { expect(HOME_IDS).toHaveLength(10); expect(WATCHLIST_IDS).toEqual([ "watchlist-items", @@ -52,8 +54,17 @@ describe("media source registry (US-002)", () => { "watchlist-recently", "watchlist-tonight", ]); - // Disjoint — no id is registered by both consumers. - expect(HOME_IDS.filter((id) => WATCHLIST_IDS.includes(id))).toEqual([]); + expect(LIBRARY_IDS).toEqual([ + "library-az", + "library-timeline", + "library-server", + "library-quality", + ]); + // Disjoint — no id is registered by more than one consumer. + expect(HOME_IDS.filter((id) => WATCHLIST_IDS.includes(id) || LIBRARY_IDS.includes(id))).toEqual( + [], + ); + expect(WATCHLIST_IDS.filter((id) => LIBRARY_IDS.includes(id))).toEqual([]); }); it("builds a source whose stages line up with the registration for every id", () => { @@ -90,6 +101,14 @@ describe("media source registry (US-002)", () => { // fan-out runs, so no override is supplied. expect(built.enrichRows, `${id} should not override enrich`).toBeUndefined(); } + for (const id of LIBRARY_IDS) { + const reg = REGISTRY[id]!; + const built = reg.build(stubCtx, reg.paramSchema.parse(SAMPLE_INPUT[id] ?? {}), null); + // Library lenses read the denormalized columns via a custom enrich (no + // availability re-probe, no row collapse), so they inject enrichRows like + // the home rows rather than running the default fan-out. + expect(typeof built.enrichRows, `${id} should inject enrichRows`).toBe("function"); + } }); it("preserves per-consumer cursor-null policy + rate limit (V.CU1 / §A7)", () => { @@ -105,6 +124,12 @@ describe("media source registry (US-002)", () => { expect(reg.rateLimit, `${id} watchlist read limiter`).toBe("read"); expect(typeof reg.eligibility, `${id} watchlist always eligible`).toBe("undefined"); } + for (const id of LIBRARY_IDS) { + const reg = REGISTRY[id]!; + expect(reg.cursorOnNull, `${id} library → first page`).toBe("firstPage"); + expect(reg.rateLimit, `${id} library read limiter`).toBe("read"); + expect(typeof reg.eligibility, `${id} library always eligible`).toBe("undefined"); + } }); it("carries requiresInitialCursor only on the seeded home rows", () => { diff --git a/apps/server/src/api/procedures/library.ts b/apps/server/src/api/procedures/library.ts new file mode 100644 index 00000000..784f8bbb --- /dev/null +++ b/apps/server/src/api/procedures/library.ts @@ -0,0 +1,66 @@ +import { Hono } from "hono"; +import { consola } from "consola"; +import { + libraryCollectionsQuerySchema, + type LibraryCollectionsResponse, + type LibraryFacetCounts, +} from "@ent-mcp/shared/library"; +import { requireSession, sessionUserId } from "../../auth"; +import { getCatalogService } from "../../catalog"; +import { zValidator } from "../../diagnostics/validator"; +import { getFacets, listCollections } from "../../library"; +import { MediaService } from "../../media"; +import { rateLimitOrNull } from "../rate-limit"; +import { watchlistReadLimiter } from "./media"; + +/** Per-request plugin-call deadline for the library reads, matching the watchlist bridges. */ +const LIBRARY_REQUEST_DEADLINE_MS = 5000; + +/** + * Builds the loose per-request context the library service consumes. Carries the + * per-user `MediaService` (the eager-seed path reaches `getCollectionFeed` + * through it) and the catalog handle the future filtered reads need, plus the + * shared logger. `getFacets` only reads the projection, but the context shape is + * the module's single `MaybeLibraryContext`, so it is built whole here. + */ +function buildLibraryContext(userId: string) { + return { + userId, + mediaService: new MediaService(userId), + catalog: getCatalogService(), + deadlineMs: LIBRARY_REQUEST_DEADLINE_MS, + log: consola, + }; +} + +/** + * The library read routes (design §API routes): `/facets` (unfiltered totals) + * and `/collections` (group-first owned franchises). The item lenses + * (`library-az` / `library-timeline` / `library-server` / `library-quality`) + * serve through the unified `GET /api/media/sources/:sourceId` resolver via the + * `libraryMediaSources` registrations, so they are NOT mounted here. + * + * Both routes reuse the shared read `TokenBucketLimiter` (`watchlistReadLimiter`, + * the read-family bucket the §A7 cutover centralized) so the library reads share + * the same per-user read budget as the rest of the media surface, and both sit + * behind `requireSession`. + */ +export const libraryApp = new Hono() + .use("*", requireSession) + .get("/facets", async (c) => { + const userId = sessionUserId(c); + const limited = rateLimitOrNull(watchlistReadLimiter, c, userId); + if (limited) return limited; + const facets: LibraryFacetCounts = await getFacets(buildLibraryContext(userId)); + return c.json(facets); + }) + .get("/collections", zValidator("query", libraryCollectionsQuerySchema), async (c) => { + const userId = sessionUserId(c); + const limited = rateLimitOrNull(watchlistReadLimiter, c, userId); + if (limited) return limited; + const response: LibraryCollectionsResponse = await listCollections( + buildLibraryContext(userId), + c.req.valid("query"), + ); + return c.json(response); + }); diff --git a/apps/server/src/api/procedures/media.ts b/apps/server/src/api/procedures/media.ts index 1910b7cf..0da3f689 100644 --- a/apps/server/src/api/procedures/media.ts +++ b/apps/server/src/api/procedures/media.ts @@ -30,6 +30,7 @@ import { homeMediaSources, } from "../../home"; import { getMoodSummary, watchlistMediaSources } from "../../watchlist"; +import { libraryMediaSources } from "../../library"; import { badRequest, notFound } from "../../diagnostics/http-errors"; import { zValidator } from "../../diagnostics/validator"; import { rateLimitOrNull } from "../rate-limit"; @@ -53,6 +54,7 @@ export const watchlistReadLimiter = new TokenBucketLimiter({ capacity: 30, refil const REGISTRY: Record = { ...homeMediaSources, ...watchlistMediaSources, + ...libraryMediaSources, }; /** Per-request plugin-call deadline budget, matching the home feed's `buildContext`. */ diff --git a/apps/server/src/api/router.ts b/apps/server/src/api/router.ts index e318b47f..a93c82aa 100644 --- a/apps/server/src/api/router.ts +++ b/apps/server/src/api/router.ts @@ -15,6 +15,7 @@ import { notificationsApp, adminNotificationsApp } from "./procedures/notificati import { artworkApp } from "./procedures/artwork"; import { homeApp } from "./procedures/home"; import { mediaApp } from "./procedures/media"; +import { libraryApp } from "./procedures/library"; import { searchApp } from "./procedures/search"; import { requestContextMiddleware, @@ -51,6 +52,7 @@ export const appRouter = new Hono() .route("/artwork", artworkApp) .route("/home", homeApp) .route("/media", mediaApp) + .route("/library", libraryApp) .route("/search", searchApp) .onError(errorHandler); diff --git a/apps/server/src/catalog/__tests__/canonical-metadata.test.ts b/apps/server/src/catalog/__tests__/canonical-metadata.test.ts index cc43058f..de59474f 100644 --- a/apps/server/src/catalog/__tests__/canonical-metadata.test.ts +++ b/apps/server/src/catalog/__tests__/canonical-metadata.test.ts @@ -135,6 +135,84 @@ describe("CatalogService canonical_metadata", () => { expect(row.backdropUrl).toBeNull(); }); + it("toCanonicalRow threads collection id and name onto the row", () => { + // The collections lens groups titles by TMDB franchise, so the plugin's + // `mediaItem.collection` must land on the canonical row verbatim. If this + // mapping is dropped, every movie reads as standalone and the lens breaks. + const row = toCanonicalRow(KEY_FIGHT_CLUB, { + title: "Fight Club", + type: "movie", + keywords: [], + cast: [], + director: null, + writers: [], + creators: [], + genres: [], + ids: { tmdb_id: "550" }, + collection: { id: "131635", name: "The Fight Club Collection" }, + }); + expect(row.collectionId).toBe("131635"); + expect(row.collectionName).toBe("The Fight Club Collection"); + }); + + it("toCanonicalRow leaves collection columns null when raw lacks a collection", () => { + // Standalone titles and all TV carry no collection; those rows must read + // back as null so the collections lens excludes them rather than inventing + // an empty-named franchise. A non-null default here would corrupt the lens. + const row = toCanonicalRow(KEY_TWIN_PEAKS, { + title: "Twin Peaks", + type: "tv", + keywords: [], + cast: [], + director: null, + writers: [], + creators: [], + genres: [], + ids: { tmdb_id: "1400" }, + }); + expect(row.collectionId).toBeNull(); + expect(row.collectionName).toBeNull(); + }); + + it("persists collection membership through writeMetadata and reads it back", async () => { + // Round-trips through the DB to prove the collection columns are not just + // computed in memory but actually written and selected back. This fails if + // `toCanonicalRow` stops emitting the columns or the schema drops them. + const catalog = new CatalogService(await createInMemoryDb()); + const row = buildRow(KEY_FIGHT_CLUB, { + collectionId: "131635", + collectionName: "The Fight Club Collection", + }); + await catalog.writeMetadata([row]); + + const fetched = await catalog.getMetadata("550", "movie"); + expect(fetched?.collectionId).toBe("131635"); + expect(fetched?.collectionName).toBe("The Fight Club Collection"); + }); + + it("refreshes collection membership on a conflict UPDATE, not just a fresh insert", async () => { + // The metadata-refresh job re-writes already-cached rows, hitting the + // `onConflictDoUpdate` path. If that SET clause omits the collection + // columns, franchise data first learned on a re-fetch never persists. This + // writes a row with no collection, then re-writes the same key with one, + // and proves the update lands. + const catalog = new CatalogService(await createInMemoryDb()); + await catalog.writeMetadata([buildRow(KEY_FIGHT_CLUB)]); + const before = await catalog.getMetadata("550", "movie"); + expect(before?.collectionId).toBeNull(); + + await catalog.writeMetadata([ + buildRow(KEY_FIGHT_CLUB, { + collectionId: "131635", + collectionName: "The Fight Club Collection", + }), + ]); + + const after = await catalog.getMetadata("550", "movie"); + expect(after?.collectionId).toBe("131635"); + expect(after?.collectionName).toBe("The Fight Club Collection"); + }); + it("surfaces NULL-features rows ahead of time-stale rows", async () => { const catalog = new CatalogService(await createInMemoryDb()); const now = Date.now(); diff --git a/apps/server/src/catalog/canonical.ts b/apps/server/src/catalog/canonical.ts index d77415f8..24602ff2 100644 --- a/apps/server/src/catalog/canonical.ts +++ b/apps/server/src/catalog/canonical.ts @@ -17,6 +17,9 @@ export interface RawArtwork { clearLogoUrl?: string | null; clearLogo?: string | null; overview?: string | null; + // TMDB franchise grouping threaded from the plugin `mediaItem.collection`. + // Optional and nullable so non-movie and pre-threading payloads still map. + collection?: { id: string; name: string } | null; } export type RawCanonicalSource = RawMediaItem & RawArtwork; @@ -48,6 +51,8 @@ export function toCanonicalRow( overview: nullableString(raw.overview), originalLanguage: nullableString(raw.originalLanguage), genres: emptyToNull(dedupeStrings(raw.genres)), + collectionId: raw.collection?.id ?? null, + collectionName: raw.collection?.name ?? null, features: extractFeatures(raw), lastRefreshedAt: now, lastAccessedAt: now, diff --git a/apps/server/src/catalog/service/index.ts b/apps/server/src/catalog/service/index.ts index d4e63dff..352469f0 100644 --- a/apps/server/src/catalog/service/index.ts +++ b/apps/server/src/catalog/service/index.ts @@ -156,6 +156,8 @@ export class CatalogService { originalLanguage: row.originalLanguage, genres: row.genres, features: row.features, + collectionId: row.collectionId, + collectionName: row.collectionName, lastRefreshedAt: row.lastRefreshedAt, lastAccessedAt: row.lastAccessedAt, createdAt: sql`COALESCE(${canonicalMetadata.createdAt}, ${row.createdAt})`, diff --git a/apps/server/src/db/schema/catalog/catalog.ts b/apps/server/src/db/schema/catalog/catalog.ts index d20a6d30..639969ab 100644 --- a/apps/server/src/db/schema/catalog/catalog.ts +++ b/apps/server/src/db/schema/catalog/catalog.ts @@ -30,6 +30,8 @@ export const canonicalMetadata = sqliteTable( clearLogoUrl: text("clear_logo_url"), overview: text("overview"), originalLanguage: text("original_language"), + collectionId: text("collection_id"), + collectionName: text("collection_name"), genres: text("genres", { mode: "json" }).$type(), features: text("features", { mode: "json" }).$type(), lastRefreshedAt: integer("last_refreshed_at").notNull(), diff --git a/apps/server/src/db/schema/index.ts b/apps/server/src/db/schema/index.ts index 04314811..d23cca13 100644 --- a/apps/server/src/db/schema/index.ts +++ b/apps/server/src/db/schema/index.ts @@ -6,6 +6,7 @@ export * from "./auth"; export * from "./catalog"; export * from "./home"; export * from "./infra"; +export * from "./library"; export * from "./notifications"; export * from "./plugin-runtime"; export * from "./preferences"; diff --git a/apps/server/src/db/schema/library/index.ts b/apps/server/src/db/schema/library/index.ts new file mode 100644 index 00000000..80866f8f --- /dev/null +++ b/apps/server/src/db/schema/library/index.ts @@ -0,0 +1,2 @@ +export * from "./library-items"; +export * from "./library-seed"; diff --git a/apps/server/src/db/schema/library/library-items.ts b/apps/server/src/db/schema/library/library-items.ts new file mode 100644 index 00000000..b5067acf --- /dev/null +++ b/apps/server/src/db/schema/library/library-items.ts @@ -0,0 +1,80 @@ +import { + sqliteTable, + text, + integer, + index, + uniqueIndex, + primaryKey, +} from "drizzle-orm/sqlite-core"; +import { MEDIA_TYPES } from "@ent-mcp/shared/media"; +import { WATCHED_STATES } from "@ent-mcp/shared/library"; +import { user } from "../auth/auth"; + +/** + * Denormalized browse projection for the owned library. One row per + * `(user_id, tmdb_id, media_type)`, keyed by the composite primary key + * `(user_id, id)` where `id` is `":"` — the same title can be + * owned by many users, so `id` is unique only WITHIN a user, never globally. + * `owned` acts as a tombstone: a title that + * leaves the owned collection stays at `owned = false` so a later sync does not + * resurrect it (watchlist pattern). The sort/facet columns are hydrated from + * the catalog metadata, availability, and progress pipelines so the lens + * sources can page directly off the index without re-probing live. + * + * JSON columns store text on disk but carry a richer TS shape. `$type()` + * documents the serialization contract at the schema level. `servers` and + * `quality_tiers` are multi-valued (an item on Plex+Jellyfin in 4K+1080p is one + * row whose arrays hold every value); the server/quality lenses expand them via + * `json_each`. + */ +export const libraryItems = sqliteTable( + "library_items", + { + id: text("id").notNull(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + tmdbId: text("tmdb_id").notNull(), + mediaType: text("media_type", { enum: MEDIA_TYPES }).notNull(), + owned: integer("owned", { mode: "boolean" }).notNull().default(true), + ownedAt: integer("owned_at").notNull(), + unownedAt: integer("unowned_at"), + sortTitle: text("sort_title").notNull().default(""), + year: integer("year"), + genres: text("genres", { mode: "json" }).$type().notNull().default([]), + servers: text("servers", { mode: "json" }) + .$type<{ id: string; label: string }[]>() + .notNull() + .default([]), + qualityTiers: text("quality_tiers", { mode: "json" }).$type().notNull().default([]), + watchedState: text("watched_state", { enum: WATCHED_STATES }), + collectionId: text("collection_id"), + collectionName: text("collection_name"), + hydratedAt: integer("hydrated_at"), + }, + (table) => [ + // Composite primary key: `id` (":") is unique only within + // a user, so the same title owned by two users is two distinct rows. A single + // global `id` PK would collide on the second owner and the membership upsert's + // ON CONFLICT DO NOTHING would silently drop them. + primaryKey({ columns: [table.userId, table.id] }), + uniqueIndex("library_items_user_tmdb_type_uq").on(table.userId, table.tmdbId, table.mediaType), + index("library_items_user_owned_sort_id_idx").on( + table.userId, + table.owned, + table.sortTitle, + table.id, + ), + index("library_items_user_owned_year_id_idx").on( + table.userId, + table.owned, + table.year, + table.id, + ), + index("library_items_user_owned_collection_idx").on( + table.userId, + table.owned, + table.collectionId, + ), + ], +); diff --git a/apps/server/src/db/schema/library/library-seed.ts b/apps/server/src/db/schema/library/library-seed.ts new file mode 100644 index 00000000..f9c34ab2 --- /dev/null +++ b/apps/server/src/db/schema/library/library-seed.ts @@ -0,0 +1,14 @@ +import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; +import { user } from "../auth/auth"; + +/** + * Marker rows. Presence means the user has been seeded from the owned + * collection feed at least once. Eager-seed on first read upserts here so the + * 6-hourly sync cron can iterate exactly the seeded users. + */ +export const userLibrarySeed = sqliteTable("user_library_seed", { + userId: text("user_id") + .primaryKey() + .references(() => user.id, { onDelete: "cascade" }), + seededAt: integer("seeded_at").notNull(), +}); diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index afeed7e1..1ed6152d 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -13,6 +13,7 @@ import * as artwork from "./artwork"; import * as auth from "./auth"; import * as catalog from "./catalog"; import * as home from "./home"; +import * as library from "./library"; import * as media from "./media"; import * as notifications from "./notifications"; import * as preferences from "./preferences"; @@ -40,6 +41,7 @@ async function bootstrap(): Promise { auth.registerJobs(); catalog.registerJobs(); home.registerJobs(); + library.registerJobs(); media.registerJobs(); notifications.registerJobs(); pluginRuntime.registerJobs(); diff --git a/apps/server/src/library/__tests__/collections.test.ts b/apps/server/src/library/__tests__/collections.test.ts new file mode 100644 index 00000000..10726f85 --- /dev/null +++ b/apps/server/src/library/__tests__/collections.test.ts @@ -0,0 +1,375 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { + cleanupInMemoryDbs, + createInMemoryDb, + type Db, +} from "../../__tests__/helpers/in-memory-db"; +import { user } from "../../db/schema/auth"; +import { libraryItems } from "../../db/schema/library"; + +// The collections repo resolves its database handle through `getDb()`. Mirror +// the lens-page and sync test harness EXACTLY: stub `env` (the db client imports +// it transitively) and point `getDb()` at the real migrated in-memory database +// so the group-first GROUP BY, the `COALESCE(collection_name, collection_id)` +// keyset ORDER BY, the filter-aware preview subquery, and the tenancy-scoped +// `selectRowsByIds` read are exercised against actual SQLite. The null-name +// keyset drop and the preview filter-leak can only be proven against the real +// query planner, never a mocked repo. +vi.mock("../../env", () => ({ + env: { CACHE_PROVIDER: "memory", ENCRYPTION_KEY: "test-key" }, +})); + +vi.mock("../../db/client", async () => { + const actual = await vi.importActual("../../db/client"); + return { + ...actual, + getDb: () => testDb, + }; +}); + +// Import the code under test AFTER the mocks are registered so it binds to the +// stubbed `getDb`. The repo and the cursor codec are imported real — mocking +// either would defeat the very invariants these tests lock. +const { selectCollections, selectRowsByIds, __resetLibraryForTests } = await import("../repo"); +const { encodeCollectionsCursor, decodeCollectionsCursor } = + await import("../internal/collections-cursor"); + +let testDb: Db; + +const USER_A = "u1"; +const USER_B = "u2"; + +/** A row the collections lens groups off, in the `library_items` insert shape. */ +interface SeedRow { + id: string; + userId?: string; + tmdbId?: string; + mediaType?: "movie" | "tv"; + sortTitle?: string; + year?: number | null; + genres?: string[]; + servers?: { id: string; label: string }[]; + qualityTiers?: string[]; + watchedState?: "watched" | "partial" | "unwatched" | null; + collectionId?: string | null; + collectionName?: string | null; + owned?: boolean; +} + +/** + * Inserts library rows directly into `library_items`, filling every not-null + * column with a defaultable value so a test only sets the axis it asserts on. + * The denormalized franchise columns (`collection_id`/`collection_name`), + * `sort_title`, `genres`, etc. are set explicitly so the grouping SQL and the + * preview subquery have real data to page off. `owned` defaults to true so rows + * land in the lens base set; a test overrides it to seed a tombstone. `userId` + * defaults to USER_A so the tenancy test can plant a colliding USER_B row. + */ +async function seed(rows: SeedRow[]): Promise { + await testDb.insert(libraryItems).values( + rows.map((r) => ({ + id: r.id, + userId: r.userId ?? USER_A, + tmdbId: r.tmdbId ?? r.id, + mediaType: r.mediaType ?? ("movie" as const), + owned: r.owned ?? true, + ownedAt: Date.now(), + sortTitle: r.sortTitle ?? "", + year: r.year === undefined ? null : r.year, + genres: r.genres ?? [], + servers: r.servers ?? [], + qualityTiers: r.qualityTiers ?? [], + watchedState: r.watchedState ?? null, + collectionId: r.collectionId === undefined ? null : r.collectionId, + collectionName: r.collectionName === undefined ? null : r.collectionName, + })), + ); +} + +/** + * Walks every Collections page from the first, threading the next cursor EXACTLY + * as `service.listCollections` does: a full page's `nextGroup` is encoded with + * `encodeCollectionsCursor`, then decoded back to a `CollectionCursor` with + * `decodeCollectionsCursor` before the next read. Returns the ordered list of + * collection ids the loop emitted. A safety cap turns an accidental + * infinite/duplicating loop into a failure rather than a hang. + */ +async function walkCollections( + userId: string, + limit: number, +): Promise<{ collectionIds: string[]; exhausted: boolean }> { + const collectionIds: string[] = []; + let cursor: ReturnType = undefined; + let exhausted = false; + for (let guard = 0; guard < 1000; guard++) { + const page = await selectCollections(userId, {}, cursor, limit); + for (const group of page.groups) collectionIds.push(group.collectionId); + if (!page.nextGroup) { + exhausted = true; + break; + } + cursor = decodeCollectionsCursor(encodeCollectionsCursor(page.nextGroup)); + } + return { collectionIds, exhausted }; +} + +beforeAll(async () => { + testDb = await createInMemoryDb(); + await testDb.insert(user).values([ + { + id: USER_A, + name: USER_A, + email: `${USER_A}@test`, + emailVerified: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: USER_B, + name: USER_B, + email: `${USER_B}@test`, + emailVerified: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]); +}); + +afterAll(() => cleanupInMemoryDbs()); + +beforeEach(async () => { + await __resetLibraryForTests(testDb); +}); + +describe("library collections lens (design §Collections lens, phase 3)", () => { + // OWNED-ONLY / TV-EXCLUDED — the base WHERE is `owned = true` AND + // `collection_id IS NOT NULL`. Only the owned movie WITH a collection id is a + // franchise; the TV row (null collection_id), the standalone movie (null + // collection_id), and the tombstoned movie (owned = false) must NEVER surface, + // and the tombstone must NOT inflate the franchise count. Seeding the + // tombstone in the SAME franchise as the owned movie is the mutation-sensitive + // part: a dropped `owned = true` guard would both surface a second group AND + // bump this group's count to 2. + it("returns only owned movies in a franchise, excluding TV, standalone, and tombstones", async () => { + await seed([ + { id: "movie:1", sortTitle: "A", collectionId: "10", collectionName: "Franchise" }, + // A TV row in a (nominal) collection: TV carries a null collection_id by + // construction, so it is never grouped. + { id: "tv:2", mediaType: "tv", sortTitle: "B", collectionId: null }, + // A standalone movie: no franchise, null collection_id, never grouped. + { id: "movie:3", sortTitle: "C", collectionId: null }, + // A tombstoned movie in the SAME franchise (id "10"): owned = false, so it + // is excluded from the group and must not inflate the count to 2. + { + id: "movie:4", + sortTitle: "D", + collectionId: "10", + collectionName: "Franchise", + owned: false, + }, + ]); + + const page = await selectCollections(USER_A, {}, undefined, 50); + + // Exactly one franchise, the owned-movie one; no TV/standalone/tombstone + // group leaked. + expect(page.groups).toHaveLength(1); + const [group] = page.groups; + expect(group?.collectionId).toBe("10"); + expect(group?.collectionName).toBe("Franchise"); + // The count excludes the tombstone: only `movie:1` is owned in franchise 10. + expect(group?.count).toBe(1); + expect(group?.previewIds).toEqual(["movie:1"]); + // A single short-read page exhausts the scan. + expect(page.nextGroup).toBeUndefined(); + }); + + // PREVIEW <=4 ORDERED — a franchise with six owned titles caps the preview at + // four (design §Collections lens: "preview ≤4"), ordered by `(sortTitle, id)` + // ascending. The count still reflects all six. Seeding sort titles out of + // insertion order proves the SQL ORDER BY (not insertion order) drives the + // preview, and the cap proves the `LIMIT 4` in the preview subquery. + it("caps the preview at four ids ordered by (sortTitle, id) while counting all", async () => { + await seed([ + { id: "movie:f6", sortTitle: "Foxtrot", collectionId: "10", collectionName: "Franchise" }, + { id: "movie:f1", sortTitle: "Alpha", collectionId: "10", collectionName: "Franchise" }, + { id: "movie:f4", sortTitle: "Delta", collectionId: "10", collectionName: "Franchise" }, + { id: "movie:f2", sortTitle: "Bravo", collectionId: "10", collectionName: "Franchise" }, + { id: "movie:f5", sortTitle: "Echo", collectionId: "10", collectionName: "Franchise" }, + { id: "movie:f3", sortTitle: "Charlie", collectionId: "10", collectionName: "Franchise" }, + ]); + + const page = await selectCollections(USER_A, {}, undefined, 50); + + const [group] = page.groups; + // The count is the full owned set, even though the preview is capped. + expect(group?.count).toBe(6); + // Exactly the first four in `(sortTitle, id)` order — never the later two. + expect(group?.previewIds).toEqual(["movie:f1", "movie:f2", "movie:f3", "movie:f4"]); + expect(group?.previewIds).toHaveLength(4); + }); + + // PREVIEW FILTER-AWARE — regression for the preview filter-leak fix. When a + // filter is active, BOTH the group count AND the preview ids must reflect only + // the matching titles: the preview subquery re-applies the same axis as the + // group count. The franchise carries three "Horror" titles and two "Comedy" + // titles; under `genres: ["Horror"]` the count is 3 and the preview lists ONLY + // the three Horror ids — no Comedy id may leak into the fan. Before the fix the + // count was filter-aware but the preview was not, so a Comedy id would appear; + // asserting the exact preview set (and that no Comedy id is present) fails on + // any leak. + it("applies the active filter to BOTH the count and the preview ids (no leak)", async () => { + await seed([ + { + id: "movie:h1", + sortTitle: "Aaa", + collectionId: "10", + collectionName: "Franchise", + genres: ["Horror"], + }, + { + id: "movie:c1", + sortTitle: "Bbb", + collectionId: "10", + collectionName: "Franchise", + genres: ["Comedy"], + }, + { + id: "movie:h2", + sortTitle: "Ccc", + collectionId: "10", + collectionName: "Franchise", + genres: ["Horror"], + }, + { + id: "movie:c2", + sortTitle: "Ddd", + collectionId: "10", + collectionName: "Franchise", + genres: ["Comedy"], + }, + { + id: "movie:h3", + sortTitle: "Eee", + collectionId: "10", + collectionName: "Franchise", + genres: ["Horror"], + }, + ]); + + const page = await selectCollections(USER_A, { genres: ["Horror"] }, undefined, 50); + + const [group] = page.groups; + // The count narrows to the three Horror titles. + expect(group?.count).toBe(3); + // The preview lists ONLY the Horror ids, ordered by `(sortTitle, id)` — no + // Comedy id leaks into the fan. + expect(group?.previewIds).toEqual(["movie:h1", "movie:h2", "movie:h3"]); + expect(group?.previewIds).not.toContain("movie:c1"); + expect(group?.previewIds).not.toContain("movie:c2"); + }); + + // NULL-NAME KEYSET — regression for the paging blocker. Franchises whose + // `collection_name` is NULL (only `collection_id` set: "20", "30", "40") must + // still page completely: the ORDER BY and the cursor predicate both compare + // `COALESCE(collection_name, collection_id)`, so the resume position is total + // even with no learned title. Paging the whole set in `limit = 1` hops, + // threading each page's `nextGroup` through encode/decode EXACTLY as + // `listCollections` does, must reconstruct EVERY franchise with NO drop and NO + // duplicate. If either side stopped using COALESCE (comparing the raw nullable + // name), the null-name groups would compare as NULL — never strictly-greater — + // and the cursor predicate would skip them at the very first boundary, so this + // loop would lose them. + it("pages every null-name franchise across boundaries with no drop or duplicate", async () => { + await seed([ + // One named franchise plus three null-name ones. Distinct sort titles so + // each id lands in its own group with one owned member. + { id: "movie:n1", sortTitle: "A", collectionId: "named", collectionName: "Zeta Saga" }, + { id: "movie:n2", sortTitle: "B", collectionId: "20", collectionName: null }, + { id: "movie:n3", sortTitle: "C", collectionId: "30", collectionName: null }, + { id: "movie:n4", sortTitle: "D", collectionId: "40", collectionName: null }, + ]); + + const { collectionIds, exhausted } = await walkCollections(USER_A, 1); + + // Every franchise surfaced exactly once across the one-at-a-time pages. + expect(new Set(collectionIds).size).toBe(4); + expect(collectionIds).toHaveLength(4); + expect([...collectionIds].sort()).toEqual(["20", "30", "40", "named"]); + // The paging order follows `COALESCE(name, id)` ascending: the null-name + // groups order by their id ("20" < "30" < "40"), then "Zeta Saga" sorts last. + expect(collectionIds).toEqual(["20", "30", "40", "named"]); + // The final short-read page emitted no next cursor, so the scan terminates. + expect(exhausted).toBe(true); + }); + + // TENANCY — regression for the `selectRowsByIds` cross-tenant scope. The + // composite id is unique only WITHIN a user, so the SAME id can exist for two + // users. `selectRowsByIds(userA, [...])` must return ONLY userA's owned rows + // even when userB owns a row with the same composite id (same franchise). + // Passing both ids — including the colliding one userB owns — must NOT leak + // userB's row. Without the `user_id = userA` scope the `id IN (…)` read would + // surface both rows and cross tenants. + it("scopes selectRowsByIds to the requesting user, never leaking another tenant's row", async () => { + await seed([ + // userA owns "movie:1" (their copy) plus a distinct "movie:5". + { + id: "movie:1", + userId: USER_A, + sortTitle: "A-owned", + collectionId: "10", + collectionName: "Franchise", + }, + { id: "movie:5", userId: USER_A, sortTitle: "A-extra", collectionId: "10" }, + // userB owns a row with the IDENTICAL composite id "movie:1" in the same + // franchise — a different tenant's copy that must never leak. + { + id: "movie:1", + userId: USER_B, + sortTitle: "B-owned", + collectionId: "10", + collectionName: "Franchise", + }, + ]); + + // Ask for userA's rows, passing an id userB also has. + const rows = await selectRowsByIds(USER_A, ["movie:1", "movie:5"]); + + // Both of userA's rows come back; userB's colliding "movie:1" does not. + expect(rows.map((r) => r.id).sort()).toEqual(["movie:1", "movie:5"]); + // The "movie:1" we got is userA's copy (its sort title), proving no + // cross-tenant substitution and no duplicate composite id. + const movie1 = rows.filter((r) => r.id === "movie:1"); + expect(movie1).toHaveLength(1); + expect(movie1[0]?.sortTitle).toBe("A-owned"); + }); + + // CURSOR CODEC — the resume tuple must survive a full encode->decode round-trip + // (including a collection name with spaces, split on the LAST space so the id + // suffix is recovered intact), and every bad token must degrade to `undefined` + // (read as "first page") and NEVER throw — the degrade-don't-400 discipline the + // lens codecs follow. + it("round-trips a collections cursor and folds every bad token to first-page", () => { + // A name WITH spaces proves the codec splits on the LAST space: the prefix is + // the full multi-word name, the suffix is the numeric collection id. + const cursor = { collectionName: "The Lord of the Rings Collection", collectionId: "119" }; + expect(decodeCollectionsCursor(encodeCollectionsCursor(cursor))).toEqual(cursor); + + // A null-name group encodes its `collection_id` as the name (the service uses + // `collection_name ?? collection_id`), so a space-free single-token name with + // an id round-trips too. + const fallback = { collectionName: "40", collectionId: "40" }; + expect(decodeCollectionsCursor(encodeCollectionsCursor(fallback))).toEqual(fallback); + + // An undefined token (no cursor supplied) is first-page. + expect(decodeCollectionsCursor(undefined)).toBeUndefined(); + // An empty string is first-page, never a throw. + expect(() => decodeCollectionsCursor("")).not.toThrow(); + expect(decodeCollectionsCursor("")).toBeUndefined(); + // A token with no space cannot carry both halves → first-page. + expect(decodeCollectionsCursor("noSpaceToken")).toBeUndefined(); + // A trailing space yields an empty id → first-page rather than a blank-id + // keyset that would page from the start of the tie. + expect(decodeCollectionsCursor("Some Name ")).toBeUndefined(); + }); +}); diff --git a/apps/server/src/library/__tests__/derive.test.ts b/apps/server/src/library/__tests__/derive.test.ts new file mode 100644 index 00000000..69633989 --- /dev/null +++ b/apps/server/src/library/__tests__/derive.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it } from "vite-plus/test"; +import { QUALITY_TIERS, WATCHED_STATES } from "@ent-mcp/shared/library"; +import type { LibraryItemQuality } from "@ent-mcp/shared/plugins"; +import type { ProgressEntry } from "../../media"; +import { normalizeSortTitle } from "../internal/normalize-title"; +import { deriveQualityTiers, qualityToTier } from "../internal/quality-tier"; +import { deriveWatchedState } from "../internal/watched-state"; + +// These are pure-derivation invariants: each assertion is written so it FAILS +// if the underlying business rule changes (Rule 9), not merely if the function +// throws. No database or plugin runtime is touched — every input is a literal. +describe("normalizeSortTitle", () => { + // The A–Z rail files "The Matrix" under "M", so a leading "the" must be + // dropped. If article-stripping regressed, the key would start with "the". + it("strips a leading 'the' article", () => { + expect(normalizeSortTitle("The Matrix")).toBe("matrix"); + }); + + // "a" is also an article: "A Few Good Men" files under "F". The remaining + // interior whitespace must be preserved so multi-word titles stay intact. + it("strips a leading 'a' article and keeps interior words", () => { + expect(normalizeSortTitle("A Few Good Men")).toBe("few good men"); + }); + + // "an" is the third article form. Folding it proves the regex matches the + // whole `the|a|an` set, not just one literal. + it("strips a leading 'an' article", () => { + expect(normalizeSortTitle("An Education")).toBe("education"); + }); + + // The browse key is lowercased so the rail index is case-insensitive; a + // title shouting in caps must still group with its lowercase peers. + it("lowercases the title", () => { + expect(normalizeSortTitle("BLADE RUNNER")).toBe("blade runner"); + }); + + // Diacritics are NFD-folded so "Amélie" sorts beside ASCII "a…" titles. If + // the combining-mark strip regressed, the accented "é" would survive. + it("folds diacritics to their ASCII base letter", () => { + expect(normalizeSortTitle("Amélie")).toBe("amelie"); + }); + + // Surrounding whitespace is collapsed (trimmed) so a padded title produces + // the same key as the clean one — keys must be stable across hydrate runs. + it("collapses surrounding whitespace", () => { + expect(normalizeSortTitle(" Inception ")).toBe("inception"); + }); + + // A null/blank title still needs a defined key so the row groups under "#"; + // the contract is the empty string, never null/undefined or a thrown error. + it("returns an empty string for null, undefined, and blank input", () => { + expect(normalizeSortTitle(null)).toBe(""); + expect(normalizeSortTitle(undefined)).toBe(""); + expect(normalizeSortTitle("")).toBe(""); + expect(normalizeSortTitle(" ")).toBe(""); + }); + + // "Theater" begins with the letters "the" but is NOT the article "the" — the + // regex anchors on a trailing word boundary (whitespace), so the word must + // survive intact. This is the guard against over-eager prefix stripping. + it("does NOT strip a non-article leading word", () => { + expect(normalizeSortTitle("Theater")).toBe("theater"); + }); +}); + +describe("qualityToTier", () => { + // 4K with an HDR signal is the top tier; the HDR modifier must be appended so + // the Quality lens can separate "4K HDR" from plain "4K". + it("maps 4k with an HDR format to '4K HDR'", () => { + const quality: LibraryItemQuality = { resolution: "4k", hdr: "hdr10" }; + const tier = qualityToTier(quality); + expect(tier).toBe("4K HDR"); + // The label must be an anchor the canonical tier tuple recognises, or the + // Quality lens would rank it below every listed tier. + expect(QUALITY_TIERS).toContain(tier); + }); + + // 4K with no HDR signal collapses to plain "4K". This pins the branch that the + // HDR modifier is conditional, not always appended. + it("maps 4k without HDR to '4K'", () => { + expect(qualityToTier({ resolution: "4k" })).toBe("4K"); + }); + + // hdr: "none" is an explicit "no HDR" signal, not a present format. If the + // null/"none" guard regressed, this would wrongly yield "4K HDR". + it("treats hdr 'none' as no HDR", () => { + expect(qualityToTier({ resolution: "4k", hdr: "none" })).toBe("4K"); + }); + + it("maps 1080p to '1080p'", () => { + expect(qualityToTier({ resolution: "1080p" })).toBe("1080p"); + }); + + it("maps 720p to '720p'", () => { + expect(qualityToTier({ resolution: "720p" })).toBe("720p"); + }); + + it("maps sd to 'SD'", () => { + expect(qualityToTier({ resolution: "sd" })).toBe("SD"); + }); + + // No resolution but an HDR flag still carries a fidelity signal worth a tier. + it("maps a missing resolution with an HDR flag to 'HDR'", () => { + expect(qualityToTier({ hdr: "dolby-vision" })).toBe("HDR"); + }); + + // An empty/unclassifiable descriptor must contribute no tier rather than a + // bogus one, so the contract is null — the signal the dedupe path drops. + it("returns null for an empty/unclassifiable quality", () => { + expect(qualityToTier({})).toBeNull(); + expect(qualityToTier({ hdr: "none" })).toBeNull(); + expect(qualityToTier({ codec: "hevc", bitrate: 8000 })).toBeNull(); + }); +}); + +describe("deriveQualityTiers", () => { + // Each copy's tier appears once, in first-seen order. A title with a 4K HDR + // and a 1080p copy must yield exactly that ordered pair. + it("maps every copy to its tier in first-seen order", () => { + const copies: LibraryItemQuality[] = [ + { resolution: "4k", hdr: "hdr10" }, + { resolution: "1080p" }, + ]; + expect(deriveQualityTiers(copies)).toEqual(["4K HDR", "1080p"]); + }); + + // Duplicate copies of the same tier collapse to one entry, and the FIRST + // occurrence fixes the position. Reordering the surviving entry would fail + // this, proving order is preserved rather than incidentally correct. + it("dedupes copies that map to the same tier, preserving first-seen order", () => { + const copies: LibraryItemQuality[] = [ + { resolution: "1080p" }, + { resolution: "4k", hdr: "dolby-vision" }, + { resolution: "1080p", codec: "h264" }, + { resolution: "4k", hdr: "hdr10" }, + ]; + expect(deriveQualityTiers(copies)).toEqual(["1080p", "4K HDR"]); + }); + + // Unclassifiable copies are omitted entirely, never emitted as null/"". + it("omits copies that classify to null", () => { + const copies: LibraryItemQuality[] = [{}, { resolution: "720p" }, { hdr: "none" }]; + expect(deriveQualityTiers(copies)).toEqual(["720p"]); + }); + + it("returns an empty array for no copies", () => { + expect(deriveQualityTiers([])).toEqual([]); + }); + + // Every label the deriver emits must be a member of the canonical anchor + // tuple; an emitted label outside QUALITY_TIERS would silently rank dead last + // in the Quality lens. This locks the deriver's vocabulary to the anchor. + it("emits only labels found in the QUALITY_TIERS anchor", () => { + const copies: LibraryItemQuality[] = [ + { resolution: "4k", hdr: "hdr10" }, + { resolution: "1080p" }, + { resolution: "720p" }, + { resolution: "sd" }, + { hdr: "dolby-vision" }, + ]; + for (const tier of deriveQualityTiers(copies)) { + expect(QUALITY_TIERS).toContain(tier); + } + }); +}); + +describe("deriveWatchedState", () => { + // Progress at or past total is "watched". This branch is defensive (the CW + // projection drops finished titles) but must still resolve correctly. + it("returns 'watched' when watched reaches total", () => { + const progress: ProgressEntry = { watched: 10, total: 10 }; + expect(deriveWatchedState(progress)).toBe("watched"); + }); + + it("returns 'watched' when watched exceeds total", () => { + expect(deriveWatchedState({ watched: 11, total: 10 })).toBe("watched"); + }); + + // Strictly-between progress is "partial" — the everyday continue-watching row. + it("returns 'partial' for progress between 0 and total", () => { + const state = deriveWatchedState({ watched: 3, total: 10 }); + expect(state).toBe("partial"); + }); + + // Zero watched (but a present entry) is "unwatched". An entry exists yet no + // part has been started. + it("returns 'unwatched' when watched is zero", () => { + expect(deriveWatchedState({ watched: 0, total: 10 })).toBe("unwatched"); + }); + + // A total of zero must not be read as "watched" via the `>= total` rule; with + // zero watched it falls through to "unwatched", proving the `total > 0` guard. + it("does not call a zero-total entry 'watched' when nothing is watched", () => { + expect(deriveWatchedState({ watched: 0, total: 0 })).toBe("unwatched"); + }); + + // Absence is "unknown" (null), NOT an assumed "unwatched": the CW feed cannot + // distinguish a finished title from one never started, so guessing would + // mislabel finished titles. The honest projection is null. + it("returns null when no progress entry exists", () => { + expect(deriveWatchedState(undefined)).toBeNull(); + }); + + // Every non-null result must be a member of the canonical WatchedState tuple, + // so the facet and filter axis never see a value outside the three buckets. + it("only ever returns a member of WATCHED_STATES or null", () => { + const inputs: (ProgressEntry | undefined)[] = [ + { watched: 10, total: 10 }, + { watched: 3, total: 10 }, + { watched: 0, total: 10 }, + undefined, + ]; + for (const input of inputs) { + const state = deriveWatchedState(input); + if (state !== null) { + expect(WATCHED_STATES).toContain(state); + } + } + }); +}); diff --git a/apps/server/src/library/__tests__/facets.test.ts b/apps/server/src/library/__tests__/facets.test.ts new file mode 100644 index 00000000..d1bc4605 --- /dev/null +++ b/apps/server/src/library/__tests__/facets.test.ts @@ -0,0 +1,245 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { + cleanupInMemoryDbs, + createInMemoryDb, + type Db, +} from "../../__tests__/helpers/in-memory-db"; +import { user } from "../../db/schema/auth"; +import { libraryItems } from "../../db/schema/library"; + +// `selectFacets` resolves its database handle through `getDb()`. Point it at the +// real migrated in-memory database (which applies every drizzle migration, +// including `library_items`) so each aggregation runs against actual SQLite. The +// dedup invariant (test 3) can only be proven against the real `json_each` +// expansion and `count(DISTINCT id)` semantics — a mocked repo would let a +// `count(*)` regression slip through unnoticed. +vi.mock("../../env", () => ({ + env: { CACHE_PROVIDER: "memory", ENCRYPTION_KEY: "test-key" }, +})); + +vi.mock("../../db/client", async () => { + const actual = await vi.importActual("../../db/client"); + return { + ...actual, + getDb: () => testDb, + }; +}); + +// Import the code under test AFTER the mocks are registered so it binds to the +// stubbed `getDb`. The repo is imported real (NOT mocked): the SQL each +// aggregation emits is precisely what these tests exist to pin. +const { selectFacets } = await import("../repo"); +const { __resetLibraryForTests } = await import("../repo"); + +let testDb: Db; + +const USER_ID = "u1"; + +/** + * The fields a denormalized library row needs to land in a facet bucket. Every + * column `selectFacets` reads is overridable; the rest fall back to a sensible + * owned-row default. Seeding `libraryItems` directly (rather than through + * `upsertOwned` + `writeHydration`) is deliberate: `upsertOwned` leaves the + * facet columns at their schema defaults, so a direct insert is the only way to + * drive `genres`/`servers`/`qualityTiers`/`watchedState`/`sortTitle`/`year` + * across the exact shapes each invariant needs. + */ +interface SeedRow { + id: string; + tmdbId: string; + mediaType?: "movie" | "tv"; + owned?: boolean; + sortTitle?: string; + year?: number | null; + genres?: string[]; + servers?: { id: string; label: string }[]; + qualityTiers?: string[]; + watchedState?: "watched" | "partial" | "unwatched" | null; +} + +/** Inserts one fully-specified owned (or tombstoned) library row for `USER_ID`. */ +async function seed(row: SeedRow): Promise { + await testDb.insert(libraryItems).values({ + id: row.id, + userId: USER_ID, + tmdbId: row.tmdbId, + mediaType: row.mediaType ?? "movie", + owned: row.owned ?? true, + ownedAt: Date.now(), + sortTitle: row.sortTitle ?? "", + year: row.year ?? null, + genres: row.genres ?? [], + servers: row.servers ?? [], + qualityTiers: row.qualityTiers ?? [], + watchedState: row.watchedState ?? null, + }); +} + +beforeAll(async () => { + testDb = await createInMemoryDb(); + await testDb.insert(user).values({ + id: USER_ID, + name: USER_ID, + email: `${USER_ID}@test`, + emailVerified: false, + createdAt: new Date(), + updatedAt: new Date(), + }); +}); + +afterAll(() => cleanupInMemoryDbs()); + +beforeEach(async () => { + await __resetLibraryForTests(testDb); +}); + +describe("library facets (design §Facets)", () => { + // SINGLE-VALUED GROUP BY — `kinds` and `watched` are one bucket per row, + // counted with `count(*)` over the owned set. A `tv` row and two `movie` rows + // yield `{ movie: 2, tv: 1 }`; the watched buckets count one each per state. + // CRUCIALLY a row with a null `watchedState` must be DROPPED from the watched + // map (`rowsToMap` skips the null bucket) rather than surfacing as a phantom + // key — if the null filter regressed, `watched` would carry an extra entry and + // the strict `toEqual` below would fail. + it("counts single-valued kinds and watched, dropping the null watched bucket", async () => { + await seed({ id: "movie:1", tmdbId: "1", mediaType: "movie", watchedState: "watched" }); + await seed({ id: "movie:2", tmdbId: "2", mediaType: "movie", watchedState: "partial" }); + await seed({ id: "tv:3", tmdbId: "3", mediaType: "tv", watchedState: "unwatched" }); + // A fourth owned row with NO watched state: it counts toward `kinds` but must + // contribute no entry to `watched` (the null bucket is dropped). + await seed({ id: "movie:4", tmdbId: "4", mediaType: "movie", watchedState: null }); + + const facets = await selectFacets(USER_ID); + + expect(facets.kinds).toEqual({ movie: 3, tv: 1 }); + expect(facets.watched).toEqual({ watched: 1, partial: 1, unwatched: 1 }); + }); + + // MULTI-VALUED json_each — `servers`, `genres`, and `qualities` expand each row + // through `json_each`, so a SINGLE title present on two servers contributes one + // count to EACH server bucket (a title count per value, not a row count). The + // same holds for two distinct genres and two distinct quality tiers on one row. + // If the `json_each` cross-join regressed to a plain column read, only the + // first array element would surface and the second bucket would be absent. + it("expands multi-valued servers, genres and qualities so one title hits every bucket", async () => { + await seed({ + id: "movie:10", + tmdbId: "10", + servers: [ + { id: "s1", label: "Plex" }, + { id: "s2", label: "Jellyfin" }, + ], + genres: ["Drama", "Comedy"], + qualityTiers: ["4K", "1080p"], + }); + + const facets = await selectFacets(USER_ID); + + // The one title lands in both server buckets, both genre buckets, and both + // quality buckets — each as a single title count. + expect(facets.servers).toEqual({ Plex: 1, Jellyfin: 1 }); + expect(facets.genres).toEqual({ Drama: 1, Comedy: 1 }); + expect(facets.qualities).toEqual({ "4K": 1, "1080p": 1 }); + }); + + // DEDUP — locks the `count(DISTINCT id)` fix. A single owned row whose `genres` + // JSON repeats a value (dirty plugin metadata returning `["Drama","Drama"]`) + // must count that genre EXACTLY ONCE: a facet is a title count, not an + // array-element count. `json_each` emits one expanded row per array element, so + // under `count(*)` this would tally `Drama: 2` from a single title. This test + // is the mutation-sensitive heart of the dedup guard — it FAILS the moment the + // aggregation reverts to `count(*)`. + it("counts a repeated genre on one title exactly once (count(DISTINCT id))", async () => { + await seed({ id: "movie:20", tmdbId: "20", genres: ["Drama", "Drama"] }); + + const facets = await selectFacets(USER_ID); + + expect(facets.genres).toEqual({ Drama: 1 }); + }); + + // OWNED-ONLY — every aggregation is scoped to `owned = true`, so a tombstoned + // row (`owned = false`) must contribute to NO facet bucket: not `kinds`, not + // the multi-valued axes, not `letters`, not `decades`. If the owned predicate + // were dropped, the tombstone's media type, genre, server, leading letter, and + // decade would all leak into the maps and rails — every assertion below would + // fail. We pair the tombstone with one live owned row so the maps are non-empty + // and prove the tombstone is the only thing excluded. + it("excludes tombstoned (owned=false) rows from every facet, letter and decade", async () => { + // Live owned anchor: contributes a movie, a genre, a server, letter A, 2020s. + await seed({ + id: "movie:30", + tmdbId: "30", + mediaType: "movie", + sortTitle: "Arrival", + year: 2016, + genres: ["Drama"], + servers: [{ id: "s1", label: "Plex" }], + qualityTiers: ["1080p"], + watchedState: "watched", + }); + // Tombstoned row with entirely DIFFERENT facet values: if any leaked, it + // would be visible (a `tv` kind, a `Horror` genre, a `Jellyfin` server, + // letter Z, the 1990s decade). + await seed({ + id: "tv:31", + tmdbId: "31", + mediaType: "tv", + owned: false, + sortTitle: "Zodiac", + year: 1999, + genres: ["Horror"], + servers: [{ id: "s2", label: "Jellyfin" }], + qualityTiers: ["4K"], + watchedState: "unwatched", + }); + + const facets = await selectFacets(USER_ID); + + // Only the live owned anchor is represented anywhere. + expect(facets.kinds).toEqual({ movie: 1 }); + expect(facets.genres).toEqual({ Drama: 1 }); + expect(facets.servers).toEqual({ Plex: 1 }); + expect(facets.qualities).toEqual({ "1080p": 1 }); + expect(facets.watched).toEqual({ watched: 1 }); + expect(facets.letters).toEqual(["A"]); + expect(facets.decades).toEqual([2010]); + }); + + // LETTERS present-only — the A→Z rail lists the DISTINCT uppercased first + // characters of `sortTitle` that have at least one owned title. A leading + // non-alphabetic character (a digit, a symbol) or a blank `sortTitle` folds to + // the catch-all `"#"`, and `"#"` must sort LAST so it trails the letters. A + // letter with no owned title is absent (present-only). This pins the fold, the + // distinct-uppercase collapse, and the `"#"`-trails-letters sort all at once. + it("lists distinct uppercased leading letters, folding non-alpha to a trailing '#'", async () => { + await seed({ id: "movie:40", tmdbId: "40", sortTitle: "alpha" }); // A + await seed({ id: "movie:41", tmdbId: "41", sortTitle: "Avengers" }); // also A — collapses + await seed({ id: "movie:42", tmdbId: "42", sortTitle: "Batman" }); // B + await seed({ id: "movie:43", tmdbId: "43", sortTitle: "300" }); // digit -> # + await seed({ id: "movie:44", tmdbId: "44", sortTitle: "" }); // blank -> # + + const facets = await selectFacets(USER_ID); + + // Distinct (A appears once despite two titles), uppercased, "#" trailing. + // C is absent because no owned title starts with C (present-only). + expect(facets.letters).toEqual(["A", "B", "#"]); + }); + + // DECADES present-only, newest first — the timeline rail lists the DISTINCT + // decades (`floor(year / 10) * 10`) that have an owned title, sorted DESC so + // the newest decade leads. A row with a null `year` contributes NO decade + // (it is excluded by the `year IS NOT NULL` predicate). Two titles in the same + // decade collapse to one entry. This fails if the sort direction flips, if the + // decade arithmetic regresses, or if null-year rows leak a phantom decade. + it("lists distinct decades newest-first, dropping null-year rows", async () => { + await seed({ id: "movie:50", tmdbId: "50", year: 2021 }); // 2020s + await seed({ id: "movie:51", tmdbId: "51", year: 2024 }); // also 2020s — collapses + await seed({ id: "movie:52", tmdbId: "52", year: 2015 }); // 2010s + await seed({ id: "movie:53", tmdbId: "53", year: 1998 }); // 1990s + await seed({ id: "movie:54", tmdbId: "54", year: null }); // no decade + + const facets = await selectFacets(USER_ID); + + expect(facets.decades).toEqual([2020, 2010, 1990]); + }); +}); diff --git a/apps/server/src/library/__tests__/hydrate.test.ts b/apps/server/src/library/__tests__/hydrate.test.ts new file mode 100644 index 00000000..f4d31d78 --- /dev/null +++ b/apps/server/src/library/__tests__/hydrate.test.ts @@ -0,0 +1,281 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { consola, type ConsolaInstance } from "consola"; +import { and, eq } from "drizzle-orm"; +import { + cleanupInMemoryDbs, + createInMemoryDb, + type Db, +} from "../../__tests__/helpers/in-memory-db"; +import { user } from "../../db/schema/auth"; +import { libraryItems } from "../../db/schema/library"; + +// The hydrate orchestrator resolves its database handle through `getDb()` and +// drives the real `staleOrNew`/`writeHydration` repo functions. Point `getDb` +// at the migrated in-memory database (which applies every drizzle migration, +// including `library_items`) so the denormalized columns are written and read +// back against actual SQLite — a mocked repo could not prove that the projection +// columns and `hydrated_at` survive a real UPDATE/SELECT round-trip. +vi.mock("../../env", () => ({ + env: { CACHE_PROVIDER: "memory", ENCRYPTION_KEY: "test-key" }, +})); + +vi.mock("../../db/client", async () => { + const actual = await vi.importActual("../../db/client"); + return { + ...actual, + getDb: () => testDb, + }; +}); + +// Import the code under test AFTER the mocks are registered so it binds to the +// stubbed `getDb`. The repo and orchestrator are imported real (NOT mocked): +// mocking either would defeat the very invariants these tests guard — that the +// orchestrator folds the stubbed sources into the projection columns and that +// `staleOrNew` selects exactly the missing/stale rows. +const { hydrate } = await import("../internal/hydrate"); +const { staleOrNew, writeHydration, upsertOwned, __resetLibraryForTests } = await import("../repo"); +const { asLibraryContext } = await import("../internal/context"); + +let testDb: Db; + +const log: ConsolaInstance = consola.withTag("test"); + +const USER_ID = "u1"; + +/** A catalog metadata entry as the hydrate orchestrator reads it off `getMetadataBatch`. */ +type MetaEntry = { + title?: string; + year?: number | null; + genres?: string[] | null; + collectionId?: string | null; + collectionName?: string | null; +}; + +/** A quality copy as `getAvailabilityQuality` surfaces it, before tier derivation. */ +type QualityCopy = { resolution?: string; hdr?: string }; + +/** A server chip as `getMatchingServers` surfaces it. */ +type Server = { id: string; label: string }; + +/** One continue-watching feed item in the shape `projectProgressMapEntry` parses. */ +function cwEntry(tmdbId: string, type: "movie" | "tv", watchedSec: number, totalSec: number) { + // `progressMs` is the watched position in ms; `durationSec` is the total. An + // entry with `0 < watched < total` projects to `watchedState: "partial"`. + return { + progressMs: watchedSec * 1000, + item: { type, durationSec: totalSec, ids: { tmdb_id: tmdbId } }, + }; +} + +/** + * Stubs the media + catalog services the hydrate orchestrator fans out over and + * builds the loose `MaybeLibraryContext` the resolver accepts (mirroring + * `sync.test.ts#makeCtx`). The catalog handle is unused by phase-1 membership + * sync but is the phase-2 metadata source, so it is a real stub here. + * + * Each source is a `vi.fn` so a test can assert fan-out or override per call. + * Defaults resolve the empty/absent shape so a source a test does not care about + * never throws and contributes its empty projection. + */ +function makeCtx(opts: { + metadata?: Record; + servers?: Server[]; + quality?: QualityCopy[]; + continueWatching?: unknown[]; +}) { + const mediaService = { + getMatchingServers: vi.fn().mockResolvedValue(opts.servers ?? []), + getAvailabilityQuality: vi.fn().mockResolvedValue(opts.quality ?? []), + // `loadProgressMap` reads the continue-watching aggregate and projects it to + // the `{ watched, total }` map `deriveWatchedState` consumes. + getContinueWatchingFeed: vi + .fn() + .mockResolvedValue({ items: opts.continueWatching ?? [], partial: false }), + }; + const catalog = { + getMetadataBatch: vi.fn().mockResolvedValue(opts.metadata ?? {}), + }; + const ctx = { + userId: USER_ID, + mediaService: mediaService as unknown as Parameters[0]["mediaService"], + catalog: catalog as unknown as Parameters[0]["catalog"], + log, + }; + return { ctx: asLibraryContext(ctx), mediaService, catalog }; +} + +/** Inserts an owned, never-hydrated library row (the shape membership sync seeds). */ +async function seedOwned(tmdbId: string, mediaType: "movie" | "tv" = "movie") { + await upsertOwned( + [ + { + id: `${mediaType}:${tmdbId}`, + userId: USER_ID, + tmdbId, + mediaType, + ownedAt: Date.now(), + }, + ], + testDb, + ); +} + +/** Reads a single library row by its composite id, or undefined when absent. */ +async function rowById(id: string) { + const rows = await testDb + .select() + .from(libraryItems) + .where(and(eq(libraryItems.userId, USER_ID), eq(libraryItems.id, id))); + return rows[0]; +} + +beforeAll(async () => { + testDb = await createInMemoryDb(); + await testDb.insert(user).values({ + id: USER_ID, + name: USER_ID, + email: `${USER_ID}@test`, + emailVerified: false, + createdAt: new Date(), + updatedAt: new Date(), + }); +}); + +afterAll(() => cleanupInMemoryDbs()); + +beforeEach(async () => { + await __resetLibraryForTests(testDb); +}); + +describe("library hydrate (design §Sync + hydrate, phase 2)", () => { + // HYDRATE WRITES DENORM COLS — the orchestrator must fold all four stubbed + // sources into the row's denormalized projection and stamp `hydratedAt`. + // Reading the row back proves each column is sourced correctly: a normalized + // sortTitle (article stripped, lowercased) from `getMetadataBatch.title`, the + // year/genres/collection from metadata, the server chips from + // `getMatchingServers`, the quality tiers derived from + // `getAvailabilityQuality`, and `watchedState` derived from the CW feed. If + // the orchestrator ever stopped writing any one column, that assertion fails. + it("writes every denormalized column from the stubbed sources", async () => { + await seedOwned("550"); + + const { ctx } = makeCtx({ + metadata: { + "movie:550": { + title: "The Matrix", + year: 1999, + genres: ["Action", "Sci-Fi"], + collectionId: "c1", + collectionName: "The Matrix Collection", + }, + }, + servers: [{ id: "plex", label: "Plex" }], + // A 4K Dolby-Vision copy derives the "4K HDR" tier; a 1080p copy adds + // "1080p" — proving `deriveQualityTiers` ran over the stubbed copies. + quality: [{ resolution: "4k", hdr: "dolby-vision" }, { resolution: "1080p" }], + // 600s watched of a 6000s title → 0 < watched < total → "partial". + continueWatching: [cwEntry("550", "movie", 600, 6000)], + }); + + const result = await hydrate(ctx, { staleTtlMs: 1000 }); + expect(result).toEqual({ considered: 1, hydrated: 1 }); + + const row = await rowById("movie:550"); + // "The Matrix" → article stripped + lowercased → "matrix". + expect(row?.sortTitle).toBe("matrix"); + expect(row?.year).toBe(1999); + expect(row?.genres).toEqual(["Action", "Sci-Fi"]); + expect(row?.servers).toEqual([{ id: "plex", label: "Plex" }]); + expect(row?.qualityTiers).toEqual(["4K HDR", "1080p"]); + expect(row?.watchedState).toBe("partial"); + expect(row?.collectionId).toBe("c1"); + expect(row?.collectionName).toBe("The Matrix Collection"); + // `hydratedAt` is stamped so a later `staleOrNew` skips the row. + expect(row?.hydratedAt).not.toBeNull(); + }); + + // STALE/NEW SELECTION — `staleOrNew` is the read that bounds the whole + // fan-out, so it MUST select exactly the missing-or-stale owned rows and + // nothing fresh. Driven directly with an explicit `now` to avoid clock flake. + // A never-hydrated row (`hydratedAt IS NULL`) is always selected; once + // `writeHydration` stamps `hydratedAt = T`, the row drops out of the window + // (`now` inside `[T, T+ttl)`) and reappears only once `now` crosses the TTL. + // If the predicate widened (e.g. dropped the `hydrated_at < staleBefore` + // bound), the fresh-window assertion would fail; if it narrowed (dropped the + // `IS NULL` arm), the new-row assertion would fail. + it("selects only missing or stale rows for the given now", async () => { + await seedOwned("550"); + const TTL = 60_000; + const T = 1_000_000; + + // A never-hydrated row is selected regardless of `now`. + const fresh = await staleOrNew(USER_ID, TTL, T, testDb); + expect(fresh.map((t) => t.id)).toEqual(["movie:550"]); + + // Hydrate it at `T` so `hydrated_at = T`. + await writeHydration( + [ + { + id: "movie:550", + sortTitle: "matrix", + year: 1999, + genres: [], + servers: [], + qualityTiers: [], + watchedState: null, + collectionId: null, + collectionName: null, + }, + ], + T, + testDb, + ); + + // Inside the window (`now` one ms before the TTL elapses) the row is fresh + // and MUST NOT be re-selected. + const withinWindow = await staleOrNew(USER_ID, TTL, T + TTL - 1, testDb); + expect(withinWindow).toEqual([]); + + // Once `now` advances past the TTL the row is stale again and IS selected. + const pastWindow = await staleOrNew(USER_ID, TTL, T + TTL + 1, testDb); + expect(pastWindow.map((t) => t.id)).toEqual(["movie:550"]); + }); + + // NULL-SAFE — a row whose metadata batch is missing (the catalog has no + // canonical row for it yet) must still hydrate the columns it CAN resolve + // rather than throwing and stalling the whole pass. The metadata-sourced + // columns fall back to their empty/null shape (sortTitle "", year null, + // genres [], collection null), while the availability- and progress-sourced + // columns still populate. This is the partial-hydrate-self-heals invariant: a + // throw here would mean one missing catalog row poisons every later row in the + // batch. + it("hydrates the resolvable columns when the metadata batch is missing", async () => { + await seedOwned("777"); + + // Note: `metadata` is empty (no entry for "movie:777") but servers/quality/CW + // all resolve, so the row must still hydrate without throwing. + const { ctx } = makeCtx({ + metadata: {}, + servers: [{ id: "jellyfin", label: "Jellyfin" }], + quality: [{ resolution: "720p" }], + continueWatching: [cwEntry("777", "movie", 100, 1000)], + }); + + const result = await hydrate(ctx, { staleTtlMs: 1000 }); + expect(result).toEqual({ considered: 1, hydrated: 1 }); + + const row = await rowById("movie:777"); + // Metadata-sourced columns fall back to their empty/null shape. + expect(row?.sortTitle).toBe(""); + expect(row?.year).toBeNull(); + expect(row?.genres).toEqual([]); + expect(row?.collectionId).toBeNull(); + expect(row?.collectionName).toBeNull(); + // Availability- and progress-sourced columns still populate. + expect(row?.servers).toEqual([{ id: "jellyfin", label: "Jellyfin" }]); + expect(row?.qualityTiers).toEqual(["720p"]); + expect(row?.watchedState).toBe("partial"); + // The row was still stamped hydrated despite the partial sources. + expect(row?.hydratedAt).not.toBeNull(); + }); +}); diff --git a/apps/server/src/library/__tests__/lens-expanded.test.ts b/apps/server/src/library/__tests__/lens-expanded.test.ts new file mode 100644 index 00000000..2f58466c --- /dev/null +++ b/apps/server/src/library/__tests__/lens-expanded.test.ts @@ -0,0 +1,478 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { QUALITY_TIERS } from "@ent-mcp/shared/library"; +import { + cleanupInMemoryDbs, + createInMemoryDb, + type Db, +} from "../../__tests__/helpers/in-memory-db"; +import { user } from "../../db/schema/auth"; +import { libraryItems } from "../../db/schema/library"; + +// The grouped lens repos resolve their database handle through `getDb()`. Mirror +// the `lens-pages.test.ts` / `sync.test.ts` harness EXACTLY: stub `env` (the db +// client imports it transitively) and point `getDb()` at the real migrated +// in-memory database. The `json_each` expansion, the `sv.value ->> 'id'` / +// `qt.value` keyset ORDER BY, and the quality rank `CASE` can only be proven +// against the real SQLite query planner — the expanded off-by-one boundary and +// the rank-vs-cursor mismatch never reproduce against a mocked repo. +vi.mock("../../env", () => ({ + env: { CACHE_PROVIDER: "memory", ENCRYPTION_KEY: "test-key" }, +})); + +vi.mock("../../db/client", async () => { + const actual = await vi.importActual("../../db/client"); + return { + ...actual, + getDb: () => testDb, + }; +}); + +// Import the code under test AFTER the mocks are registered so it binds to the +// stubbed `getDb`. The repo (grouped lens pages) and the keyset codec are +// imported real — mocking either would defeat the very invariants these tests +// lock (the expanded boundary discipline and the rank ordinal threading). +const { selectServerPage, selectQualityPage } = await import("../repo"); +// `selectFacets` is imported here too so the regression test can prove, in one +// place, that the facet key the popover surfaces is the SAME value the Server +// lens filter now matches on. +const { selectFacets } = await import("../repo"); +const { __resetLibraryForTests } = await import("../repo"); +const { serverToken, decodeServer, qualityToken, decodeQuality } = + await import("../sources/keyset"); +const { encode, decode } = await import("../../media"); + +let testDb: Db; + +const USER_ID = "u1"; + +/** The bottom sentinel rank the SQL `CASE` assigns any label outside `QUALITY_TIERS`. */ +const UNKNOWN_RANK = QUALITY_TIERS.length; + +/** A row the grouped lenses page off, in the `library_items` insert shape. */ +interface SeedRow { + id: string; + tmdbId?: string; + mediaType?: "movie" | "tv"; + sortTitle?: string; + year?: number | null; + genres?: string[]; + servers?: { id: string; label: string }[]; + qualityTiers?: string[]; + watchedState?: "watched" | "partial" | "unwatched" | null; + owned?: boolean; +} + +/** + * Inserts owned library rows directly into `library_items`, filling every + * not-null column with a defaultable value so a test only sets the axis it + * asserts on. The denormalized `servers` / `quality_tiers` columns are set + * explicitly per test so the `json_each` expansion has real values to fan out + * over. `owned` defaults to true so the rows land in the lens base + * `owned = true` set; a test can override it to seed a tombstone. + */ +async function seed(rows: SeedRow[]): Promise { + await testDb.insert(libraryItems).values( + rows.map((r) => ({ + id: r.id, + userId: USER_ID, + tmdbId: r.tmdbId ?? r.id, + mediaType: r.mediaType ?? ("movie" as const), + owned: r.owned ?? true, + ownedAt: Date.now(), + sortTitle: r.sortTitle ?? "", + year: r.year === undefined ? null : r.year, + genres: r.genres ?? [], + servers: r.servers ?? [], + qualityTiers: r.qualityTiers ?? [], + watchedState: r.watchedState ?? null, + })), + ); +} + +/** + * A stable per-expanded-row key for assertions: `"|"`. The Server + * lens fans a title out once per server, so the distinguishing identity of an + * expanded row is the `(section.id, library id)` pair — `id` alone repeats + * across sections. This key lets the completeness assertions detect a drop or a + * duplicate of any single expanded row. + */ +function serverKey(row: { section: { id: string }; id: string }): string { + return `${row.section.id}|${row.id}`; +} + +/** + * The Quality twin of {@link serverKey}: `"|"`. A title held + * in two tiers fans out into two expanded rows whose only distinguishing key is + * `(tier, library id)`. + */ +function qualityKey(row: { section: { id: string }; id: string }): string { + return `${row.section.id}|${row.id}`; +} + +/** + * Walks every Server page from the first, threading the next cursor EXACTLY as + * `sources/server.ts` does: the page's `nextRow` is encoded with `serverToken`, + * wrapped in the opaque `{ mode: "keyset", k }` cursor, round-tripped through the + * shared `encode`/`decode` codec a real request traverses, and decoded back to a + * `ServerCursor` with `decodeServer`. Returns the ordered expanded rows the loop + * emitted plus whether the final page exhausted the scan (no `nextRow`). A safety + * cap turns an accidental infinite/duplicating loop into a failure, not a hang. + */ +async function walkServer( + limit: number, + filters: Parameters[1] = {}, +): Promise<{ + rows: { id: string; section: { id: string; label: string }; sortTitle: string }[]; + exhausted: boolean; +}> { + const rows: { id: string; section: { id: string; label: string }; sortTitle: string }[] = []; + let cursor: ReturnType = undefined; + let exhausted = false; + for (let guard = 0; guard < 1000; guard++) { + const page = await selectServerPage(USER_ID, filters, cursor, limit); + for (const row of page.rows) rows.push(row); + if (!page.nextRow) { + exhausted = true; + break; + } + const raw = encode({ mode: "keyset", k: serverToken(page.nextRow) }); + cursor = decodeServer(decode(raw)); + } + return { rows, exhausted }; +} + +/** + * Quality twin of {@link walkServer}, threading `qualityToken`/`decodeQuality` + * EXACTLY as `sources/quality.ts` does — including carrying the expanded row's + * SQL `rank` ordinal back through the token rather than re-deriving it. + */ +async function walkQuality( + limit: number, + filters: Parameters[1] = {}, +): Promise<{ + rows: { id: string; section: { id: string; label: string }; sortTitle: string; rank?: number }[]; + exhausted: boolean; +}> { + const rows: { + id: string; + section: { id: string; label: string }; + sortTitle: string; + rank?: number; + }[] = []; + let cursor: ReturnType = undefined; + let exhausted = false; + for (let guard = 0; guard < 1000; guard++) { + const page = await selectQualityPage(USER_ID, filters, cursor, limit); + for (const row of page.rows) rows.push(row); + if (!page.nextRow) { + exhausted = true; + break; + } + const raw = encode({ mode: "keyset", k: qualityToken(page.nextRow) }); + cursor = decodeQuality(decode(raw)); + } + return { rows, exhausted }; +} + +beforeAll(async () => { + testDb = await createInMemoryDb(); + await testDb.insert(user).values({ + id: USER_ID, + name: USER_ID, + email: `${USER_ID}@test`, + emailVerified: false, + createdAt: new Date(), + updatedAt: new Date(), + }); +}); + +afterAll(() => cleanupInMemoryDbs()); + +beforeEach(async () => { + await __resetLibraryForTests(testDb); +}); + +describe("library grouped lens pages (design §The 5 lenses, phase 3 json_each expansion)", () => { + // SERVER MULTI-SECTION — the load-bearing expansion invariant. A title owned on + // TWO servers must appear in BOTH server sections (two expanded rows), and a + // title on ONE server must appear exactly once. The `json_each(servers)` join + // fans each owned row out once per server `{ id, label }`; if the expansion + // collapsed to distinct-by-title (or only the first server), the two-server + // title would lose a section and this fails. The section id/label must come + // from the server object, not the library row. + it("expands a title across every server section, and a single-server title once", async () => { + await seed([ + { + id: "movie:multi", + sortTitle: "Multi", + servers: [ + { id: "plex", label: "Plex" }, + { id: "jelly", label: "Jellyfin" }, + ], + }, + { id: "movie:solo", sortTitle: "Solo", servers: [{ id: "plex", label: "Plex" }] }, + ]); + + const { rows } = await walkServer(100); + + // `movie:multi` surfaces once per server section; `movie:solo` only in plex. + // Assert on the full `(section.id, library id)` pairs so a dropped section or + // a duplicated row both fail. + expect(rows.map(serverKey)).toEqual([ + "jelly|movie:multi", + "plex|movie:multi", + "plex|movie:solo", + ]); + + // The section label rides through from the server object, distinct per + // section — proving the label is read off `sv.value ->> 'label'`, not the + // library id. + const jelly = rows.find((r) => r.section.id === "jelly"); + expect(jelly?.section.label).toBe("Jellyfin"); + const plexMulti = rows.find((r) => r.section.id === "plex" && r.id === "movie:multi"); + expect(plexMulti?.section.label).toBe("Plex"); + }); + + // SERVER KEYSET COMPLETENESS across boundaries — paging the whole EXPANDED set + // in `limit`-sized hops, threading each page's `nextRow` back as the next cursor + // EXACTLY as `sources/server.ts` does, must reconstruct one row per + // `(title, server)` with NO drop and NO duplicate, ordered `(section.id, + // sortTitle, id)`. With 5 expanded rows at limit 2 the loop crosses >=2 page + // boundaries (pages of 2,2,1); an off-by-one in `toExpandedPage` (encoding the + // dropped overflow row instead of the last returned one) drops or repeats the + // boundary expanded row. A title on two servers makes the boundary fall mid-fan. + it("pages the whole expanded Server set across boundaries with no drops or duplicates", async () => { + await seed([ + // `movie:ab` fans into both `alpha` and `beta`; the rest sit in one section + // each. Sorted by `(section.id, sortTitle, id)` the expanded order is: + // alpha|movie:ab, alpha|movie:aonly, + // beta|movie:ab, beta|movie:bonly, + // gamma|movie:gonly + { + id: "movie:ab", + sortTitle: "Across", + servers: [ + { id: "alpha", label: "Alpha" }, + { id: "beta", label: "Beta" }, + ], + }, + { id: "movie:aonly", sortTitle: "Aonly", servers: [{ id: "alpha", label: "Alpha" }] }, + { id: "movie:bonly", sortTitle: "Bonly", servers: [{ id: "beta", label: "Beta" }] }, + { id: "movie:gonly", sortTitle: "Gonly", servers: [{ id: "gamma", label: "Gamma" }] }, + ]); + + const { rows, exhausted } = await walkServer(2); + + // The full expanded set in `(section.id, sortTitle, id)` order — exactly once + // each, in order. `toEqual` on the ordered key array asserts order, no-skip, + // and no-duplicate in one shot. The cursor crossed the `alpha -> beta` and + // `beta -> gamma` section boundaries (and one within-section boundary), so a + // broken last-returned-vs-overflow discipline drops or doubles a key here. + expect(rows.map(serverKey)).toEqual([ + "alpha|movie:ab", + "alpha|movie:aonly", + "beta|movie:ab", + "beta|movie:bonly", + "gamma|movie:gonly", + ]); + expect(new Set(rows.map(serverKey)).size).toBe(5); + expect(rows).toHaveLength(5); + // The final short read emitted no next cursor, so the pipeline ends the scan + // rather than looping forever. + expect(exhausted).toBe(true); + }); + + // QUALITY KEYSET + RANK — the expanded set is ordered HIGHEST-FIDELITY FIRST by + // the `QUALITY_TIERS` ordinal (rank ascending), an UNKNOWN label sorts LAST + // (rank == QUALITY_TIERS.length), ties broken by `(sortTitle, id)`, and the full + // set reconstructs with no drop/dup across a boundary. Paging in hops of 2 + // crosses tier-section boundaries; because the cursor predicate and the + // `ORDER BY` share ONE rank `CASE`, the boundary is stable. If the rank were + // re-derived (or the unknown label ranked above a known tier) the order or the + // boundary would break and this fails. + it("pages the expanded Quality set highest-fidelity first, unknown last, no drops or duplicates", async () => { + // "4K HDR" is rank 0 (highest), "1080p" is rank 4, "Bootleg" is unknown + // (rank == QUALITY_TIERS.length). `movie:dual` is held in both "4K HDR" and + // "1080p", so it fans into two tier sections. Expected expanded order: + // 4K HDR : movie:dual (rank 0) + // 1080p : movie:dual, movie:hd (rank 4, tie-broken by sortTitle/id) + // Bootleg: movie:low (rank UNKNOWN_RANK) + await seed([ + { id: "movie:dual", sortTitle: "Dual", qualityTiers: ["1080p", "4K HDR"] }, + { id: "movie:hd", sortTitle: "Hd", qualityTiers: ["1080p"] }, + { id: "movie:low", sortTitle: "Low", qualityTiers: ["Bootleg"] }, + ]); + + const { rows, exhausted } = await walkQuality(2); + + // Highest-fidelity first by rank ordinal; the unknown tier last; the 1080p + // tie broken by `(sortTitle, id)`. `toEqual` on the ordered keys asserts the + // ordering, no-skip and no-duplicate across the boundary in one shot. + expect(rows.map(qualityKey)).toEqual([ + "4K HDR|movie:dual", + "1080p|movie:dual", + "1080p|movie:hd", + "Bootleg|movie:low", + ]); + expect(new Set(rows.map(qualityKey)).size).toBe(4); + expect(rows).toHaveLength(4); + expect(exhausted).toBe(true); + + // The rank ordinal the source threaded through the token is the SQL `CASE` + // value: 0 for "4K HDR", 4 for "1080p", and the bottom sentinel for the + // unknown label. This is what keeps the hop token comparable to the cursor + // predicate's numeric rank — assert it explicitly so a re-derived-or-dropped + // rank fails here, not silently. + const byKey = new Map(rows.map((r) => [qualityKey(r), r] as const)); + expect(byKey.get("4K HDR|movie:dual")?.rank).toBe(QUALITY_TIERS.indexOf("4K HDR")); + expect(byKey.get("1080p|movie:hd")?.rank).toBe(QUALITY_TIERS.indexOf("1080p")); + expect(byKey.get("Bootleg|movie:low")?.rank).toBe(UNKNOWN_RANK); + // The unknown tier really did sort to the very end (rank == tuple length). + expect(rows[rows.length - 1]?.section.id).toBe("Bootleg"); + }); + + // CURSOR CODEC — the grouped resume tuples must survive a full encode->decode + // round-trip for both lenses, and EVERY bad-cursor path must degrade to + // `undefined` (read as "first page") and NEVER throw. This is the V.CU1 + // "a hand-edited cursor degrades, never 400s" invariant, applied to the + // three-part grouped tokens. + it("round-trips a grouped keyset cursor and folds every bad cursor to first-page", () => { + // Server round-trip: a `sortTitle` WITH a space proves `splitTriToken` peels + // the FIRST space (section id) and the LAST space (library id), recovering the + // full middle sortTitle. + const serverRow = { + section: { id: "plex", label: "Plex" }, + sortTitle: "The Matrix", + id: "movie:603", + } as Parameters[0]; + expect(decodeServer(decode(encode({ mode: "keyset", k: serverToken(serverRow) })))).toEqual({ + sectionId: "plex", + sortTitle: "The Matrix", + id: "movie:603", + }); + + // Quality round-trip: the rank ordinal parses back to a finite integer, the + // middle sortTitle (with a space) survives, and the id survives. + const qualityRow = { + section: { id: "4K HDR", label: "4K HDR" }, + sortTitle: "The Matrix", + id: "movie:603", + rank: 0, + } as Parameters[0]; + expect(decodeQuality(decode(encode({ mode: "keyset", k: qualityToken(qualityRow) })))).toEqual({ + tierRank: 0, + sortTitle: "The Matrix", + id: "movie:603", + }); + + // A null cursor (none supplied) is first-page for both grouped lenses. + expect(decodeServer(null)).toBeUndefined(); + expect(decodeQuality(null)).toBeUndefined(); + + // A foreign (offset) cursor is not a keyset cursor → first-page, no throw. + const offset = decode(encode({ mode: "offset", n: 5 })); + expect(() => decodeServer(offset)).not.toThrow(); + expect(decodeServer(offset)).toBeUndefined(); + expect(decodeQuality(offset)).toBeUndefined(); + + // An empty token has no spaces → first-page for both. + expect(decodeServer(decode(encode({ mode: "keyset", k: "" })))).toBeUndefined(); + expect(decodeQuality(decode(encode({ mode: "keyset", k: "" })))).toBeUndefined(); + + // A two-part token (only ONE space) lacks the third grouped component → + // first-page, never a partial cursor that drops the section/rank key. + expect(decodeServer(decode(encode({ mode: "keyset", k: "plex movie:1" })))).toBeUndefined(); + expect(decodeQuality(decode(encode({ mode: "keyset", k: "0 movie:1" })))).toBeUndefined(); + + // A trailing space (empty id) → first-page. + expect(decodeServer(decode(encode({ mode: "keyset", k: "plex Title " })))).toBeUndefined(); + + // A quality token whose head is not a finite number → first-page rather than + // flowing a NaN rank into the keyset comparison. + expect( + decodeQuality(decode(encode({ mode: "keyset", k: "notanumber Title movie:1" }))), + ).toBeUndefined(); + + // A malformed/garbage opaque cursor decodes to null upstream, so both grouped + // decoders fold it to first-page and never throw. + expect(() => decodeServer(decode("@@@not-base64-json@@@"))).not.toThrow(); + expect(decodeServer(decode("@@@not-base64-json@@@"))).toBeUndefined(); + expect(decodeQuality(decode("@@@not-base64-json@@@"))).toBeUndefined(); + }); + + // FILTERS NARROW THE EXPANDED TITLE SET — a filter axis narrows which TITLES + // appear, applied in SQL as a row-scoped `json_each EXISTS` membership (design + // §Filters; §Schema line: "servers=json_each EXISTS"). The `servers` axis + // matches on the human-readable `label` (`"Plex"`), NOT the connection id, so + // it agrees with the facet key and the FE popover value (see the regression + // test below). A `servers: ["Plex"]` filter keeps every title available on + // Plex and DROPS every title not on Plex entirely; a kept multi-server title + // still fans out across ALL its sections (the filter narrows titles, not + // sections — the expansion is unchanged). The mutation-sensitive invariant: a + // title NOT on Plex must vanish completely, while a title ON Plex keeps every + // one of its sections. + it("narrows the expanded Server set to titles matching the filter, fanning kept titles across all sections", async () => { + await seed([ + { + id: "movie:both", + sortTitle: "Both", + servers: [ + { id: "plex", label: "Plex" }, + { id: "jelly", label: "Jellyfin" }, + ], + }, + { + id: "movie:jellyonly", + sortTitle: "JellyOnly", + servers: [{ id: "jelly", label: "Jellyfin" }], + }, + ]); + + const { rows } = await walkServer(100, { servers: ["Plex"] }); + + // `movie:jellyonly` (not on Plex) is dropped ENTIRELY — the filter's + // row-scoped `EXISTS` excludes the whole title. `movie:both` (on Plex) is + // kept and still expands across BOTH its server sections, because the filter + // narrows the title set, never the per-title expansion. A filter that leaked + // the jelly-only title, or one that collapsed `movie:both` to just its plex + // section, both fail here. + expect(rows.map(serverKey)).toEqual(["jelly|movie:both", "plex|movie:both"]); + // The excluded title contributes no section at all. + expect(rows.some((r) => r.id === "movie:jellyonly")).toBe(false); + }); + + // SERVERS FILTER MATCHES ON LABEL, NOT ID — the regression guard for the + // facet-key / filter-value mismatch. The facets repo keys the `servers` count + // map on `je.value ->> 'label'`, and the FE popover sends that same label back + // as `filters.servers`; the lens filter predicate (`ownedFilterConditions`'s + // servers arm) must therefore match on `label` too, or selecting any server + // facet matches NO row. We seed one owned title on `{ id: "conn-1", label: + // "Plex" }` and prove the two axes now agree: filtering by the LABEL the facet + // surfaces keeps the title, while filtering by the connection id keeps nothing. + // If the predicate regressed to `value ->> 'id'`, the label filter would match + // nothing and the first assertion would fail. The Server LENS still SECTIONS on + // the id (asserted via `section.id` below) — that grouping axis is unchanged. + it("filters the Server lens on the server LABEL (facet key), not the connection id", async () => { + await seed([{ id: "movie:1", sortTitle: "Alpha", servers: [{ id: "conn-1", label: "Plex" }] }]); + + // (a) The facets `servers` map keys on the human LABEL, so the popover badge + // and the filter value both read "Plex" — never the opaque "conn-1" id. + const facets = await selectFacets(USER_ID); + expect(facets.servers).toEqual({ Plex: 1 }); + + // The LABEL is what the facet map keys on and what the popover sends back, so + // it must KEEP the title. + const byLabel = await walkServer(100, { servers: ["Plex"] }); + expect(byLabel.rows.map((r) => r.id)).toEqual(["movie:1"]); + // The lens still SECTIONS on the connection id — the section grouping axis is + // separate from the (now label-keyed) filter axis. + expect(byLabel.rows[0]?.section).toEqual({ id: "conn-1", label: "Plex" }); + + // The connection id is NO LONGER the filter value: it matches nothing, since + // the predicate now compares `value ->> 'label'`. This is the half of the + // pair that proves the bug is fixed — under the old `value ->> 'id'` + // predicate this filter would have (wrongly) kept the title. + const byId = await walkServer(100, { servers: ["conn-1"] }); + expect(byId.rows).toHaveLength(0); + }); +}); diff --git a/apps/server/src/library/__tests__/lens-pages.test.ts b/apps/server/src/library/__tests__/lens-pages.test.ts new file mode 100644 index 00000000..91705f93 --- /dev/null +++ b/apps/server/src/library/__tests__/lens-pages.test.ts @@ -0,0 +1,389 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { + cleanupInMemoryDbs, + createInMemoryDb, + type Db, +} from "../../__tests__/helpers/in-memory-db"; +import { user } from "../../db/schema/auth"; +import { libraryItems } from "../../db/schema/library"; + +// The lens-page repo resolves its database handle through `getDb()`. Mirror the +// sync test harness EXACTLY: stub `env` (the db client imports it transitively) +// and point `getDb()` at the real migrated in-memory database so the keyset +// ORDER BY, the COALESCE timeline predicate, and the `json_each` membership +// filters are exercised against actual SQLite — the off-by-one boundary and the +// null/0 ORDER-BY-vs-cursor mismatch can only be proven against the real query +// planner, never a mocked repo. +vi.mock("../../env", () => ({ + env: { CACHE_PROVIDER: "memory", ENCRYPTION_KEY: "test-key" }, +})); + +vi.mock("../../db/client", async () => { + const actual = await vi.importActual("../../db/client"); + return { + ...actual, + getDb: () => testDb, + }; +}); + +// Import the code under test AFTER the mocks are registered so it binds to the +// stubbed `getDb`. The repo (lens pages) and the keyset codec are imported real +// — mocking either would defeat the very invariants these tests lock. +const { selectAzPage, selectTimelinePage } = await import("../repo"); +const { __resetLibraryForTests } = await import("../repo"); +const { azToken, decodeAz, timelineToken, decodeTimeline } = await import("../sources/keyset"); +const { encode, decode } = await import("../../media"); + +let testDb: Db; + +const USER_ID = "u1"; + +/** A row the lens sources page off, in the `library_items` insert shape. */ +interface SeedRow { + id: string; + tmdbId?: string; + mediaType?: "movie" | "tv"; + sortTitle?: string; + year?: number | null; + genres?: string[]; + servers?: { id: string; label: string }[]; + qualityTiers?: string[]; + watchedState?: "watched" | "partial" | "unwatched" | null; + owned?: boolean; +} + +/** + * Inserts owned library rows directly into `library_items`, filling every + * not-null column with a defaultable value so a test only needs to set the + * axis it asserts on. `owned` defaults to true so the rows land in the lens + * sources' base `owned = true` set; a test can override it to seed a tombstone. + */ +async function seed(rows: SeedRow[]): Promise { + await testDb.insert(libraryItems).values( + rows.map((r) => ({ + id: r.id, + userId: USER_ID, + tmdbId: r.tmdbId ?? r.id, + mediaType: r.mediaType ?? ("movie" as const), + owned: r.owned ?? true, + ownedAt: Date.now(), + sortTitle: r.sortTitle ?? "", + year: r.year === undefined ? null : r.year, + genres: r.genres ?? [], + servers: r.servers ?? [], + qualityTiers: r.qualityTiers ?? [], + watchedState: r.watchedState ?? null, + })), + ); +} + +/** + * Walks every A–Z page from the first, threading the next cursor EXACTLY as + * `sources/az.ts` does: the page's `nextRow` is encoded with `azToken`, wrapped + * in the opaque `{ mode: "keyset", k }` cursor, round-tripped through the shared + * `encode`/`decode` codec a real request would traverse, and decoded back to an + * `AzCursor` with `decodeAz`. Returns the ordered list of ids the loop emitted + * plus whether the final page exhausted the scan (no `nextRow`). A safety cap on + * the loop count turns an accidental infinite/duplicating loop into a failure + * rather than a hang. + */ +async function walkAz(limit: number): Promise<{ ids: string[]; exhausted: boolean }> { + const ids: string[] = []; + let cursor: ReturnType = undefined; + let exhausted = false; + for (let guard = 0; guard < 1000; guard++) { + const page = await selectAzPage(USER_ID, {}, cursor, limit); + for (const row of page.rows) ids.push(row.id); + if (!page.nextRow) { + exhausted = true; + break; + } + const raw = encode({ mode: "keyset", k: azToken(page.nextRow) }); + cursor = decodeAz(decode(raw)); + } + return { ids, exhausted }; +} + +/** Timeline twin of {@link walkAz}, threading `timelineToken`/`decodeTimeline`. */ +async function walkTimeline(limit: number): Promise<{ ids: string[]; exhausted: boolean }> { + const ids: string[] = []; + let cursor: ReturnType = undefined; + let exhausted = false; + for (let guard = 0; guard < 1000; guard++) { + const page = await selectTimelinePage(USER_ID, {}, cursor, limit); + for (const row of page.rows) ids.push(row.id); + if (!page.nextRow) { + exhausted = true; + break; + } + const raw = encode({ mode: "keyset", k: timelineToken(page.nextRow) }); + cursor = decodeTimeline(decode(raw)); + } + return { ids, exhausted }; +} + +beforeAll(async () => { + testDb = await createInMemoryDb(); + await testDb.insert(user).values({ + id: USER_ID, + name: USER_ID, + email: `${USER_ID}@test`, + emailVerified: false, + createdAt: new Date(), + updatedAt: new Date(), + }); +}); + +afterAll(() => cleanupInMemoryDbs()); + +beforeEach(async () => { + await __resetLibraryForTests(testDb); +}); + +describe("library lens pages (design §The 5 lenses, phase 2 keyset)", () => { + // AZ PAGINATION COMPLETENESS — the load-bearing boundary invariant. Paging the + // whole owned set in `limit`-sized hops, threading each page's `nextRow` back + // as the next cursor EXACTLY as the source does, must reconstruct the full + // sorted set with NO id skipped and NO id duplicated. With 7 rows at limit 2 + // the loop crosses 3 page boundaries (pages of 2,2,2,1), so an off-by-one in + // `toLensPage` (encoding the dropped overflow row instead of the last returned + // row, or vice versa) drops or repeats the boundary id and this fails. A test + // that only checked page 1 could not see it. + it("pages the whole A–Z set across boundaries with no skips or duplicates", async () => { + await seed([ + { id: "movie:1", sortTitle: "Alpha" }, + { id: "movie:2", sortTitle: "Bravo" }, + { id: "movie:3", sortTitle: "Charlie" }, + { id: "movie:4", sortTitle: "Delta" }, + { id: "movie:5", sortTitle: "Echo" }, + { id: "movie:6", sortTitle: "Foxtrot" }, + { id: "movie:7", sortTitle: "Golf" }, + ]); + + const { ids, exhausted } = await walkAz(2); + + // The concatenation across every page equals the full set in `(sortTitle, + // id)` order — exactly once each, in order. Using `toEqual` on the ordered + // array asserts order, no-skip, and no-duplicate in one shot. + expect(ids).toEqual([ + "movie:1", + "movie:2", + "movie:3", + "movie:4", + "movie:5", + "movie:6", + "movie:7", + ]); + // Sanity: the seeded set was fully reconstructed, none lost or doubled. + expect(new Set(ids).size).toBe(7); + expect(ids).toHaveLength(7); + // The final page (a short read of 1 row) emitted no next cursor, so the + // pipeline ends the scan rather than looping forever. + expect(exhausted).toBe(true); + }); + + // AZ KEYSET TIEBREAK — two rows sharing the IDENTICAL `sortTitle` must both + // appear, ordered by `id`, even when the page boundary falls BETWEEN them. The + // keyset predicate is `(sortTitle, id)`: if the tiebreak on `id` were dropped + // (e.g. resuming on `sortTitle > cursor` alone), the second same-title row + // would be skipped. Seeding three same-title rows at limit 1 forces a boundary + // after the first of the tie, so a broken tiebreak loses `movie:b`. + it("keeps both rows of a sortTitle tie, ordered by id, across the boundary", async () => { + await seed([ + { id: "movie:a", sortTitle: "Same" }, + { id: "movie:b", sortTitle: "Same" }, + { id: "movie:c", sortTitle: "Same" }, + ]); + + const { ids, exhausted } = await walkAz(1); + + // Both tied rows survive and stay in ascending-id order across the boundary + // that splits the tie. + expect(ids).toEqual(["movie:a", "movie:b", "movie:c"]); + expect(exhausted).toBe(true); + }); + + // TIMELINE PAGINATION incl NULL + year-0 — locks the COALESCE/ORDER-BY match. + // The order is `COALESCE(year, 0) DESC, id ASC`; a null year and a literal 0 + // year both collapse to the descending tail, tie-broken by id. Paging in hops + // of 2 must reconstruct the full set with no drops or duplicates in that exact + // order. If the ORDER BY (e.g. raw `year DESC`, SQLite NULLS-last) ever + // disagreed with the cursor predicate's `COALESCE(year,0)`, the boundary that + // lands among the null/0 tail would skip or double a row and this fails. + it("pages the Timeline set with null and year-0 at the tail, no skips or duplicates", async () => { + await seed([ + { id: "movie:y2020", year: 2020 }, + { id: "movie:y2000", year: 2000 }, + { id: "movie:y1999", year: 1999 }, + { id: "movie:y0", year: 0 }, + { id: "movie:nullA", year: null }, + { id: "movie:nullB", year: null }, + ]); + + const { ids, exhausted } = await walkTimeline(2); + + // year DESC for the dated rows, then the COALESCE-to-0 tail (the literal-0 + // row and both nulls) ordered among themselves by ascending id. `movie:nullA` + // < `movie:nullB` < `movie:y0` lexically, so the tail order is fixed. + expect(ids).toEqual([ + "movie:y2020", + "movie:y2000", + "movie:y1999", + "movie:nullA", + "movie:nullB", + "movie:y0", + ]); + expect(new Set(ids).size).toBe(6); + expect(ids).toHaveLength(6); + expect(exhausted).toBe(true); + }); + + // CURSOR CODEC — the resume tuple must survive a full encode->decode round-trip + // for both lenses, and EVERY bad-cursor path must degrade to `undefined` + // (read as "first page") and NEVER throw. This is the V.CU1 "a hand-edited + // cursor degrades, never 400s" invariant. + it("round-trips a keyset cursor and folds every bad cursor to first-page", () => { + // A–Z round-trip: a row with a SPACE in its sortTitle proves the codec + // splits on the LAST space, recovering the full sortTitle and the id. + const azRow = { sortTitle: "The Matrix", id: "movie:603" } as Parameters[0]; + expect(decodeAz(decode(encode({ mode: "keyset", k: azToken(azRow) })))).toEqual({ + sortTitle: "The Matrix", + id: "movie:603", + }); + + // Timeline round-trip: the year parses back to a finite integer and the id + // survives; an undated row pages as year 0. + const tlRow = { year: 1999, id: "movie:603" } as Parameters[0]; + expect(decodeTimeline(decode(encode({ mode: "keyset", k: timelineToken(tlRow) })))).toEqual({ + year: 1999, + id: "movie:603", + }); + const undated = { year: null, id: "movie:9" } as Parameters[0]; + expect(decodeTimeline(decode(encode({ mode: "keyset", k: timelineToken(undated) })))).toEqual({ + year: 0, + id: "movie:9", + }); + + // A null cursor (no cursor supplied) is first-page for both lenses. + expect(decodeAz(null)).toBeUndefined(); + expect(decodeTimeline(null)).toBeUndefined(); + + // A foreign (offset) cursor is not a keyset cursor → first-page, no throw. + const offset = decode(encode({ mode: "offset", n: 5 })); + expect(() => decodeAz(offset)).not.toThrow(); + expect(decodeAz(offset)).toBeUndefined(); + expect(decodeTimeline(offset)).toBeUndefined(); + + // An empty keyset token has no space → first-page. + expect(decodeAz(decode(encode({ mode: "keyset", k: "" })))).toBeUndefined(); + expect(decodeTimeline(decode(encode({ mode: "keyset", k: "" })))).toBeUndefined(); + + // A token with a trailing space (empty id) → first-page. + expect(decodeAz(decode(encode({ mode: "keyset", k: "Alpha " })))).toBeUndefined(); + + // A timeline token whose head is not a finite number → first-page rather + // than flowing NaN into the keyset comparison. + expect( + decodeTimeline(decode(encode({ mode: "keyset", k: "notanumber movie:1" }))), + ).toBeUndefined(); + + // A malformed/garbage opaque cursor string decodes to null upstream, so + // both lens decoders fold it to first-page and never throw. + expect(() => decodeAz(decode("@@@not-base64-json@@@"))).not.toThrow(); + expect(decodeAz(decode("@@@not-base64-json@@@"))).toBeUndefined(); + expect(decodeTimeline(decode("@@@not-base64-json@@@"))).toBeUndefined(); + }); + + // FILTERS IN SQL — each filter axis must narrow the owned set in SQL, and an + // empty axis must apply no filter (return everything). A single mixed seed + // exercises every axis: kinds (media_type IN), watched (watched_state IN), + // genres + qualities (json_each value membership), and servers (json_each + // id membership, multi-valued). The owned-only base set is also proven: a + // tombstoned row never appears. + it("applies each filter axis in SQL and an empty axis returns everything", async () => { + await seed([ + { + id: "movie:1", + sortTitle: "A", + mediaType: "movie", + watchedState: "watched", + genres: ["Drama", "Crime"], + qualityTiers: ["4K HDR"], + servers: [ + { id: "plex", label: "Plex" }, + { id: "jellyfin", label: "Jellyfin" }, + ], + }, + { + id: "tv:2", + sortTitle: "B", + mediaType: "tv", + watchedState: "unwatched", + genres: ["Comedy"], + qualityTiers: ["1080p"], + servers: [{ id: "jellyfin", label: "Jellyfin" }], + }, + { + id: "movie:3", + sortTitle: "C", + mediaType: "movie", + watchedState: "partial", + genres: ["Drama"], + qualityTiers: ["720p"], + servers: [{ id: "emby", label: "Emby" }], + }, + // A tombstoned row must NEVER surface in any lens — the base WHERE is + // `owned = true`. Give it a kind/genre/server that would otherwise match + // the filters below so a dropped `owned` guard would leak it. + { + id: "movie:dead", + sortTitle: "D", + mediaType: "movie", + watchedState: "watched", + genres: ["Drama"], + qualityTiers: ["4K HDR"], + servers: [{ id: "plex", label: "Plex" }], + owned: false, + }, + ]); + + const ids = async (filters: Parameters[1]) => { + const page = await selectAzPage(USER_ID, filters, undefined, 100); + return page.rows.map((r) => r.id).sort(); + }; + + // Empty axes → no filter: every OWNED row, never the tombstone. + expect(await ids({})).toEqual(["movie:1", "movie:3", "tv:2"]); + + // kinds: media_type IN (...). + expect(await ids({ kinds: ["tv"] })).toEqual(["tv:2"]); + expect(await ids({ kinds: ["movie"] })).toEqual(["movie:1", "movie:3"]); + + // watched: watched_state IN (...). + expect(await ids({ watched: ["watched"] })).toEqual(["movie:1"]); + expect(await ids({ watched: ["watched", "partial"] })).toEqual(["movie:1", "movie:3"]); + + // genres: json_each value membership — `movie:1` and `movie:3` both carry + // "Drama"; `tv:2` does not. + expect(await ids({ genres: ["Drama"] })).toEqual(["movie:1", "movie:3"]); + expect(await ids({ genres: ["Comedy"] })).toEqual(["tv:2"]); + + // qualities: json_each value membership. + expect(await ids({ qualities: ["4K HDR"] })).toEqual(["movie:1"]); + expect(await ids({ qualities: ["1080p", "720p"] })).toEqual(["movie:3", "tv:2"]); + + // servers: json_each LABEL membership over the `{ id, label }` objects (the + // label is the facet key the popover surfaces and sends back). A multi-server + // row (`movie:1` on Plex+Jellyfin) matches when ANY requested server label is + // one of its servers; the connection id is NOT a match value. + expect(await ids({ servers: ["Plex"] })).toEqual(["movie:1"]); + expect(await ids({ servers: ["Jellyfin"] })).toEqual(["movie:1", "tv:2"]); + expect(await ids({ servers: ["Emby"] })).toEqual(["movie:3"]); + expect(await ids({ servers: ["plex"] })).toEqual([]); + + // Combined axes intersect (AND across axes): a movie, watched, on Plex. + expect(await ids({ kinds: ["movie"], watched: ["watched"], servers: ["Plex"] })).toEqual([ + "movie:1", + ]); + }); +}); diff --git a/apps/server/src/library/__tests__/multi-user.test.ts b/apps/server/src/library/__tests__/multi-user.test.ts new file mode 100644 index 00000000..51c0109b --- /dev/null +++ b/apps/server/src/library/__tests__/multi-user.test.ts @@ -0,0 +1,262 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { consola, type ConsolaInstance } from "consola"; +import { and, eq } from "drizzle-orm"; +import { + cleanupInMemoryDbs, + createInMemoryDb, + type Db, +} from "../../__tests__/helpers/in-memory-db"; +import { user } from "../../db/schema/auth"; +import { libraryItems } from "../../db/schema/library"; + +// Membership reads/writes resolve their database handle through `getDb()`. +// Mirror the sync/lens-pages harness EXACTLY: stub `env` (the db client imports +// it transitively) and point `getDb()` at the real migrated in-memory database +// so the composite-PK conflict path is exercised against actual SQLite. The +// "same title, two owners" invariant can only be proven against the real +// `(user_id, id)` primary key the migration 0004 declares — a single global +// `id` PK would silently drop the second owner's insert under +// `onConflictDoNothing`, and only the real query planner reveals that. +vi.mock("../../env", () => ({ + env: { CACHE_PROVIDER: "memory", ENCRYPTION_KEY: "test-key" }, +})); + +vi.mock("../../db/client", async () => { + const actual = await vi.importActual("../../db/client"); + return { + ...actual, + getDb: () => testDb, + }; +}); + +// Import the code under test AFTER the mocks are registered so it binds to the +// stubbed `getDb`. The repo and service are imported real (NOT mocked): mocking +// either would defeat the very per-user isolation invariants these tests guard. +const { syncMembership } = await import("../service"); +const { allKnownKeys, upsertOwned, tombstoneMissing, __resetLibraryForTests } = + await import("../repo"); +const { asLibraryContext } = await import("../internal/context"); + +let testDb: Db; + +const log: ConsolaInstance = consola.withTag("test"); + +const USER_A = "uA"; +const USER_B = "uB"; + +/** Shape the `collection@v1` aggregate surfaces, as `syncMembership` consumes it. */ +type CollectionFeed = { items: unknown[]; partial: boolean }; + +/** + * Builds one feed entry in the `collection@v1` `{ item, addedAt }` shape that + * `toOwnedRow` parses. `addedAt` is an ISO string so `parseItemDate` resolves a + * real `ownedAt`. + */ +function entry(tmdbId: string, type: "movie" | "tv" = "movie", addedAt?: string) { + return { item: { ids: { tmdb_id: tmdbId }, type }, addedAt: addedAt ?? "2024-01-01T00:00:00Z" }; +} + +/** + * A media-service stub whose only sync-relevant method is `getCollectionFeed`. + * Each user's sync gets its own stub so the two feeds stay independent — the + * end-to-end isolation test relies on each user seeing only its own feed. + */ +function makeMediaService(feed: CollectionFeed = { items: [], partial: false }) { + return { getCollectionFeed: vi.fn().mockResolvedValue(feed) }; +} + +/** + * Builds the loose `MaybeLibraryContext` the public surface accepts for a given + * user. The `catalog` handle is unused by phase-1 membership sync (carried for + * the phase-2 hydrate path), so an empty stub cast through the resolver's + * expected type is sufficient. Each call gets a fresh media-service stub bound + * to `userId` so two users never share a feed. + */ +function makeCtx(userId: string, feed?: CollectionFeed) { + const mediaService = makeMediaService(feed); + const ctx = { + userId, + mediaService: mediaService as unknown as Parameters[0]["mediaService"], + catalog: {} as unknown as Parameters[0]["catalog"], + log, + }; + return { ctx, mediaService }; +} + +/** Reads a single library row scoped to `userId` by its composite id, or undefined. */ +async function rowById(userId: string, id: string) { + const rows = await testDb + .select() + .from(libraryItems) + .where(and(eq(libraryItems.userId, userId), eq(libraryItems.id, id))); + return rows[0]; +} + +/** Inserts a seed `user` row so the `library_items.user_id` foreign key resolves. */ +async function seedUser(id: string) { + await testDb.insert(user).values({ + id, + name: id, + email: `${id}@test`, + emailVerified: false, + createdAt: new Date(), + updatedAt: new Date(), + }); +} + +beforeAll(async () => { + testDb = await createInMemoryDb(); + // Seed TWO users. Both can legitimately own the SAME title; the composite PK + // is what keeps each owner's row distinct, and that is exactly what these + // tests lock. + await seedUser(USER_A); + await seedUser(USER_B); +}); + +afterAll(() => cleanupInMemoryDbs()); + +beforeEach(async () => { + await __resetLibraryForTests(testDb); +}); + +describe("library multi-user membership (design §Sync + hydrate, composite-PK isolation)", () => { + // SAME TITLE, TWO OWNERS — the core regression. `id` ("movie:550") is unique + // only WITHIN a user, so two users owning the same title are TWO distinct rows + // under the `(user_id, id)` primary key. Both inserts MUST report 1 inserted + // and both per-user rows MUST be owned. If the table ever reverts to a single + // global `id` PK, uB's insert collides on the existing `movie:550` pk; the + // repo's `onConflictDoNothing` then silently drops it, `upsertOwned` returns 0, + // and uB has no row — this test fails on the `expect(insertedB).toBe(1)` + // assertion and again on uB's `owned === true`. That is the mutation-sensitive + // heart of the fix. + it("lets two users each own the same title as distinct rows", async () => { + const insertedA = await upsertOwned( + [{ id: "movie:550", userId: USER_A, tmdbId: "550", mediaType: "movie", ownedAt: Date.now() }], + testDb, + ); + const insertedB = await upsertOwned( + [{ id: "movie:550", userId: USER_B, tmdbId: "550", mediaType: "movie", ownedAt: Date.now() }], + testDb, + ); + + // Each user's insert lands its own row — a global `id` PK would drop uB's. + expect(insertedA).toBe(1); + expect(insertedB).toBe(1); + + // Both per-user rows exist and are owned; the ids match but the rows are + // distinct because they are scoped by `user_id`. + const aRow = await rowById(USER_A, "movie:550"); + const bRow = await rowById(USER_B, "movie:550"); + expect(aRow?.owned).toBe(true); + expect(bRow?.owned).toBe(true); + expect(aRow?.userId).toBe(USER_A); + expect(bRow?.userId).toBe(USER_B); + + // Exactly two rows carry that composite id across the whole table — one per + // owner, never collapsed into one. + const allWithId = await testDb + .select() + .from(libraryItems) + .where(eq(libraryItems.id, "movie:550")); + expect(allWithId).toHaveLength(2); + }); + + // PER-USER allKnownKeys ISOLATION — the diff key set is userId-scoped. uA and + // uB each own a disjoint set plus one shared title; `allKnownKeys(uA)` must + // return ONLY uA's keys and `allKnownKeys(uB)` ONLY uB's. If the query ever + // dropped its `user_id` predicate, each set would leak the other user's keys + // and a brand-new owned title for one user could be wrongly pre-filtered as + // "already known" because the OTHER user owns it — silently never inserted. + it("scopes allKnownKeys to a single user", async () => { + await upsertOwned( + [ + { id: "movie:550", userId: USER_A, tmdbId: "550", mediaType: "movie", ownedAt: Date.now() }, + { id: "tv:1400", userId: USER_A, tmdbId: "1400", mediaType: "tv", ownedAt: Date.now() }, + ], + testDb, + ); + await upsertOwned( + [ + // uB shares "movie:550" with uA and adds a title uA does NOT own. + { id: "movie:550", userId: USER_B, tmdbId: "550", mediaType: "movie", ownedAt: Date.now() }, + { id: "movie:603", userId: USER_B, tmdbId: "603", mediaType: "movie", ownedAt: Date.now() }, + ], + testDb, + ); + + // Each set is exactly that user's keys — never the other's. + expect(await allKnownKeys(USER_A)).toEqual(new Set(["movie:550", "tv:1400"])); + expect(await allKnownKeys(USER_B)).toEqual(new Set(["movie:550", "movie:603"])); + }); + + // PER-USER TOMBSTONE ISOLATION — `tombstoneMissing` is userId-scoped, so + // tombstoning a title for uA MUST NOT touch uB's row for the same title. After + // both own "movie:550", a tombstone sweep for uA that keeps nothing flips uA's + // row to owned=false; uB's identically-keyed row stays owned=true. If the + // update ever dropped its `user_id` predicate, uB's row would be collateral + // damage — one user's un-ownership would silently erase another's library. + it("tombstones a title for one user without touching the other owner", async () => { + await upsertOwned( + [{ id: "movie:550", userId: USER_A, tmdbId: "550", mediaType: "movie", ownedAt: Date.now() }], + testDb, + ); + await upsertOwned( + [{ id: "movie:550", userId: USER_B, tmdbId: "550", mediaType: "movie", ownedAt: Date.now() }], + testDb, + ); + + // Sweep uA with an empty keep set — every owned row for uA is tombstoned. + const tombstoned = await tombstoneMissing(USER_A, [], Date.now(), testDb); + expect(tombstoned).toBe(1); + + // uA's row is now tombstoned; uB's identically-keyed row is untouched. + const aRow = await rowById(USER_A, "movie:550"); + expect(aRow?.owned).toBe(false); + expect(aRow?.unownedAt).not.toBeNull(); + + const bRow = await rowById(USER_B, "movie:550"); + expect(bRow?.owned).toBe(true); + expect(bRow?.unownedAt).toBeNull(); + }); + + // END-TO-END via syncMembership — two users each sync a feed that contains the + // SAME title plus a private one. Each user must end with its own owned row for + // the shared title and independent counts: each sync sees only its own feed + // (its own `getCollectionFeed` stub) and inserts only its own rows. A + // regression to a global `id` PK would surface here as uB's sync reporting + // `added: 1` instead of `2` (the shared title's insert dropped on conflict), + // and uB missing the shared owned row. This walks the real public lifecycle, + // not the repo directly. + it("syncs each user's feed independently when feeds share a title", async () => { + // uA owns the shared title plus a private one. + const resultA = await syncMembership( + makeCtx(USER_A, { + items: [entry("550"), entry("700")], + partial: false, + }).ctx, + ); + expect(resultA).toEqual({ added: 2, partial: false, removed: 0 }); + + // uB's feed re-includes the SAME shared title plus a different private one. + // Both rows are new FOR uB, so its sync inserts two even though uA already + // owns "movie:550". + const resultB = await syncMembership( + makeCtx(USER_B, { + items: [entry("550"), entry("800")], + partial: false, + }).ctx, + ); + expect(resultB).toEqual({ added: 2, partial: false, removed: 0 }); + + // Each user's known-key projection is exactly its own feed — never blended. + expect(await allKnownKeys(USER_A)).toEqual(new Set(["movie:550", "movie:700"])); + expect(await allKnownKeys(USER_B)).toEqual(new Set(["movie:550", "movie:800"])); + + // Both own the shared title as their own owned row; neither sees the other's + // private title. + expect((await rowById(USER_A, "movie:550"))?.owned).toBe(true); + expect((await rowById(USER_B, "movie:550"))?.owned).toBe(true); + expect(await rowById(USER_A, "movie:800")).toBeUndefined(); + expect(await rowById(USER_B, "movie:700")).toBeUndefined(); + }); +}); diff --git a/apps/server/src/library/__tests__/rank-quality.test.ts b/apps/server/src/library/__tests__/rank-quality.test.ts new file mode 100644 index 00000000..ea209ced --- /dev/null +++ b/apps/server/src/library/__tests__/rank-quality.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vite-plus/test"; +import { QUALITY_TIERS } from "@ent-mcp/shared/library"; +import { decodeCollectionsCursor, encodeCollectionsCursor } from "../internal/collections-cursor"; +import { QUALITY_RANK_UNRANKED, rankQualityTier } from "../internal/rank-quality"; + +// These are pure-derivation invariants: each assertion is written so it FAILS +// if the underlying business rule changes (Rule 9), not merely if the function +// throws. No database or plugin runtime is touched — every input is a literal. +describe("rankQualityTier", () => { + // Every known tier must map to its own tuple index, and the tuple is ordered + // hi→lo so index 0 is the highest fidelity. Asserting the FULL list (not a + // spot check) means this test fails if a tier is reordered, renamed, added, + // or removed — any of which would silently desync the Quality lens ordering. + it("maps each known tier label to its hi→lo tuple index", () => { + QUALITY_TIERS.forEach((label, expectedIndex) => { + expect(rankQualityTier(label)).toBe(expectedIndex); + }); + }); + + // The top of the tuple is the highest fidelity, so its rank must be exactly 0; + // pinning this catches a regression that flipped the tuple to lo→hi order. + it("ranks the highest-fidelity tier at ordinal 0", () => { + expect(rankQualityTier(QUALITY_TIERS[0])).toBe(0); + }); + + // A label no plugin tier lists (free-form strings like "Atmos") must sink to + // the unranked sentinel so it sorts below every real tier; a non-sentinel + // result here would float unknown quality up into the ranked band. + it("ranks an unknown label as the unranked sentinel", () => { + expect(rankQualityTier("Atmos")).toBe(QUALITY_RANK_UNRANKED); + }); + + // The empty string is the degenerate unknown label; it must also fall through + // to the sentinel rather than accidentally matching an empty tuple slot. + it("ranks the empty label as the unranked sentinel", () => { + expect(rankQualityTier("")).toBe(QUALITY_RANK_UNRANKED); + }); + + // LOCKSTEP INVARIANT. The SQL `CASE` the repo builds emits one WHEN arm per + // tier and uses QUALITY_RANK_UNRANKED in its ELSE arm; that value MUST equal + // QUALITY_TIERS.length so the SQL ELSE ordinal sits exactly one past the last + // ranked index — identical to the JS fallback. If the sentinel and the tuple + // length ever drift, the SQL ORDER BY and the JS rank disagree at a page + // boundary and the Quality lens drops or duplicates rows. This pins the link. + it("keeps the unranked sentinel equal to the tuple length", () => { + expect(QUALITY_RANK_UNRANKED).toBe(QUALITY_TIERS.length); + }); + + // The sentinel must rank strictly below every real tier; comparing it against + // the last (lowest-fidelity) tier's rank proves the ordering gap exists rather + // than merely asserting an arithmetic identity. + it("ranks the sentinel below the lowest-fidelity tier", () => { + const lowestTierRank = rankQualityTier(QUALITY_TIERS.at(-1)!); + expect(QUALITY_RANK_UNRANKED).toBeGreaterThan(lowestTierRank); + }); +}); + +describe("collections cursor codec", () => { + // The id is a TMDB numeric string that never holds a space, while the name + // can, so the codec splits on the LAST space. Round-tripping a spaced name + // proves the suffix-is-id split survives interior spaces; a naive first-space + // split would corrupt both fields here. + it("round-trips a (name, id) pair whose name contains spaces", () => { + const cursor = { + collectionName: "The Lord of the Rings Collection", + collectionId: "119", + }; + const decoded = decodeCollectionsCursor(encodeCollectionsCursor(cursor)); + expect(decoded).toEqual(cursor); + }); + + // A single-word name must also survive so the spaced-name case is not the only + // path the codec handles. + it("round-trips a (name, id) pair with a single-word name", () => { + const cursor = { collectionName: "Alien", collectionId: "8091" }; + const decoded = decodeCollectionsCursor(encodeCollectionsCursor(cursor)); + expect(decoded).toEqual(cursor); + }); + + // The decoder is total and degrades to "first page" instead of throwing, so an + // undefined token (no cursor on the first request) yields undefined. + it("returns undefined for an undefined token without throwing", () => { + expect(() => decodeCollectionsCursor(undefined)).not.toThrow(); + expect(decodeCollectionsCursor(undefined)).toBeUndefined(); + }); + + // An empty string is a hand-edited or stripped link; it must degrade to + // undefined, not throw and not resume from a bogus position. + it("returns undefined for an empty token", () => { + expect(decodeCollectionsCursor("")).toBeUndefined(); + }); + + // A foreign token with no space at all has no name/id boundary, so the codec + // cannot recover a resume position and must return undefined. + it("returns undefined for a token with no separator", () => { + expect(decodeCollectionsCursor("nodelimiter")).toBeUndefined(); + }); + + // A token that ends on the separator has an empty id suffix; with no id there + // is no keyset anchor, so the codec rejects it rather than resuming on a blank + // id that would scan from the wrong place. + it("returns undefined when the id suffix is empty", () => { + expect(decodeCollectionsCursor("Trailing Space ")).toBeUndefined(); + }); +}); diff --git a/apps/server/src/library/__tests__/sync.test.ts b/apps/server/src/library/__tests__/sync.test.ts new file mode 100644 index 00000000..19c462d4 --- /dev/null +++ b/apps/server/src/library/__tests__/sync.test.ts @@ -0,0 +1,299 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { consola, type ConsolaInstance } from "consola"; +import { and, eq } from "drizzle-orm"; +import { + cleanupInMemoryDbs, + createInMemoryDb, + type Db, +} from "../../__tests__/helpers/in-memory-db"; +import { user } from "../../db/schema/auth"; +import { libraryItems } from "../../db/schema/library"; + +// The membership sync resolves its database handle through `getDb()`. Point it +// at the real migrated in-memory database so `upsertOwned`'s +// `onConflictDoNothing` and `tombstoneMissing`'s predicate are exercised +// against actual SQLite — the no-resurrect invariant can only be proven against +// the real conflict path, never a mocked repo. +vi.mock("../../env", () => ({ + env: { CACHE_PROVIDER: "memory", ENCRYPTION_KEY: "test-key" }, +})); + +vi.mock("../../db/client", async () => { + const actual = await vi.importActual("../../db/client"); + return { + ...actual, + getDb: () => testDb, + }; +}); + +// Import the code under test AFTER the mocks are registered so it binds to the +// stubbed `getDb`. The repo is imported real (NOT mocked): mocking it would +// defeat the very invariant the no-resurrect test exists to guard. +const { syncMembership } = await import("../service"); +const { allKnownKeys, upsertOwned, __resetLibraryForTests } = await import("../repo"); +const { asLibraryContext } = await import("../internal/context"); + +let testDb: Db; + +const log: ConsolaInstance = consola.withTag("test"); + +const USER_ID = "u1"; + +/** Shape the `collection@v1` aggregate surfaces, as `syncMembership` consumes it. */ +type CollectionFeed = { items: unknown[]; partial: boolean }; + +/** + * Builds one feed entry in the `collection@v1` `{ item, addedAt }` shape that + * `toOwnedRow` parses. `addedAt` is an ISO string so `parseItemDate` resolves a + * real `ownedAt`. + */ +function entry(tmdbId: string, type: "movie" | "tv" = "movie", addedAt?: string) { + return { item: { ids: { tmdb_id: tmdbId }, type }, addedAt: addedAt ?? "2024-01-01T00:00:00Z" }; +} + +/** + * A media-service stub whose only sync-relevant method is `getCollectionFeed`. + * The default resolves an empty, complete feed; callers override per test to + * drive the membership diff. Phase-1 membership sync touches nothing else on + * the service, so the rest is deliberately absent. + */ +function makeMediaService(feed: CollectionFeed = { items: [], partial: false }) { + return { getCollectionFeed: vi.fn().mockResolvedValue(feed) }; +} + +/** + * Builds the loose `MaybeLibraryContext` the public surface accepts. The + * `catalog` handle is unused by phase-1 membership sync (it is carried for the + * phase-2 hydrate path), so an empty stub is sufficient and is cast through the + * resolver's expected type. Each call gets a fresh media-service stub. + */ +function makeCtx(feed?: CollectionFeed) { + const mediaService = makeMediaService(feed); + const ctx = { + userId: USER_ID, + mediaService: mediaService as unknown as Parameters[0]["mediaService"], + catalog: {} as unknown as Parameters[0]["catalog"], + log, + }; + return { ctx, mediaService }; +} + +/** Reads a single library row by its composite id, or undefined when absent. */ +async function rowById(id: string) { + const rows = await testDb + .select() + .from(libraryItems) + .where(and(eq(libraryItems.userId, USER_ID), eq(libraryItems.id, id))); + return rows[0]; +} + +beforeAll(async () => { + testDb = await createInMemoryDb(); + await testDb.insert(user).values({ + id: USER_ID, + name: USER_ID, + email: `${USER_ID}@test`, + emailVerified: false, + createdAt: new Date(), + updatedAt: new Date(), + }); +}); + +afterAll(() => cleanupInMemoryDbs()); + +beforeEach(async () => { + await __resetLibraryForTests(testDb); +}); + +describe("library membership sync (design §Sync + hydrate, phase 1)", () => { + // IDEMPOTENT DIFF — a re-run with the identical feed must be a no-op. If the + // diff ever stopped pre-filtering against `allKnownKeys` (e.g. blindly + // re-inserting), the second run's `added` would be non-zero; if it tombstoned + // on equality, `removed` would be non-zero. Both counts MUST be zero and the + // owned rows must be byte-for-byte unchanged (same `ownedAt`, still owned). + it("re-running with the identical feed inserts and tombstones nothing", async () => { + const feed: CollectionFeed = { + items: [entry("550"), entry("1400", "tv")], + partial: false, + }; + + const first = await syncMembership(makeCtx(feed).ctx); + expect(first).toEqual({ added: 2, partial: false, removed: 0 }); + expect(await allKnownKeys(USER_ID)).toEqual(new Set(["movie:550", "tv:1400"])); + + const before = await rowById("movie:550"); + expect(before?.owned).toBe(true); + + const second = await syncMembership(makeCtx(feed).ctx); + expect(second).toEqual({ added: 0, partial: false, removed: 0 }); + + // The pre-existing rows are untouched: same membership, same ownedAt, + // still owned, never tombstoned. + expect(await allKnownKeys(USER_ID)).toEqual(new Set(["movie:550", "tv:1400"])); + const after = await rowById("movie:550"); + expect(after?.owned).toBe(true); + expect(after?.ownedAt).toBe(before?.ownedAt); + expect(after?.unownedAt).toBeNull(); + }); + + // NO-RESURRECT — the load-bearing invariant. A key that was owned, then + // dropped from the feed (tombstoned), MUST stay tombstoned when the feed + // re-includes it. The tombstone survives ONLY because `upsertOwned` uses + // `onConflictDoNothing` on the primary key. This test runs against the real + // SQLite conflict path (repo is NOT mocked). + // + // It exercises the conflict TWO ways: + // 1. End-to-end through `syncMembership`, mirroring the real lifecycle + // (present -> dropped -> re-included). + // 2. A direct `upsertOwned([tombstonedKey])` call. This is the assertion + // that actually FAILS if `onConflictDoNothing` is ever replaced with an + // upsert: the service pre-filters re-included keys against + // `allKnownKeys`, so the full-sync path alone would never reach the + // conflict and could not catch a regression. Inserting the existing pk + // directly forces the conflict and proves it is a no-op. + it("does not resurrect a tombstoned key when the feed re-includes it", async () => { + // Run 1: key K1 + anchor A2 present -> both owned. The anchor keeps every + // later feed non-empty so the tombstone sweep fires (an empty feed is a + // no-op by design and would not tombstone anything). + await syncMembership( + makeCtx({ + items: [ + entry("K1", "movie", "2024-06-01T00:00:00Z"), + entry("A2", "movie", "2024-06-01T00:00:00Z"), + ], + partial: false, + }).ctx, + ); + const owned = await rowById("movie:K1"); + expect(owned?.owned).toBe(true); + expect(owned?.unownedAt).toBeNull(); + const originalOwnedAt = owned?.ownedAt; + + // Run 2: feed drops K1 but keeps anchor A2 -> the complete, non-empty feed + // tombstones K1 (owned=false, unownedAt set). + await syncMembership( + makeCtx({ items: [entry("A2", "movie", "2024-06-01T00:00:00Z")], partial: false }).ctx, + ); + const tombstoned = await rowById("movie:K1"); + expect(tombstoned?.owned).toBe(false); + expect(tombstoned?.unownedAt).not.toBeNull(); + const tombstonedAt = tombstoned?.unownedAt; + + // Direct conflict: attempt to re-insert the tombstoned key as owned. With + // `onConflictDoNothing` this returns 0 and leaves the row untouched. An + // upsert would return 1 and flip `owned` back to true / clear `unownedAt` + // — exactly the resurrection this invariant forbids. This call is the + // mutation-sensitive heart of the test. + const reinserted = await upsertOwned( + [{ id: "movie:K1", userId: USER_ID, tmdbId: "K1", mediaType: "movie", ownedAt: Date.now() }], + testDb, + ); + expect(reinserted).toBe(0); + const afterDirect = await rowById("movie:K1"); + expect(afterDirect?.owned).toBe(false); + expect(afterDirect?.unownedAt).toBe(tombstonedAt); + expect(afterDirect?.ownedAt).toBe(originalOwnedAt); + + // Run 3: a full feed re-including K1 (alongside the still-present anchor + // A2) stays consistent end-to-end. The row remains owned=false and the run + // is a no-op (both keys pre-filtered as known, already-tombstoned row not + // re-swept). + const third = await syncMembership( + makeCtx({ + items: [ + entry("K1", "movie", "2024-06-01T00:00:00Z"), + entry("A2", "movie", "2024-06-01T00:00:00Z"), + ], + partial: false, + }).ctx, + ); + const final = await rowById("movie:K1"); + expect(final?.owned).toBe(false); + expect(final?.unownedAt).toBe(tombstonedAt); + expect(third.added).toBe(0); + expect(third.removed).toBe(0); + }); + + // TOMBSTONE ON REMOVAL — the direct half of the lifecycle: a key present in + // one feed then absent from the next flips owned=false with `unownedAt` + // populated. `removed` must count exactly that one transition. A bug that + // hard-deleted instead of tombstoning would lose the row entirely (and so + // lose the no-resurrect guard); this asserts the row survives, flipped. + it("tombstones a key that leaves the feed and stamps unownedAt", async () => { + const present = await syncMembership( + makeCtx({ items: [entry("700"), entry("701")], partial: false }).ctx, + ); + expect(present.added).toBe(2); + + const removed = await syncMembership(makeCtx({ items: [entry("700")], partial: false }).ctx); + expect(removed.removed).toBe(1); + + // 700 stays owned; 701 is tombstoned, not deleted, with a timestamp. + const kept = await rowById("movie:700"); + expect(kept?.owned).toBe(true); + expect(kept?.unownedAt).toBeNull(); + + const gone = await rowById("movie:701"); + expect(gone).toBeDefined(); + expect(gone?.owned).toBe(false); + expect(gone?.unownedAt).not.toBeNull(); + }); + + // EMPTY / ABSENT FEED — no `collection@v1` provider (or a provider that + // disconnected) yields an empty, complete feed: the sync must be a no-op with + // zero counts, must not throw, and CRUCIALLY must not tombstone any existing + // owned row. An empty `feedKeys` would otherwise match every owned row in the + // sweep and wipe the whole library on a provider outage; the sweep guard + // forbids it. Verifies the design §Errors "no provider -> eager-seed no-op". + it("treats an empty feed as a no-op and never tombstones existing owned rows", async () => { + // Pre-seed owned rows from a complete feed. + await syncMembership(makeCtx({ items: [entry("900"), entry("901")], partial: false }).ctx); + + // A later empty feed (provider gone) must leave them fully intact. + const result = await syncMembership(makeCtx({ items: [], partial: false }).ctx); + expect(result).toEqual({ added: 0, partial: false, removed: 0 }); + expect((await rowById("movie:900"))?.owned).toBe(true); + expect((await rowById("movie:901"))?.owned).toBe(true); + }); + + // PARTIAL FEED — a degraded fan-out (a provider errored) surfaces + // `partial: true` but MUST still apply the rows that did arrive and MUST NOT + // throw. It must ALSO NOT tombstone keys merely absent from the incomplete + // feed: absence under partial is untrusted (the missing provider may own + // them). The degradation is reported, not swallowed; a later complete sync + // reconciles any real removals. + it("on a partial feed applies delivered rows but tombstones nothing absent", async () => { + // Pre-seed two owned rows from a complete feed. + await syncMembership(makeCtx({ items: [entry("800"), entry("801")], partial: false }).ctx); + + // Next feed is partial and omits 801 — 801 must NOT be tombstoned. + const result = await syncMembership(makeCtx({ items: [entry("800")], partial: true }).ctx); + expect(result.partial).toBe(true); + expect(result.removed).toBe(0); + expect((await rowById("movie:800"))?.owned).toBe(true); + expect((await rowById("movie:801"))?.owned).toBe(true); + }); + + // FEED THROW — a terminal all-providers failure inside `getCollectionFeed` + // must be swallowed at the sync boundary: the run reports `partial: true`, + // does NOT throw to the caller, and MUST NOT tombstone the owned library. + // The swallowed error returns `feedKeys: []` with `partial: true`; the sweep + // guard skips the tombstone pass so a transient outage cannot wipe owned + // rows. (Before the guard, the empty `keepKeys` matched every owned row and + // a single outage erased the whole library — this is the regression guard.) + it("does not tombstone the owned library when the feed errors terminally", async () => { + // Pre-seed an owned row from a healthy feed. + await syncMembership(makeCtx({ items: [entry("802")], partial: false }).ctx); + + // The next sync's feed call throws (all providers down). + const { ctx, mediaService } = makeCtx(); + mediaService.getCollectionFeed.mockRejectedValueOnce(new Error("all providers failed")); + + const result = await syncMembership(ctx); + expect(result.partial).toBe(true); + expect(result.added).toBe(0); + expect(result.removed).toBe(0); + // The owned library survives the outage untouched. + expect((await rowById("movie:802"))?.owned).toBe(true); + }); +}); diff --git a/apps/server/src/library/errors.ts b/apps/server/src/library/errors.ts new file mode 100644 index 00000000..330b2f16 --- /dev/null +++ b/apps/server/src/library/errors.ts @@ -0,0 +1,23 @@ +/** Base error class for library-module failures. Carries a structured code. */ +export class LibraryError extends Error { + readonly code: string; + constructor(message: string, code: string) { + super(message); + this.code = code; + this.name = "LibraryError"; + } +} + +/** + * Raised when the owned-collection membership sync fails irrecoverably (a + * terminal all-providers failure surfaced by `collection@v1`). An expected + * plugin absence (no provider installed) is NOT an error — the service + * swallows it at its boundary and returns zero counts, so this is reserved for + * genuine failures the caller should classify as a failed run. + */ +export class LibrarySyncError extends LibraryError { + constructor(message = "library membership sync failed") { + super(message, "library.sync_failed"); + this.name = "LibrarySyncError"; + } +} diff --git a/apps/server/src/library/index.ts b/apps/server/src/library/index.ts new file mode 100644 index 00000000..328a75d4 --- /dev/null +++ b/apps/server/src/library/index.ts @@ -0,0 +1,24 @@ +/** + * Public barrel for `library/`. Outside code imports only from here — the + * boundaries rule forbids deep imports of `./repo`, `./internal/**`, and + * individual files under `./jobs/`. Phases 1–3 expose the membership-sync and + * denormalized-hydrate surfaces, the facets read, the group-first collections + * read, the item-lens registrations including the server/quality `json_each` + * lenses (composed into the unified media registry by the `/api/media` + * adapter), and the cron job registration. + */ +export { + syncLibrary, + syncMembership, + hydrateLibrary, + getFacets, + listCollections, + type LibraryContext, + type SyncMembershipResult, + type HydrateOptions, + type HydrateResult, +} from "./service"; +export { libraryMediaSources } from "./internal/media-sources"; +export type { MaybeLibraryContext } from "./types"; +export { LibraryError, LibrarySyncError } from "./errors"; +export { registerJobs, LIBRARY_SYNC_JOB_ID, LIBRARY_HYDRATE_JOB_ID } from "./jobs"; diff --git a/apps/server/src/library/internal/collections-cursor.ts b/apps/server/src/library/internal/collections-cursor.ts new file mode 100644 index 00000000..164a8606 --- /dev/null +++ b/apps/server/src/library/internal/collections-cursor.ts @@ -0,0 +1,31 @@ +import type { CollectionCursor } from "../repo"; + +/** + * Opaque keyset cursor codec for the group-first Collections endpoint. Unlike + * the item lenses — which ride the media `paginate` stage's `Cursor` wrapper — + * `/api/library/collections` does not flow through the media pipeline, so it + * mints and parses its own cursor string here. The token packs + * `" "` joined on a single space and is split on + * the LAST space, mirroring the lens keyset codecs: the `collectionId` (a TMDB + * numeric id) never contains a space, but a `collectionName` ("The Lord of the + * Rings Collection") can, so the prefix is the name and the suffix is the id. + * + * `decodeCollectionsCursor` is total: a null, empty, or malformed token (a + * hand-edited link) returns `undefined`, which the service reads as "first + * page" and NEVER throws — the same degrade-don't-400 discipline the lens codecs + * follow. + */ +export function encodeCollectionsCursor(cursor: CollectionCursor): string { + return `${cursor.collectionName} ${cursor.collectionId}`; +} + +/** Decodes a collections cursor back to its `(collectionName, collectionId)` resume position, or undefined. */ +export function decodeCollectionsCursor(token: string | undefined): CollectionCursor | undefined { + if (!token) return undefined; + const sep = token.lastIndexOf(" "); + if (sep < 0) return undefined; + const collectionName = token.slice(0, sep); + const collectionId = token.slice(sep + 1); + if (collectionId.length === 0) return undefined; + return { collectionName, collectionId }; +} diff --git a/apps/server/src/library/internal/context.ts b/apps/server/src/library/internal/context.ts new file mode 100644 index 00000000..96ad49ea --- /dev/null +++ b/apps/server/src/library/internal/context.ts @@ -0,0 +1,51 @@ +import { consola } from "consola"; +import type { SourceContext } from "../../media"; +import type { LibraryContext, MaybeLibraryContext } from "../types"; + +/** + * Resolves a loose per-request context into the canonical `LibraryContext`. + * Mirrors `watchlist/internal/context.ts#asWatchlistContext`: the home row + * context names its logger `logger`, the section envelopes name it `log`, so + * both are accepted and collapsed onto `log` (falling back to the shared + * `consola` singleton). Phase 1 membership sync needs nothing beyond the + * handles already present on the loose shape, so this is a straight projection. + */ +export function asLibraryContext(ctx: MaybeLibraryContext): LibraryContext { + return { + userId: ctx.userId, + mediaService: ctx.mediaService, + catalog: ctx.catalog, + deadlineMs: ctx.deadlineMs, + log: ctx.log ?? ctx.logger ?? consola, + }; +} + +/** + * The fully-resolved per-request handles the lens read path needs: enrich reads + * catalog metadata via `catalog.getMetadataBatch` and the live resume positions + * via `loadProgressMap(ctx)`, both of which the bare `LibraryContext` already + * carries (`catalog`, `mediaService`, `log`, `deadlineMs` satisfy the + * `MediaProgressContext` `loadProgressMap` consumes). It is a distinct alias + * (not just `LibraryContext`) so a future read-only handle — bound artwork, a + * `toCanonicalRow` cold-fill — can be added here without touching the sync + * context. Posters/backdrops come from the denormalized catalog `posterUrl`/ + * `backdropUrl`, so no artwork fan-out is wired in this phase. + */ +export type ResolvedLibraryReadContext = LibraryContext; + +/** + * Bridges the media `SourceContext` the unified resolver hands a source into the + * resolved library read context the enrich hook consumes. The resolver names its + * logger `logger`; `asLibraryContext` collapses that onto `log`. This is the read + * analogue of `asLibraryContext` for the sync path — a source's `fetchRawSet` + * gets a `SourceContext`, so the eager-seed + enrich helpers resolve it here. + */ +export function asLibraryReadContext(ctx: SourceContext): ResolvedLibraryReadContext { + return asLibraryContext({ + userId: ctx.userId, + mediaService: ctx.mediaService, + catalog: ctx.catalog, + ...(ctx.deadlineMs != null ? { deadlineMs: ctx.deadlineMs } : {}), + logger: ctx.logger, + }); +} diff --git a/apps/server/src/library/internal/enrich.ts b/apps/server/src/library/internal/enrich.ts new file mode 100644 index 00000000..1ce28bfe --- /dev/null +++ b/apps/server/src/library/internal/enrich.ts @@ -0,0 +1,110 @@ +import type { CanonicalMetadata } from "@ent-mcp/shared/catalog"; +import type { CompactMediaItem } from "@ent-mcp/shared/home"; +import { loadProgressMap, type EnrichRowsFn, type ProgressMap } from "../../media"; +import type { ExpandedLibraryRow, LibraryRow } from "../types"; +import type { ResolvedLibraryReadContext } from "./context"; + +/** + * Builds the library lens `enrichRows` hook (design §Enrich). This is the custom + * enrich path the design mandates over the default `batchLoad` + `enrich`: the + * default re-probes availability live (`getMatchingServersCached`), which would + * defeat the whole denormalized projection AND collapse the row set to one item + * per `(tmdbId, mediaType)` — killing the phase-3 server/quality `json_each` + * fan-out. Instead this reads `availability.servers` and the quality `tags` + * straight off the row's denormalized columns and never re-probes. + * + * It produces exactly one `CompactMediaItem` per input row and NEVER dedups or + * collapses on `(tmdbId, mediaType)`. Az/timeline emit one `LibraryRow` per + * title; the phase-3 server/quality lenses expand `json_each` so the SAME title + * appears once per server / quality section as an {@link ExpandedLibraryRow} + * carrying its `section`. Keeping the mapping dedup-free — and surfacing the + * per-row `section` onto the item — is what lets the FE insert a header on + * `section.id` change down the flat stream (design §Enrich dup rules; §FE). + * + * Typed on `LibraryRow`; the grouped sources pass `ExpandedLibraryRow`s (a + * subtype), so one builder serves all four lenses — the optional `section` is + * read structurally and is simply absent on the flat lenses. + */ +export function buildEnrichRows(ctx: ResolvedLibraryReadContext): EnrichRowsFn { + return async (rows) => { + if (rows.length === 0) return { items: [], partial: false }; + const { metadata, partial: metaPartial } = await loadMetadata(ctx, rows); + const { map: progress, partial: progressPartial } = await loadProgressMap(ctx); + const items = rows.map((row) => toCompactItem(row, metadata[row.id], progress)); + return { items, partial: metaPartial || progressPartial }; + }; +} + +/** + * Batches catalog metadata for the page in one read, keyed by the composite id + * (`candidateId` = `":"`, which equals `LibraryRow.id`). A + * metadata failure degrades to an empty map + `partial: true` rather than + * throwing, so a title with no cached metadata still renders from its + * denormalized columns (matching the design's tolerance of null meta). + */ +async function loadMetadata( + ctx: ResolvedLibraryReadContext, + rows: LibraryRow[], +): Promise<{ metadata: Record; partial: boolean }> { + const keys = rows.map((row) => ({ tmdbId: row.tmdbId, type: row.mediaType })); + try { + return { metadata: await ctx.catalog.getMetadataBatch(keys), partial: false }; + } catch (err) { + ctx.log.warn("[library:enrich] getMetadataBatch failed; enriching from denorm only", err); + return { metadata: {}, partial: true }; + } +} + +/** + * Maps one row to its wire `CompactMediaItem`. Display fields (title, year, + * poster, backdrop, overview, genres) come from catalog metadata when present; + * the availability snapshot and quality `tags` come from the row's denormalized + * columns with NO live re-probe; the within-content `progress` (the resume bar) + * comes from the live continue-watching map. The row-level `watchedState` facet + * is NOT a `CompactMediaItem` field — it drives the filter axis server-side, not + * a card chip. Absent fields are omitted (not null) per the `CompactMediaItem` + * lean-wire convention. + */ +function toCompactItem( + row: LibraryRow & Partial>, + meta: CanonicalMetadata | undefined, + progress: ProgressMap, +): CompactMediaItem { + const item: CompactMediaItem = { + id: row.id, + tmdbId: row.tmdbId, + mediaType: row.mediaType, + title: meta?.title ?? "", + availability: { + hasAnyServerCopy: row.servers.length > 0, + requestEligible: false, + servers: row.servers, + }, + }; + // The quality tiers ride on `tags` so the FE renders a chip per tier; an + // empty array would render an empty chip strip, so it is omitted entirely. + if (row.qualityTiers.length > 0) item.tags = row.qualityTiers; + const resume = progress.get(row.id); + if (resume) item.progress = { watched: resume.watched, total: resume.total }; + // The grouped (server/quality) lenses tag each expanded row with the section + // it belongs to; surfacing it lets the FE insert a header on section change + // and key the list on `id + section.id`. The flat lenses leave it absent. + if (row.section) item.section = row.section; + applyMetadata(item, row, meta); + return item; +} + +/** Folds the catalog metadata display fields onto the item, omitting absent ones. */ +function applyMetadata( + item: CompactMediaItem, + row: LibraryRow, + meta: CanonicalMetadata | undefined, +): void { + const year = meta?.year ?? row.year; + if (year != null) item.year = year; + if (meta?.posterUrl) item.poster = meta.posterUrl; + if (meta?.backdropUrl) item.backdrop = meta.backdropUrl; + if (meta?.overview) item.overview = meta.overview; + const genres = meta?.genres ?? row.genres; + if (genres.length > 0) item.genres = genres.slice(0, 3); +} diff --git a/apps/server/src/library/internal/facets-cache.ts b/apps/server/src/library/internal/facets-cache.ts new file mode 100644 index 00000000..7ad4e897 --- /dev/null +++ b/apps/server/src/library/internal/facets-cache.ts @@ -0,0 +1,39 @@ +import type { LibraryFacetCounts } from "@ent-mcp/shared/library"; +import { MemoryCache } from "../../cache/memory"; + +/** + * Short-TTL per-user cache for the unfiltered facet totals (design §Facets: + * "cache short-TTL, invalidate on sync"). The facet query fans out a handful of + * GROUP BYs, so the FE's repeated reads (the popover re-opens, the rail + * re-renders) ride the cache instead of re-aggregating. It is a module-singleton + * `MemoryCache` mirroring `watchlist/tonight/section.ts` rather than the media + * dispatch cache, because the facets are a same-module concern with no + * cross-process invalidation need: the membership sync busts the entry directly + * (`bustFacets`), no event bus required. + */ +const CACHE_TTL_MS = 60_000; +const CACHE_MAX_ENTRIES = 5000; +const cache = new MemoryCache(CACHE_MAX_ENTRIES); + +function facetsCacheKey(userId: string): string { + return `library:facets:${userId}`; +} + +/** Reads the cached facet totals for a user, or null on a miss/expiry. */ +export async function readFacets(userId: string): Promise { + return cache.get(facetsCacheKey(userId)); +} + +/** Caches a user's freshly computed facet totals for the short TTL. */ +export async function writeFacets(userId: string, facets: LibraryFacetCounts): Promise { + await cache.set(facetsCacheKey(userId), facets, CACHE_TTL_MS); +} + +/** + * Invalidates a user's cached facet totals. Called by the membership sync after + * it writes new owned rows / tombstones so the next `/facets` read recomputes + * against the changed owned set rather than serving a stale snapshot. + */ +export async function bustFacets(userId: string): Promise { + await cache.delete(facetsCacheKey(userId)); +} diff --git a/apps/server/src/library/internal/hydrate.ts b/apps/server/src/library/internal/hydrate.ts new file mode 100644 index 00000000..a9d7eec7 --- /dev/null +++ b/apps/server/src/library/internal/hydrate.ts @@ -0,0 +1,147 @@ +import type { CanonicalMetadata } from "@ent-mcp/shared/catalog"; +import { loadProgressMap, type ProgressMap } from "../../media"; +import { staleOrNew, writeHydration, type HydrateTarget, type HydrationUpdate } from "../repo"; +import type { LibraryContext } from "../types"; +import { normalizeSortTitle } from "./normalize-title"; +import { deriveQualityTiers } from "./quality-tier"; +import { deriveWatchedState } from "./watched-state"; + +/** Default staleness window the membership sync uses when it triggers a hydrate. */ +export const HYDRATE_DEFAULT_STALE_TTL_MS = 6 * 60 * 60 * 1000; + +/** Tuning knobs for one hydrate pass. */ +export interface HydrateOptions { + /** + * A row whose `hydratedAt` is older than this is re-hydrated. The hourly + * availability re-hydrate passes a 1-hour window; the membership sync passes + * the 6-hour default so it only touches genuinely new or long-stale rows. + */ + staleTtlMs?: number; +} + +/** Counts surfaced for run-status visibility. */ +export interface HydrateResult { + /** Rows that needed hydrating (missing or stale projection). */ + considered: number; + /** Rows whose denormalized projection was written this pass. */ + hydrated: number; +} + +/** + * The two batch-loaded sources every row's projection reads from, bundled so + * `buildUpdate` stays within the three-logical-param budget. Per-row server and + * quality probes are NOT batched — they fan out inside `buildUpdate`. + */ +interface BatchSources { + metadata: Record; + progress: ProgressMap; +} + +/** + * Hydrates the denormalized browse projection for a user's new and stale owned + * rows (design §Sync + hydrate, phase 2). For each stale row it folds together + * three independent sources and tolerates any of them being absent — a partial + * hydrate is valid and self-heals on the next pass: + * - catalog metadata (`getMetadataBatch`) → sortTitle, year, genres, franchise, + * - per-key `getMatchingServers` → server chips, + * - per-key `getAvailabilityQuality` → quality tiers (the N-call fan-out), + * - `loadProgressMap` → watchedState. + * + * The availability fan-out is the expensive part the design flags; it is bounded + * by the stale-row set and runs only in background jobs. Returns counts for + * run-status visibility. A fully-fresh library short-circuits to zero work. + */ +export async function hydrate( + ctx: LibraryContext, + opts: HydrateOptions = {}, +): Promise { + const staleTtlMs = opts.staleTtlMs ?? HYDRATE_DEFAULT_STALE_TTL_MS; + const now = Date.now(); + const targets = await staleOrNew(ctx.userId, staleTtlMs, now); + if (targets.length === 0) return { considered: 0, hydrated: 0 }; + const sources: BatchSources = { + metadata: await loadMetadata(ctx, targets), + progress: await loadProgress(ctx), + }; + const updates = await Promise.all(targets.map((target) => buildUpdate(ctx, target, sources))); + const hydrated = await writeHydration(updates, now); + return { considered: targets.length, hydrated }; +} + +/** + * Fetches catalog metadata for every stale row in one batch, keyed by the + * composite id so `buildUpdate` does an O(1) lookup. Tolerates a metadata miss: + * a key absent from the result simply hydrates its metadata-sourced columns to + * their empty/null shape. + */ +async function loadMetadata( + ctx: LibraryContext, + targets: HydrateTarget[], +): Promise> { + const keys = targets.map((target) => ({ tmdbId: target.tmdbId, type: target.mediaType })); + try { + return await ctx.catalog.getMetadataBatch(keys); + } catch (err) { + ctx.log.warn("[library:hydrate] getMetadataBatch failed; hydrating without metadata", err); + return {}; + } +} + +/** + * Loads the continue-watching progress map once per pass (it is itself + * per-request memoized). On a total CW failure it returns an empty map so every + * row hydrates `watchedState` to `null` rather than failing the pass. + */ +async function loadProgress(ctx: LibraryContext): Promise { + const { map } = await loadProgressMap(ctx); + return map; +} + +/** + * Folds the three sources into one row's denormalized projection. The + * availability lookups (`getMatchingServers`, `getAvailabilityQuality`) are the + * only awaits here; metadata and progress are already loaded. Each source is + * null-safe so any single failure degrades that column, not the row. + */ +async function buildUpdate( + ctx: LibraryContext, + target: HydrateTarget, + sources: BatchSources, +): Promise { + const meta = sources.metadata[target.id]; + const { servers, qualityTiers } = await loadAvailability(ctx, target); + return { + id: target.id, + sortTitle: normalizeSortTitle(meta?.title), + year: meta?.year ?? null, + genres: meta?.genres ?? [], + servers, + qualityTiers, + watchedState: deriveWatchedState(sources.progress.get(target.id)), + collectionId: meta?.collectionId ?? null, + collectionName: meta?.collectionName ?? null, + }; +} + +/** + * Resolves a row's server chips and quality tiers from the availability + * providers. The two probes run concurrently; either failing degrades only its + * own column (empty array) so the row still hydrates the rest. + */ +async function loadAvailability( + ctx: LibraryContext, + target: HydrateTarget, +): Promise<{ servers: { id: string; label: string }[]; qualityTiers: string[] }> { + const opts = ctx.deadlineMs != null ? { deadlineMs: ctx.deadlineMs } : {}; + const [servers, copies] = await Promise.all([ + ctx.mediaService.getMatchingServers(target.tmdbId, target.mediaType, opts).catch((err) => { + ctx.log.warn("[library:hydrate] getMatchingServers failed", err); + return [] as { id: string; label: string }[]; + }), + ctx.mediaService.getAvailabilityQuality(target.tmdbId, target.mediaType, opts).catch((err) => { + ctx.log.warn("[library:hydrate] getAvailabilityQuality failed", err); + return []; + }), + ]); + return { servers, qualityTiers: deriveQualityTiers(copies) }; +} diff --git a/apps/server/src/library/internal/media-sources.ts b/apps/server/src/library/internal/media-sources.ts new file mode 100644 index 00000000..57564660 --- /dev/null +++ b/apps/server/src/library/internal/media-sources.ts @@ -0,0 +1,130 @@ +import { libraryLensQuerySchema, type LibraryLensQueryParsed } from "@ent-mcp/shared/library"; +import type { AnyMediaSourceRegistration, MediaSourceRegistration } from "../../media"; +import type { LensFilters } from "../repo"; +import { azSource, type AzParams } from "../sources/az"; +import { timelineSource, type TimelineParams } from "../sources/timeline"; +import { serverSource, type ServerParams } from "../sources/server"; +import { qualitySource, type QualityParams } from "../sources/quality"; +import type { ExpandedLibraryRow, LibraryRow } from "../types"; +import { asLibraryReadContext } from "./context"; +import { buildEnrichRows } from "./enrich"; + +/** + * Surfaces the library item lenses as `MediaSourceRegistration`s so the + * `/api/media/sources/:sourceId` resolver composes them into the one registry + * alongside `homeMediaSources` / `watchlistMediaSources` (design §The 5 lenses: + * "Register in media unified REGISTRY … zero new read-routing"). Mirrors + * `watchlistMediaSources` exactly: every lens is `rateLimit: "read"` (the shared + * read limiter), `cursorMode: "keyset"`, and `cursorOnNull: "firstPage"` (a + * bad/foreign cursor falls back to the first page, never 400 — matching the + * keyset codec's total decode). + * + * Each `build` maps the parsed wire query onto the source's internal params, + * decodes nothing (the source's keyset codec parses the seed payload out of the + * outer cursor the resolver already decoded), and wires the library enrich + * override so the pipeline reads the denormalized columns instead of re-probing + * availability (design §Enrich). + */ +const azRegistration: MediaSourceRegistration = { + sourceId: "library-az", + rateLimit: "read", + paramSchema: libraryLensQuerySchema, + cursorMode: "keyset", + cursorOnNull: "firstPage", + build: (ctx, params, cursor) => ({ + source: azSource, + cfg: { params: toLensParams(params), cursor, limit: params.limit }, + enrichRows: buildEnrichRows(asLibraryReadContext(ctx)), + }), +}; + +const timelineRegistration: MediaSourceRegistration< + LibraryLensQueryParsed, + TimelineParams, + LibraryRow +> = { + sourceId: "library-timeline", + rateLimit: "read", + paramSchema: libraryLensQuerySchema, + cursorMode: "keyset", + cursorOnNull: "firstPage", + build: (ctx, params, cursor) => ({ + source: timelineSource, + cfg: { params: toLensParams(params), cursor, limit: params.limit }, + enrichRows: buildEnrichRows(asLibraryReadContext(ctx)), + }), +}; + +/** + * The Server lens registration (design §The 5 lenses, json_each). Identical + * surface to the flat lenses, but its `Row` is `ExpandedLibraryRow`: the source + * emits one row per `(title, server)` and the SAME `buildEnrichRows` override + * (dedup-free) maps each expanded row to its own `CompactMediaItem`, surfacing + * the server section — so a title repeats across server sections (intended). + */ +const serverRegistration: MediaSourceRegistration< + LibraryLensQueryParsed, + ServerParams, + ExpandedLibraryRow +> = { + sourceId: "library-server", + rateLimit: "read", + paramSchema: libraryLensQuerySchema, + cursorMode: "keyset", + cursorOnNull: "firstPage", + build: (ctx, params, cursor) => ({ + source: serverSource, + cfg: { params: toLensParams(params), cursor, limit: params.limit }, + enrichRows: buildEnrichRows(asLibraryReadContext(ctx)), + }), +}; + +/** + * The Quality lens registration (design §The 5 lenses, json_each). Mirrors the + * Server registration: `ExpandedLibraryRow` rows, the dedup-free enrich, a title + * repeating once per quality tier section (intended). + */ +const qualityRegistration: MediaSourceRegistration< + LibraryLensQueryParsed, + QualityParams, + ExpandedLibraryRow +> = { + sourceId: "library-quality", + rateLimit: "read", + paramSchema: libraryLensQuerySchema, + cursorMode: "keyset", + cursorOnNull: "firstPage", + build: (ctx, params, cursor) => ({ + source: qualitySource, + cfg: { params: toLensParams(params), cursor, limit: params.limit }, + enrichRows: buildEnrichRows(asLibraryReadContext(ctx)), + }), +}; + +/** + * Projects the parsed wire query onto the `{ filters, limit }` shape the lens + * sources read. All four lenses share the param shape, so one mapper serves + * them. An omitted axis stays undefined → the repo applies no filter for it. + */ +function toLensParams(params: LibraryLensQueryParsed): { filters: LensFilters; limit: number } { + const filters: LensFilters = {}; + if (params.kinds) filters.kinds = params.kinds; + if (params.genres) filters.genres = params.genres; + if (params.qualities) filters.qualities = params.qualities; + if (params.servers) filters.servers = params.servers; + if (params.watched) filters.watched = params.watched; + return { filters, limit: params.limit }; +} + +/** + * Registration map keyed by `sourceId`, one per library item lens. The flat + * lenses (az/timeline) emit one row per title; the `json_each` lenses + * (server/quality) expand each title once per section, so the same title can + * appear multiple times in one page (intended — design §Enrich dup rules). + */ +export const libraryMediaSources: Record = { + "library-az": azRegistration, + "library-timeline": timelineRegistration, + "library-server": serverRegistration, + "library-quality": qualityRegistration, +}; diff --git a/apps/server/src/library/internal/normalize-title.ts b/apps/server/src/library/internal/normalize-title.ts new file mode 100644 index 00000000..971da7f5 --- /dev/null +++ b/apps/server/src/library/internal/normalize-title.ts @@ -0,0 +1,28 @@ +// Leading-article prefixes stripped before sorting so "The Matrix" files under +// "M" on the A–Z rail. English-only by design: the lens is TMDB-title driven and +// the rail is A–Z + "#", so this matches the mock's alphabetical intent without +// pulling in a locale-aware collator. +const LEADING_ARTICLE_RE = /^(the|a|an)\s+/; + +// Unicode combining marks (the `U+0300–U+036F` block) left behind after NFD +// decomposition. Stripping them folds "Amélie" → "amelie" so accented titles +// sort beside their ASCII peers. +const COMBINING_MARKS_RE = /[̀-ͯ]/g; + +/** + * Normalizes a display title into the `sort_title` browse key the A–Z lens and + * the letter rail index off. Pure and deterministic so it is unit-testable in + * isolation (Rule 9) and produces a stable keyset across hydrate runs: + * 1. strip a single leading article (`the`/`a`/`an` + whitespace), + * 2. lowercase, + * 3. NFD-decompose and drop combining marks to fold diacritics, + * 4. collapse surrounding whitespace. + * + * Returns `""` for a null/blank input so a row whose metadata has not resolved + * still has a defined sort key (it groups under `#` on the rail). + */ +export function normalizeSortTitle(title: string | null | undefined): string { + if (!title) return ""; + const folded = title.trim().toLowerCase().normalize("NFD").replace(COMBINING_MARKS_RE, ""); + return folded.replace(LEADING_ARTICLE_RE, "").trim(); +} diff --git a/apps/server/src/library/internal/quality-tier.ts b/apps/server/src/library/internal/quality-tier.ts new file mode 100644 index 00000000..4e9cae97 --- /dev/null +++ b/apps/server/src/library/internal/quality-tier.ts @@ -0,0 +1,50 @@ +import type { LibraryItemQuality } from "@ent-mcp/shared/plugins"; + +/** + * Folds a structured plugin `quality` descriptor into the free-form tier label + * the library stores in `qualityTiers` and the Quality lens ranks against + * `QUALITY_TIERS` (design §Known fuzzy areas: quality tier rank). The label is + * the resolution anchor with an HDR modifier appended so the Quality lens can + * separate "4K HDR" from plain "4K": + * - resolution `4k` + a Dolby-Vision/HDR `hdr` flag → `"4K HDR"`, + * - resolution `4k` alone → `"4K"`, `1080p` → `"1080p"`, `720p` → `"720p"`, + * `sd` → `"SD"`, + * - no resolution but an HDR flag → `"HDR"`. + * + * Returns `null` when the descriptor carries no resolution and no HDR signal, + * so a copy the server could not classify contributes no tier rather than a + * bogus one. Pure and deterministic so it is unit-testable in isolation. + */ +export function qualityToTier(quality: LibraryItemQuality): string | null { + const hasHdr = quality.hdr != null && quality.hdr !== "none"; + switch (quality.resolution) { + case "4k": + return hasHdr ? "4K HDR" : "4K"; + case "1080p": + return "1080p"; + case "720p": + return "720p"; + case "sd": + return "SD"; + default: + return hasHdr ? "HDR" : null; + } +} + +/** + * Maps every copy's quality into its tier label and de-duplicates, preserving + * first-seen order. A title with both a 4K HDR and a 1080p copy yields + * `["4K HDR", "1080p"]` — the Quality lens later expands that one row into a + * section per tier via `json_each`. + */ +export function deriveQualityTiers(copies: LibraryItemQuality[]): string[] { + const seen = new Set(); + const tiers: string[] = []; + for (const copy of copies) { + const tier = qualityToTier(copy); + if (tier == null || seen.has(tier)) continue; + seen.add(tier); + tiers.push(tier); + } + return tiers; +} diff --git a/apps/server/src/library/internal/rank-quality.ts b/apps/server/src/library/internal/rank-quality.ts new file mode 100644 index 00000000..4d25edaa --- /dev/null +++ b/apps/server/src/library/internal/rank-quality.ts @@ -0,0 +1,36 @@ +import { QUALITY_TIERS } from "@ent-mcp/shared/library"; + +/** + * The deterministic rank for an unknown quality label: every label not in + * `QUALITY_TIERS` ranks BELOW every listed tier (design §Known fuzzy areas: + * quality tier rank). `QUALITY_TIERS` is a fixed, short tuple, so its length is + * always a strictly larger ordinal than any in-tuple index — a stable sentinel + * that needs no magic number. + */ +const UNRANKED = QUALITY_TIERS.length; + +/** + * Maps a free-form quality label to its fidelity ordinal: the index in the + * hi→lo `QUALITY_TIERS` tuple (0 = highest fidelity), or `UNRANKED` for any + * label the tuple does not list. The Quality lens sorts these ASCENDING so a + * smaller ordinal (higher fidelity) comes first; an unknown label sinks to the + * bottom. Pure and deterministic so it is unit-testable in isolation, and so it + * agrees value-for-value with the SQL CASE {@link qualityRankCase} builds — the + * two MUST stay in lockstep or the cursor predicate and the `ORDER BY` would + * disagree at a page boundary. + */ +export function rankQualityTier(label: string): number { + const index = QUALITY_TIERS.indexOf(label as (typeof QUALITY_TIERS)[number]); + return index === -1 ? UNRANKED : index; +} + +/** + * The bottom-rank sentinel ordinal for any label outside `QUALITY_TIERS`, + * exported so the repo's SQL `CASE` can use the SAME `ELSE` value as + * {@link rankQualityTier}'s fallback. The repo builds the `CASE` from + * `QUALITY_TIERS` (one arm per tier, this value in the `ELSE`) so the SQL rank + * and the TS rank agree value-for-value — the cursor predicate and the + * `ORDER BY` must share an identical rank expression or rows drop/duplicate at + * the page boundary (phase-2 lesson). + */ +export const QUALITY_RANK_UNRANKED = UNRANKED; diff --git a/apps/server/src/library/internal/reads.ts b/apps/server/src/library/internal/reads.ts new file mode 100644 index 00000000..ee271f90 --- /dev/null +++ b/apps/server/src/library/internal/reads.ts @@ -0,0 +1,40 @@ +import { clearSeedLock, trySeedLock } from "../repo"; +import { syncMembership } from "../service"; +import type { MaybeLibraryContext } from "../types"; +import { asLibraryContext } from "./context"; + +/** + * Eager-seed trigger run on the first page of a library read (the lens sources' + * `fetchRawSet` and `/facets`), mirroring `watchlist/internal/reads.ts` (whose + * `getItems` seeds on its first page). A brand-new user has no `library_items` + * rows until the 6-hourly cron runs, so the first read would otherwise show an + * empty library; this seeds membership inline so the page is populated on first + * paint. + * + * `trySeedLock` claims the per-user seed marker atomically — only the caller + * that wins the race runs the membership fetch, so concurrent first reads do + * not double-fetch. On success the lock stays so later reads skip seeding (the + * 6-hourly cron owns ongoing reconciliation). On a feed error the lock is + * rolled back so the next read retries. Hydration is deliberately NOT awaited + * here — it stays lazy/async (the hourly job and the post-sync hydrate fill the + * denormalized columns), so the first paint may show un-hydrated rows (no + * servers/quality/franchise). That is acceptable per design §Known fuzzy areas. + * + * The membership sync swallows feed errors internally and busts the facets + * cache on success, so this returns void — a seed failure must never fail the + * read it rode in on. + */ +export async function ensureSeeded(ctx: MaybeLibraryContext): Promise { + const c = asLibraryContext(ctx); + const acquired = await trySeedLock(c.userId, Date.now()); + if (!acquired) return; + try { + await syncMembership(c); + } catch (err) { + // `syncMembership` already swallows feed errors, so reaching here means an + // unexpected throw (e.g. a DB write failure). Roll the lock back so the next + // read retries the seed rather than treating the user as permanently seeded. + c.log.warn("[library:seed] eager seed failed; clearing lock for retry", err); + await clearSeedLock(c.userId); + } +} diff --git a/apps/server/src/library/internal/watched-state.ts b/apps/server/src/library/internal/watched-state.ts new file mode 100644 index 00000000..0d6c3ca8 --- /dev/null +++ b/apps/server/src/library/internal/watched-state.ts @@ -0,0 +1,25 @@ +import type { WatchedState } from "@ent-mcp/shared/library"; +import type { ProgressEntry } from "../../media"; + +/** + * Derives a row's `watchedState` facet from its resume position. The signal is + * the continue-watching feed projected by `loadProgressMap`, which keys a + * `{ watched, total }` entry only for titles with *active, unfinished* progress: + * - an entry with progress strictly between 0 and `total` → `"partial"`, + * - an entry at or past `total` → `"watched"` (defensive; the CW projection + * already drops finished titles, so this rarely fires), + * - an entry at zero watched → `"unwatched"`. + * + * Returns `null` when no entry exists for the row. Absence from the CW feed + * means the title is either fully watched or never started — the feed cannot + * distinguish the two — so `null` ("unknown") is the honest projection rather + * than guessing `"unwatched"` and mislabelling a finished title. The facet and + * the `watched` filter axis treat `null` as its own bucket. Pure and + * deterministic so it is unit-testable in isolation (Rule 9). + */ +export function deriveWatchedState(progress: ProgressEntry | undefined): WatchedState | null { + if (!progress) return null; + if (progress.total > 0 && progress.watched >= progress.total) return "watched"; + if (progress.watched > 0) return "partial"; + return "unwatched"; +} diff --git a/apps/server/src/library/jobs/hydrate-library.ts b/apps/server/src/library/jobs/hydrate-library.ts new file mode 100644 index 00000000..b5e02f6b --- /dev/null +++ b/apps/server/src/library/jobs/hydrate-library.ts @@ -0,0 +1,58 @@ +import { consola } from "consola"; +import { getCatalogService } from "../../catalog"; +import { MediaService } from "../../media"; +import { registerScheduledPerRow } from "../../jobs/scheduled-per-row"; +import { listSeededUserIds } from "../repo"; +import { hydrateLibrary } from "../service"; + +export const LIBRARY_HYDRATE_JOB_ID = "library.hydrate"; + +const RUN_TIMEOUT_SEC = 30 * 60; +const PER_ROW_TIMEOUT_SEC = 60; + +// Availability moves faster than membership (a copy is added or re-encoded +// between the 6-hourly syncs), so the dedicated hydrate pass uses a 1-hour +// staleness window — every row older than an hour is re-projected. +const HYDRATE_STALE_TTL_MS = 60 * 60 * 1000; + +interface SeededUserRow { + userId: string; +} + +/** + * Registers the hourly per-row job that re-hydrates each seeded user's stale + * browse projection (design §Sync + hydrate: "availability re-hydrate hourly"). + * Distinct from the 6-hourly `library.sync` job, which reconciles membership and + * hydrates only freshly inserted rows: this pass exists because availability + * (server presence, quality copies) goes stale faster than membership, and its + * `checkAvailability` fan-out is the design's flagged N-call cost — acceptable in + * a background job, never on a read path. Iterates exactly the seeded users so a + * fresh install fans out to nobody; row failures do not block the run. The + * per-row timeout is wider than the sync job's because of that fan-out. Mirrors + * `jobs/sync-library.ts`. + */ +export function registerHydrateLibraryJob(): void { + registerScheduledPerRow({ + id: LIBRARY_HYDRATE_JOB_ID, + name: "Library availability hydrate", + description: "Re-hydrates stale library availability and quality for each seeded user.", + schedule: "0 * * * *", + perRowTimeoutSec: PER_ROW_TIMEOUT_SEC, + runTimeoutSec: RUN_TIMEOUT_SEC, + adminTriggerable: true, + continueOnRowError: true, + rowSource: () => listSeededUserIds(), + handler: async (_ctx, row) => { + const mediaService = new MediaService(row.userId); + await hydrateLibrary( + { + userId: row.userId, + mediaService, + catalog: getCatalogService(), + log: consola, + }, + { staleTtlMs: HYDRATE_STALE_TTL_MS }, + ); + }, + }); +} diff --git a/apps/server/src/library/jobs/index.ts b/apps/server/src/library/jobs/index.ts new file mode 100644 index 00000000..2b178899 --- /dev/null +++ b/apps/server/src/library/jobs/index.ts @@ -0,0 +1,11 @@ +import { registerHydrateLibraryJob } from "./hydrate-library"; +import { registerSyncLibraryJob } from "./sync-library"; + +export { LIBRARY_SYNC_JOB_ID } from "./sync-library"; +export { LIBRARY_HYDRATE_JOB_ID } from "./hydrate-library"; + +/** Registers every library background job. Called once from the server bootstrap. */ +export function registerJobs(): void { + registerSyncLibraryJob(); + registerHydrateLibraryJob(); +} diff --git a/apps/server/src/library/jobs/sync-library.ts b/apps/server/src/library/jobs/sync-library.ts new file mode 100644 index 00000000..8c113d51 --- /dev/null +++ b/apps/server/src/library/jobs/sync-library.ts @@ -0,0 +1,53 @@ +import { consola } from "consola"; +import { getCatalogService } from "../../catalog"; +import { MediaService } from "../../media"; +import { registerScheduledPerRow } from "../../jobs/scheduled-per-row"; +import { listSeededUserIds } from "../repo"; +import { hydrateLibrary, syncLibrary } from "../service"; + +export const LIBRARY_SYNC_JOB_ID = "library.sync"; + +const RUN_TIMEOUT_SEC = 30 * 60; +const PER_ROW_TIMEOUT_SEC = 30; + +interface SeededUserRow { + userId: string; +} + +/** + * Registers the 6-hourly per-row job that re-syncs owned-library membership + * for every previously-seeded user (design §Sync + hydrate). Iterates exactly + * the seeded users so a fresh install fans out to nobody. After reconciling + * membership it hydrates the user's new and stale rows so freshly inserted + * titles get their browse projection without waiting for the hourly hydrate + * pass. Row failures do not block the run — the run-status aggregate captures + * partials so admins can inspect them. Mirrors + * `watchlist/jobs/sync-plugin-watchlist.ts`. + */ +export function registerSyncLibraryJob(): void { + registerScheduledPerRow({ + id: LIBRARY_SYNC_JOB_ID, + name: "Library membership sync", + description: "Re-syncs owned-library membership from the collection feed for each seeded user.", + schedule: "0 */6 * * *", + perRowTimeoutSec: PER_ROW_TIMEOUT_SEC, + runTimeoutSec: RUN_TIMEOUT_SEC, + adminTriggerable: true, + continueOnRowError: true, + rowSource: () => listSeededUserIds(), + handler: async (_ctx, row) => { + const mediaService = new MediaService(row.userId); + const ctx = { + userId: row.userId, + mediaService, + catalog: getCatalogService(), + log: consola, + }; + await syncLibrary(ctx); + // Hydrate new and long-stale rows right after membership reconciles so a + // freshly owned title gets its browse projection on this same run. The + // hourly `library.hydrate` job handles the faster availability staleness. + await hydrateLibrary(ctx); + }, + }); +} diff --git a/apps/server/src/library/repo/collections.ts b/apps/server/src/library/repo/collections.ts new file mode 100644 index 00000000..f976c2ee --- /dev/null +++ b/apps/server/src/library/repo/collections.ts @@ -0,0 +1,269 @@ +import { and, asc, eq, gt, isNotNull, or, sql, type SQL } from "drizzle-orm"; +import { getDb, type Db } from "../../db/client"; +import { libraryItems } from "../../db/schema/library"; +import type { LibraryRow } from "../types"; +import { ownedFilterConditions, ROW_COLUMNS, type LensFilters } from "./lens-pages"; + +/** The maximum preview ids gathered per franchise group (design §Collections lens: "preview ≤4"). */ +const PREVIEW_LIMIT = 4; + +/** + * The delimiter the per-group preview `group_concat` joins ids on, then the + * caller splits back out. Safe because the only values concatenated are composite + * library ids (`":"`) whose grammar is comma-free; if the id + * format ever admits a comma this split would mis-parse, so the invariant is + * load-bearing. + */ +const PREVIEW_SEP = ","; + +/** + * The keyset ordering key for a franchise group: the display title, falling back + * to the stable collection id when a group has no learned title yet. The cursor + * encodes this SAME coalesced value (`CollectionGroup.collectionName` is already + * `collection_name ?? collection_id`), so the `ORDER BY` and the cursor predicate + * MUST both compare `COALESCE(collection_name, collection_id)` — comparing the + * raw nullable column would disagree with the encoded cursor and silently drop + * every null-name group at a page boundary (the phase-2 timeline COALESCE lesson + * applied to the group key). A collection's rows all share one franchise title, + * so the value is stable per group. + */ +const collectionSortKey = sql`COALESCE(${libraryItems.collectionName}, ${libraryItems.collectionId})`; + +/** + * Keyset resume position for the Collections lens: the last returned group's + * `(collectionName, collectionId)`. `collectionName` is the human title the + * groups order by; `collectionId` is the stable tie-break. The cursor encodes + * the LAST RETURNED group (never the dropped `limit + 1` overflow group) and its + * predicate uses the SAME `(collection_name, collection_id)` ordering the + * `ORDER BY` uses, so a page boundary neither drops nor duplicates a franchise + * (phase-2 keyset lessons applied to the group-first read). + */ +export interface CollectionCursor { + collectionName: string; + collectionId: string; +} + +/** One franchise group plus its preview ids, before the service enriches them. */ +export interface CollectionGroup { + collectionId: string; + /** The franchise display title. Never null: the GROUP BY only sees non-null collection ids. */ + collectionName: string; + /** Total owned titles in the franchise (may exceed `previewIds.length`). */ + count: number; + /** Up to {@link PREVIEW_LIMIT} owned-title ids, ordered by `(sortTitle, id)`. */ + previewIds: string[]; +} + +/** One page of franchise groups plus the next-page marker (the last returned group). */ +export interface CollectionsPage { + groups: CollectionGroup[]; + /** The last group when the page was full, so the service mints the next cursor; absent when exhausted. */ + nextGroup?: CollectionGroup; +} + +/** + * The raw shape the grouping query returns before the preview-id string is + * split. `collectionId` is typed nullable to match the column's select + * inference, but the `collection_id IS NOT NULL` WHERE guarantees it is non-null + * at runtime (narrowed in {@link toCollectionGroup}). + */ +interface CollectionGroupRow { + collectionId: string | null; + collectionName: string | null; + count: number; + previewIds: string | null; +} + +/** + * Pages the Collections lens group-first over the user's owned franchises + * (design §Collections lens). Groups the owned set by `collection_id`, ordered + * by `(collection_name, collection_id)` keyset, returning each franchise's owned + * title count and up to four preview ids for the poster fan. Owned-only by + * construction: the WHERE scopes to `owned = true` and `collection_id IS NOT + * NULL`, so standalone titles and TV (both null `collection_id`) are excluded + * and a franchise surfaces only when it has at least one owned movie. The same + * filter axes the item lenses use narrow the grouped set. Selects `limit + 1` + * groups so the caller detects a next page without a count query; the overflow + * group is dropped and surfaced as `nextGroup`. + */ +export async function selectCollections( + userId: string, + filters: LensFilters, + cursor: CollectionCursor | undefined, + limit: number, + db: Db = getDb(), +): Promise { + const where = and( + ...ownedFilterConditions(userId, filters), + isNotNull(libraryItems.collectionId), + ...collectionCursorCondition(cursor), + ); + const rows = await db + .select({ + collectionId: libraryItems.collectionId, + collectionName: libraryItems.collectionName, + count: sql`count(*)`.as("count"), + previewIds: previewIdsExpr(userId, filters), + }) + .from(libraryItems) + .where(where) + // Order by the SAME coalesced key the cursor predicate compares so the sort + // and the keyset agree on the group boundary, tie-broken by the stable + // (non-null here) collection id. + .groupBy(libraryItems.collectionId) + .orderBy(collectionSortKey, asc(libraryItems.collectionId)) + .limit(limit + 1); + return toCollectionsPage(rows.map(toCollectionGroup), limit); +} + +/** + * Fetches the full browse rows for a set of preview ids in one indexed read so + * the service can enrich them into `CompactMediaItem`s without re-probing. Rows + * come back in arbitrary order; the service re-orders each group's preview to + * the id order `selectCollections` chose (by `(sortTitle, id)`). Scoped to the + * requesting user's owned rows: the composite id is global (not per-user), so an + * unscoped `id IN (…)` read would be a cross-tenant leak — every library read is + * owned-set scoped (design §Architecture). + */ +export async function selectRowsByIds( + userId: string, + ids: string[], + db: Db = getDb(), +): Promise { + if (ids.length === 0) return []; + return db + .select(ROW_COLUMNS) + .from(libraryItems) + .where(and(eq(libraryItems.userId, userId), eq(libraryItems.owned, true), idInList(ids))); +} + +/** + * The per-group preview-id aggregate: a correlated subquery that takes the first + * {@link PREVIEW_LIMIT} owned ids of the franchise ordered by `(sort_title, id)` + * and joins them on {@link PREVIEW_SEP}. SQLite's `group_concat` cannot itself + * order-and-limit per group, so the ordered+limited id set is selected in an + * inner subquery and concatenated in the outer one. The preview ordering is + * documented as `(sortTitle, id)` ascending — the same order the A–Z lens uses — + * so the poster fan is stable run-to-run. The `user_id` is bound (not the outer + * row's) so the subquery uses the `(user_id, owned, collection_id)` index. + */ +function previewIdsExpr(userId: string, filters: LensFilters): SQL { + const extra = innerFilterConditions(filters); + const filterClause = extra.length > 0 ? sql` AND ${sql.join(extra, sql` AND `)}` : sql``; + return sql`( + SELECT group_concat(p.id, ${PREVIEW_SEP}) + FROM ( + SELECT inner_li.id AS id + FROM ${libraryItems} inner_li + WHERE inner_li.user_id = ${userId} + AND inner_li.owned = 1 + AND inner_li.collection_id = ${libraryItems.collectionId}${filterClause} + ORDER BY inner_li.sort_title, inner_li.id + LIMIT ${PREVIEW_LIMIT} + ) p + )`; +} + +/** + * The active filter axes re-expressed against the `inner_li` preview subquery + * alias so the poster fan honours the SAME filters as the group count — without + * this the count is filter-aware but the preview can surface titles excluded + * from the count. Mirrors the lens-page filter predicates (kinds/watched as + * column membership, genres/qualities/servers as `json_each` membership); every + * value stays a bound parameter via {@link sqlInList}. + */ +function innerFilterConditions(filters: LensFilters): SQL[] { + const conditions: SQL[] = []; + if (filters.kinds && filters.kinds.length > 0) { + conditions.push(sql`inner_li.media_type IN ${sqlInList(filters.kinds)}`); + } + if (filters.watched && filters.watched.length > 0) { + conditions.push(sql`inner_li.watched_state IN ${sqlInList(filters.watched)}`); + } + if (filters.genres && filters.genres.length > 0) { + conditions.push( + sql`EXISTS (SELECT 1 FROM json_each(inner_li.genres) WHERE value IN ${sqlInList(filters.genres)})`, + ); + } + if (filters.qualities && filters.qualities.length > 0) { + conditions.push( + sql`EXISTS (SELECT 1 FROM json_each(inner_li.quality_tiers) WHERE value IN ${sqlInList(filters.qualities)})`, + ); + } + if (filters.servers && filters.servers.length > 0) { + // Match on the human-readable `label`, not the connection `id`: the facets + // repo keys the `servers` count map on `label` and the FE popover sends that + // label back as `filters.servers`, so the preview filter must agree with the + // facet key and the lens-page filter on the label. + conditions.push( + sql`EXISTS (SELECT 1 FROM json_each(inner_li.servers) WHERE value ->> 'label' IN ${sqlInList(filters.servers)})`, + ); + } + return conditions; +} + +/** Renders `values` as a parenthesized `IN` list of bound parameters (no interpolation). */ +function sqlInList(values: string[]): SQL { + return sql`(${sql.join( + values.map((value) => sql`${value}`), + sql`, `, + )})`; +} + +/** + * Keyset predicate for the Collections lens: groups strictly after + * `(collectionName, collectionId)` in ascending order. A larger name, or the + * same name with a larger id, is "after". The comparison columns match the + * `ORDER BY` exactly so a page boundary is stable. + */ +function collectionCursorCondition(cursor: CollectionCursor | undefined): SQL[] { + if (!cursor) return []; + return [ + or( + sql`${collectionSortKey} > ${cursor.collectionName}`, + and( + sql`${collectionSortKey} = ${cursor.collectionName}`, + gt(libraryItems.collectionId, cursor.collectionId), + ), + )!, + ]; +} + +/** Renders `ids` as a `library_items.id IN (?, ?, …)` predicate of bound parameters. */ +function idInList(ids: string[]): SQL { + const list = sql.join( + ids.map((id) => sql`${id}`), + sql`, `, + ); + return sql`${libraryItems.id} IN (${list})`; +} + +/** Maps a grouping query row onto a {@link CollectionGroup}, splitting the joined preview ids. */ +function toCollectionGroup(row: CollectionGroupRow): CollectionGroup { + // Non-null at runtime: the grouping query filters `collection_id IS NOT NULL`. + const collectionId = row.collectionId!; + return { + collectionId, + // A non-null `collection_id` may still carry a null name if hydrate never + // learned the franchise title; fall back to the id so the group still has a + // stable, non-empty title rather than rendering blank. + collectionName: row.collectionName ?? collectionId, + count: row.count, + previewIds: row.previewIds ? row.previewIds.split(PREVIEW_SEP) : [], + }; +} + +/** + * Splits the `limit + 1` over-fetch into the page groups plus the next-page + * marker. When the query returned more than `limit` groups there is another + * page, so the trailing group is dropped from the page and returned as + * `nextGroup` for the service to encode into the keyset cursor — the LAST + * RETURNED group, never the dropped overflow group (phase-2 lesson: the keyset + * predicate is strictly-greater, so encoding the overflow would skip it). A + * short read means the scan is exhausted and no `nextGroup` is emitted. + */ +function toCollectionsPage(groups: CollectionGroup[], limit: number): CollectionsPage { + if (groups.length <= limit) return { groups }; + const page = groups.slice(0, limit); + return { groups: page, nextGroup: page[page.length - 1]! }; +} diff --git a/apps/server/src/library/repo/facets.ts b/apps/server/src/library/repo/facets.ts new file mode 100644 index 00000000..75e2c294 --- /dev/null +++ b/apps/server/src/library/repo/facets.ts @@ -0,0 +1,146 @@ +import { and, eq, sql, type Column, type SQL } from "drizzle-orm"; +import type { MediaType } from "@ent-mcp/shared/media"; +import type { LibraryFacetCounts, WatchedState } from "@ent-mcp/shared/library"; +import { getDb, type Db } from "../../db/client"; +import { libraryItems } from "../../db/schema/library"; + +/** One `value → count` aggregation row returned by a GROUP BY. */ +interface CountRow { + value: string | null; + count: number; +} + +/** + * Computes the unfiltered facet totals for a user's owned library in a handful + * of indexed aggregations (design §Facets). Counts are whole-library totals, + * NOT filter-aware, matching the mock look — the filter axes never narrow the + * facet query. Every aggregation is scoped to `owned = true` so tombstones never + * inflate a count. The multi-valued `genres`/`qualities`/`servers` axes expand + * each row via `json_each`, so a title on two servers contributes to both server + * buckets (and to one `kinds`/`watched` bucket). + */ +export async function selectFacets(userId: string, db: Db = getDb()): Promise { + const [kinds, genres, qualities, servers, watched, letters, decades] = await Promise.all([ + countByColumn(db, userId, sql`${libraryItems.mediaType}`), + countByJsonValue(db, userId, libraryItems.genres), + countByJsonValue(db, userId, libraryItems.qualityTiers), + countByServerLabel(db, userId), + countByColumn(db, userId, sql`${libraryItems.watchedState}`), + selectLetters(db, userId), + selectDecades(db, userId), + ]); + return { + kinds: rowsToMap(kinds) as Record, + genres: rowsToMap(genres), + qualities: rowsToMap(qualities), + servers: rowsToMap(servers), + watched: rowsToMap(watched) as Record, + letters, + decades, + }; +} + +/** `GROUP BY ` over the owned set, dropping null buckets (e.g. unset `watched_state`). */ +async function countByColumn(db: Db, userId: string, expr: SQL): Promise { + return db + .select({ value: sql`${expr}`.as("value"), count: sql`count(*)`.as("count") }) + .from(libraryItems) + .where(and(eq(libraryItems.userId, userId), eq(libraryItems.owned, true))) + .groupBy(expr); +} + +/** + * `GROUP BY value` over a JSON string-array column expanded with `json_each`, + * scoped to the owned set. Used for `genres` and `quality_tiers`: a row with two + * genres contributes one count to each genre bucket. + */ +async function countByJsonValue(db: Db, userId: string, column: Column): Promise { + // `count(DISTINCT id)` (not `count(*)`): a row whose JSON array repeats a + // value (e.g. dirty metadata returning ["Drama","Drama"]) must count once + // for that bucket, since a facet is a title count, not an array-element count. + // The `id` reference is table-qualified because the `json_each` virtual table + // in the FROM clause also exposes an `id` column, so a bare `id` is ambiguous. + return db + .select({ + value: sql`je.value`.as("value"), + count: sql`count(DISTINCT ${libraryItems}."id")`.as("count"), + }) + .from(sql`${libraryItems}, json_each(${column}) je`) + .where(and(eq(libraryItems.userId, userId), eq(libraryItems.owned, true))) + .groupBy(sql`je.value`); +} + +/** + * `GROUP BY` over the `servers` JSON column, whose elements are `{ id, label }` + * objects. The facet keys on the human-readable `label` so the popover badge + * reads "Plex (12)" rather than an opaque connection id; a title present on two + * servers contributes to both server buckets. + */ +async function countByServerLabel(db: Db, userId: string): Promise { + return db + .select({ + value: sql`je.value ->> 'label'`.as("value"), + // Table-qualified `id`: the `json_each` virtual table also exposes an `id` + // column, so a bare `id` here is ambiguous to SQLite. + count: sql`count(DISTINCT ${libraryItems}."id")`.as("count"), + }) + .from(sql`${libraryItems}, json_each(${libraryItems.servers}) je`) + .where(and(eq(libraryItems.userId, userId), eq(libraryItems.owned, true))) + .groupBy(sql`je.value ->> 'label'`); +} + +/** + * The distinct first characters present on the A–Z rail, uppercased, with every + * non-alphabetic leading character (a digit, a symbol, or an empty `sort_title`) + * folded to `"#"`. Present-only: a letter with no owned title is omitted so the + * rail renders only navigable anchors. Sorted so `"#"` trails the letters. + */ +async function selectLetters(db: Db, userId: string): Promise { + // `substr(sort_title, 1, 1)` is empty-safe (returns "" for a blank title), + // which the `#` fold below maps to the catch-all bucket. + const rows = await db + .selectDistinct({ first: sql`substr(${libraryItems.sortTitle}, 1, 1)`.as("first") }) + .from(libraryItems) + .where(and(eq(libraryItems.userId, userId), eq(libraryItems.owned, true))); + const letters = new Set(); + for (const { first } of rows) { + const upper = first.toUpperCase(); + letters.add(/^[A-Z]$/u.test(upper) ? upper : "#"); + } + return [...letters].sort((a, b) => { + if (a === "#") return 1; + if (b === "#") return -1; + return a.localeCompare(b); + }); +} + +/** + * The distinct decades present on the timeline rail, newest first (e.g. + * `[2020, 2010]`). Present-only and derived as `floor(year / 10) * 10`; rows + * with a null `year` contribute no decade. + */ +async function selectDecades(db: Db, userId: string): Promise { + const rows = await db + .selectDistinct({ + decade: sql`(${libraryItems.year} / 10) * 10`.as("decade"), + }) + .from(libraryItems) + .where( + and( + eq(libraryItems.userId, userId), + eq(libraryItems.owned, true), + sql`${libraryItems.year} IS NOT NULL`, + ), + ); + return rows.map((row) => row.decade).sort((a, b) => b - a); +} + +/** Collapses count rows into a `value → count` map, dropping null/empty buckets. */ +function rowsToMap(rows: CountRow[]): Record { + const out: Record = {}; + for (const { value, count } of rows) { + if (value == null || value === "") continue; + out[value] = count; + } + return out; +} diff --git a/apps/server/src/library/repo/hydrate.ts b/apps/server/src/library/repo/hydrate.ts new file mode 100644 index 00000000..441de40f --- /dev/null +++ b/apps/server/src/library/repo/hydrate.ts @@ -0,0 +1,103 @@ +import { and, eq, isNull, lt, or } from "drizzle-orm"; +import type { MediaType } from "@ent-mcp/shared/media"; +import type { WatchedState } from "@ent-mcp/shared/library"; +import { getDb, type Db } from "../../db/client"; +import { libraryItems } from "../../db/schema/library"; + +/** + * An owned row that needs hydrating, carrying just the identity the orchestrator + * fans out on. The denormalized columns are deliberately omitted — the hydrate + * pass overwrites them wholesale, so reading the stale values back would only + * waste a column scan. + */ +export interface HydrateTarget { + id: string; + tmdbId: string; + mediaType: MediaType; +} + +/** + * The denormalized columns one hydrate pass computes for a single row. Every + * field is overwritten wholesale: a column the source could not resolve falls + * back to its empty/null shape rather than being left at a prior value, so a + * title that lost its last server copy correctly hydrates to an empty + * `servers`. `hydratedAt` is stamped by `writeHydration`, not the caller. + */ +export interface HydrationUpdate { + id: string; + sortTitle: string; + year: number | null; + genres: string[]; + servers: { id: string; label: string }[]; + qualityTiers: string[]; + watchedState: WatchedState | null; + collectionId: string | null; + collectionName: string | null; +} + +/** + * Returns the owned rows for `userId` whose denormalized projection is missing + * (`hydrated_at IS NULL`) or stale (`hydrated_at < now - staleTtlMs`). The + * caller fans availability/metadata/progress lookups out over exactly this set, + * so a fully-fresh library returns an empty array and costs only one indexed + * read (design §Sync + hydrate, phase 2). Tombstoned rows (`owned = false`) are + * excluded — only the live owned set is browsable, so spending a fan-out on a + * tombstone would be wasted work. + */ +export async function staleOrNew( + userId: string, + staleTtlMs: number, + now: number, + db: Db = getDb(), +): Promise { + const staleBefore = now - staleTtlMs; + return db + .select({ + id: libraryItems.id, + tmdbId: libraryItems.tmdbId, + mediaType: libraryItems.mediaType, + }) + .from(libraryItems) + .where( + and( + eq(libraryItems.userId, userId), + eq(libraryItems.owned, true), + or(isNull(libraryItems.hydratedAt), lt(libraryItems.hydratedAt, staleBefore)), + ), + ); +} + +/** + * Writes the denormalized projection for each hydrated row, stamping + * `hydrated_at = now` so a later `staleOrNew` skips it until the TTL elapses. + * One `UPDATE` per row keyed by the composite primary key; the set is bounded by + * the page of rows `staleOrNew` returned, so this stays O(stale rows). Rows are + * updated in id order so a partial failure leaves a deterministic prefix + * hydrated rather than an arbitrary scatter. + */ +export async function writeHydration( + updates: HydrationUpdate[], + now: number, + db: Db = getDb(), +): Promise { + if (updates.length === 0) return 0; + let written = 0; + for (const update of updates) { + await db + .update(libraryItems) + .set({ + sortTitle: update.sortTitle, + year: update.year, + genres: update.genres, + servers: update.servers, + qualityTiers: update.qualityTiers, + watchedState: update.watchedState, + collectionId: update.collectionId, + collectionName: update.collectionName, + hydratedAt: now, + }) + .where(eq(libraryItems.id, update.id)); + written += 1; + } + return written; +} diff --git a/apps/server/src/library/repo/index.ts b/apps/server/src/library/repo/index.ts new file mode 100644 index 00000000..d0ac2c1e --- /dev/null +++ b/apps/server/src/library/repo/index.ts @@ -0,0 +1,38 @@ +import { getDb, type Db } from "../../db/client"; +import { libraryItems, userLibrarySeed } from "../../db/schema/library"; + +/** + * Repo barrel for `library/`. Drizzle lives only under `repo/` (one file per + * concern) per the `backend-feature-architecture` skill (R2); `service.ts`, + * `internal/`, `sources/`, and `jobs/` import these functions and never reach + * for `drizzle-orm` directly. The path `"../repo"` stays stable for callers as + * the file was promoted to a directory. + */ +export { upsertOwned, tombstoneMissing, allKnownKeys, type OwnedRowInput } from "./membership"; +export { trySeedLock, clearSeedLock, listSeededUserIds } from "./seed"; +export { staleOrNew, writeHydration, type HydrateTarget, type HydrationUpdate } from "./hydrate"; +export { + selectAzPage, + selectTimelinePage, + selectServerPage, + selectQualityPage, + type LensFilters, + type AzCursor, + type TimelineCursor, + type ServerCursor, + type QualityCursor, +} from "./lens-pages"; +export { selectFacets } from "./facets"; +export { + selectCollections, + selectRowsByIds, + type CollectionCursor, + type CollectionGroup, + type CollectionsPage, +} from "./collections"; + +/** Test-only: drop all library projection + seed data. */ +export async function __resetLibraryForTests(db: Db = getDb()): Promise { + await db.delete(libraryItems); + await db.delete(userLibrarySeed); +} diff --git a/apps/server/src/library/repo/lens-pages.ts b/apps/server/src/library/repo/lens-pages.ts new file mode 100644 index 00000000..b1a5231e --- /dev/null +++ b/apps/server/src/library/repo/lens-pages.ts @@ -0,0 +1,456 @@ +import { and, asc, eq, gt, inArray, or, sql, type Column, type SQL } from "drizzle-orm"; +import type { MediaType } from "@ent-mcp/shared/media"; +import { QUALITY_TIERS, type WatchedState } from "@ent-mcp/shared/library"; +import { QUALITY_RANK_UNRANKED } from "../internal/rank-quality"; +import { getDb, type Db } from "../../db/client"; +import { libraryItems } from "../../db/schema/library"; +import type { ExpandedLibraryRow, LibraryRow } from "../types"; + +/** + * The filter axes the lens pages and facets share, parsed off + * `libraryLensQuerySchema`. Every axis is optional: an omitted or empty axis + * applies no filter (design §Shared pkg: "Empty axis → no filter"). `kinds` + * filters the `media_type` column directly; `genres`/`qualities`/`servers` + * match the multi-valued JSON columns via `json_each` membership; `watched` + * filters the `watched_state` column. + */ +export interface LensFilters { + kinds?: MediaType[]; + genres?: string[]; + qualities?: string[]; + servers?: string[]; + watched?: WatchedState[]; +} + +/** Keyset resume position for the A–Z lens: the last page's `(sortTitle, id)`. */ +export interface AzCursor { + sortTitle: string; + id: string; +} + +/** Keyset resume position for the Timeline lens: the last page's `(year, id)`. */ +export interface TimelineCursor { + /** A row with a null `year` is paged as `0` so the keyset stays total. */ + year: number; + id: string; +} + +/** + * Keyset resume position for the Server lens: the last expanded row's + * `(sectionId, sortTitle, id)`. `sectionId` is the server connection id the row + * expanded into via `json_each`; the same title resumes correctly even though it + * appears once per server because the tuple is unique per expanded row. + */ +export interface ServerCursor { + sectionId: string; + sortTitle: string; + id: string; +} + +/** + * Keyset resume position for the Quality lens: the last expanded row's + * `(tierRank, sortTitle, id)`. `tierRank` is the SAME ordinal the `ORDER BY` + * `CASE` produced (the `QUALITY_TIERS` index, or the bottom sentinel for an + * unlisted label), so the resume comparison reuses the identical rank expression + * — never a re-derived one — and a page boundary neither drops nor duplicates a + * tier section. + */ +export interface QualityCursor { + tierRank: number; + sortTitle: string; + id: string; +} + +/** One page of rows plus the raw keyset token the pipeline mints the next cursor from. */ +interface LensPage { + rows: LibraryRow[]; + /** The last row when the page was full, so the source can build `nextRaw`; absent when exhausted. */ + nextRow?: LibraryRow; +} + +/** + * One page of `json_each`-EXPANDED rows for the section-grouped lenses + * (server/quality). Each row is a `LibraryRow` plus the section value it + * expanded into, so the same title appears once per section. `nextRow` carries + * that section value too so the source can build a section-keyed hop token. + */ +interface ExpandedLensPage { + rows: ExpandedLibraryRow[]; + nextRow?: ExpandedLibraryRow; +} + +/** + * The browse-projection columns every lens selects, mapped onto `LibraryRow`. + * Exported so the collections repo selects the SAME shape when it hydrates a + * group's preview rows (one source of truth for the `LibraryRow` projection). + */ +export const ROW_COLUMNS = { + id: libraryItems.id, + tmdbId: libraryItems.tmdbId, + mediaType: libraryItems.mediaType, + sortTitle: libraryItems.sortTitle, + year: libraryItems.year, + genres: libraryItems.genres, + servers: libraryItems.servers, + qualityTiers: libraryItems.qualityTiers, + watchedState: libraryItems.watchedState, + collectionId: libraryItems.collectionId, + collectionName: libraryItems.collectionName, +}; + +/** + * Pages the A–Z lens in `(sort_title, id)` ascending keyset order over the + * user's owned set, applying the requested filters in SQL (design §The 5 + * lenses). Selects `limit + 1` rows so the caller can detect a next page without + * a second count query; the extra row is dropped and surfaced as `nextRow`. The + * SQL pre-sorts, so the pipeline declares `sort: "none"`. + */ +export async function selectAzPage( + userId: string, + filters: LensFilters, + cursor: AzCursor | undefined, + limit: number, + db: Db = getDb(), +): Promise { + const where = and(...ownedFilterConditions(userId, filters), ...azCursorCondition(cursor)); + const rows = await db + .select(ROW_COLUMNS) + .from(libraryItems) + .where(where) + .orderBy(asc(libraryItems.sortTitle), asc(libraryItems.id)) + .limit(limit + 1); + return toLensPage(rows, limit); +} + +/** + * Pages the Timeline lens in `(year DESC, id)` keyset order over the user's + * owned set. A null `year` sorts last (newest-first puts undated titles at the + * tail) and is paged as `0` in the cursor so the keyset stays total. Otherwise + * identical to {@link selectAzPage}: filters in SQL, `limit + 1` over-fetch, SQL + * pre-sorts so the pipeline runs `sort: "none"`. + */ +export async function selectTimelinePage( + userId: string, + filters: LensFilters, + cursor: TimelineCursor | undefined, + limit: number, + db: Db = getDb(), +): Promise { + const where = and(...ownedFilterConditions(userId, filters), ...timelineCursorCondition(cursor)); + const rows = await db + .select(ROW_COLUMNS) + .from(libraryItems) + .where(where) + // Order by the SAME `COALESCE(year, 0)` expression the cursor predicate + // uses, so the sort and the keyset agree on where a null (or literal 0) + // year sits — at the descending tail, tie-broken by the stable id. Using + // raw `year DESC` (SQLite NULLS-last) here would disagree with the + // COALESCE predicate and silently drop/duplicate undated rows at the page + // boundary. + .orderBy(sql`COALESCE(${libraryItems.year}, 0) DESC`, asc(libraryItems.id)) + .limit(limit + 1); + return toLensPage(rows, limit); +} + +/** + * Base WHERE for every lens: the user's currently-owned rows narrowed by the + * requested filters. `owned = true` excludes tombstones; an absent/empty axis + * contributes no condition. Exported so the collections repo applies the + * IDENTICAL owned + filter predicate before its `collection_id IS NOT NULL` + * group narrowing — the filter axes behave the same on every lens. + */ +export function ownedFilterConditions(userId: string, filters: LensFilters): SQL[] { + const conditions: SQL[] = [eq(libraryItems.userId, userId), eq(libraryItems.owned, true)]; + if (filters.kinds && filters.kinds.length > 0) { + conditions.push(inArray(libraryItems.mediaType, filters.kinds)); + } + if (filters.watched && filters.watched.length > 0) { + conditions.push(inArray(libraryItems.watchedState, filters.watched)); + } + if (filters.genres && filters.genres.length > 0) { + conditions.push(jsonValueIn(libraryItems.genres, filters.genres)); + } + if (filters.qualities && filters.qualities.length > 0) { + conditions.push(jsonValueIn(libraryItems.qualityTiers, filters.qualities)); + } + if (filters.servers && filters.servers.length > 0) { + conditions.push(jsonServerLabelIn(libraryItems.servers, filters.servers)); + } + return conditions; +} + +/** + * Renders `values` as a parenthesized SQL `IN` list of bound parameters. + * Drizzle's `sql` template does NOT auto-expand a JS array into `(?, ?, …)`, so + * the membership helpers build the list explicitly with `sql.join` — each value + * stays a bound parameter, never string-interpolated. + */ +function inList(values: string[]): SQL { + return sql`(${sql.join( + values.map((value) => sql`${value}`), + sql`, `, + )})`; +} + +/** + * `EXISTS` membership over a JSON string-array column: keeps the row when any + * `json_each` value is in `values`. Used for `genres` and `quality_tiers`, + * whose JSON is a flat `["Drama", …]` / `["4K HDR", …]` array. + */ +function jsonValueIn(column: Column, values: string[]): SQL { + return sql`EXISTS (SELECT 1 FROM json_each(${column}) WHERE value IN ${inList(values)})`; +} + +/** + * `EXISTS` membership over the `servers` JSON column, whose elements are + * `{ id, label }` objects, matching on each element's human-readable `label`. + * The label is the filter axis on purpose: the facets repo keys the `servers` + * count map on `label`, and the FE popover sends that same label back as + * `filters.servers`, so the facet key, the filter value, and this predicate all + * agree on the label. (The Server LENS still SECTIONS on the connection `id` for + * stable grouping — that is a separate axis from this filter.) + */ +function jsonServerLabelIn(column: Column, values: string[]): SQL { + return sql`EXISTS (SELECT 1 FROM json_each(${column}) WHERE value ->> 'label' IN ${inList(values)})`; +} + +/** Keyset predicate for the A–Z lens: rows strictly after `(sortTitle, id)`. */ +function azCursorCondition(cursor: AzCursor | undefined): SQL[] { + if (!cursor) return []; + return [ + or( + gt(libraryItems.sortTitle, cursor.sortTitle), + and(eq(libraryItems.sortTitle, cursor.sortTitle), gt(libraryItems.id, cursor.id)), + )!, + ]; +} + +/** + * Keyset predicate for the Timeline lens: rows strictly after `(year DESC, id)`. + * "After" in a descending-year ordering means a smaller year, or the same year + * with a larger id. A null `year` compares as `0` (its sort position) via + * `COALESCE` so the resume tuple stays total across the NULLS-last tail. + */ +function timelineCursorCondition(cursor: TimelineCursor | undefined): SQL[] { + if (!cursor) return []; + const yearExpr = sql`COALESCE(${libraryItems.year}, 0)`; + return [ + or( + sql`${yearExpr} < ${cursor.year}`, + and(sql`${yearExpr} = ${cursor.year}`, gt(libraryItems.id, cursor.id)), + )!, + ]; +} + +/** + * Splits the `limit + 1` over-fetch into the page rows plus the next-page + * marker. When the query returned more than `limit` rows there is another page, + * so the trailing row is dropped from the page and returned as `nextRow` for the + * source to encode into the keyset hop token; a short read means the scan is + * exhausted and no `nextRow` is emitted. + */ +function toLensPage(rows: LibraryRow[], limit: number): LensPage { + if (rows.length <= limit) return { rows }; + // The next cursor is the LAST row of the RETURNED page, never the dropped + // overflow row: the keyset predicate is strictly-greater, so encoding the + // overflow row would skip it on the next page (it is neither in this page nor + // returned by a `> overflow` scan). Encoding the last returned row makes the + // next page resume exactly at the overflow row. (Mirrors the watchlist + // source's `rawToken(rows[rows.length - 1])` convention.) + const page = rows.slice(0, limit); + return { rows: page, nextRow: page[page.length - 1]! }; +} + +/** + * The browse-projection columns as TABLE-QUALIFIED raw SQL, for the grouped + * lenses whose `FROM` is a raw `${libraryItems}, json_each(...)` expression. + * Drizzle's query builder rejects a bare column object (`libraryItems.id`) in a + * `.select()` when the `FROM` is raw SQL — it cannot tie the column back to a + * recognized table source and throws "the table is not part of the query". So + * the grouped lenses project each column as `${libraryItems}."col"`, the same + * table-qualified convention `facets.ts` uses for its `json_each` counts. The + * `id` qualification is doubly required: the `json_each` virtual table also + * exposes an `id`, so a bare `id` would be ambiguous to SQLite. + */ +const EXPANDED_ROW_COLUMNS = { + id: sql`${libraryItems}."id"`.as("id"), + tmdbId: sql`${libraryItems}."tmdb_id"`.as("tmdb_id"), + mediaType: sql`${libraryItems}."media_type"`.as("media_type"), + sortTitle: sql`${libraryItems}."sort_title"`.as("sort_title"), + year: sql`${libraryItems}."year"`.as("year"), + genres: sql`${libraryItems}."genres"`.as("genres"), + servers: sql`${libraryItems}."servers"`.as("servers"), + qualityTiers: sql`${libraryItems}."quality_tiers"`.as("quality_tiers"), + watchedState: sql`${libraryItems}."watched_state"`.as( + "watched_state", + ), + collectionId: sql`${libraryItems}."collection_id"`.as("collection_id"), + collectionName: sql`${libraryItems}."collection_name"`.as("collection_name"), +}; + +/** + * The expanded-row columns the Server lens selects: the table-qualified browse + * columns plus the `json_each` value, aliased so it maps onto + * `ExpandedLibraryRow`. The `id`/`label` are pulled from the value object for the + * Server lens; the Quality lens overrides them since its value is a bare string + * (see below). + */ +const SERVER_ROW_COLUMNS = { + ...EXPANDED_ROW_COLUMNS, + sectionId: sql`sv.value ->> 'id'`.as("section_id"), + sectionLabel: sql`sv.value ->> 'label'`.as("section_label"), +}; + +/** + * Pages the Server lens in `(server, sort_title, id)` ascending keyset order, + * expanding each owned row across `json_each(servers)` so a title on two servers + * appears in both server sections (design §The 5 lenses; row dup per value is + * INTENDED). The keyset predicate uses the SAME `sv.value ->> 'id'` / + * `sort_title` / `id` expressions the `ORDER BY` uses — column-for-column — so a + * page boundary is stable. Filters apply identically to the flat lenses. + */ +export async function selectServerPage( + userId: string, + filters: LensFilters, + cursor: ServerCursor | undefined, + limit: number, + db: Db = getDb(), +): Promise { + const sectionId = sql`sv.value ->> 'id'`; + const where = and( + ...ownedFilterConditions(userId, filters), + ...serverCursorCondition(cursor, sectionId), + ); + const rows = await db + .select(SERVER_ROW_COLUMNS) + .from(sql`${libraryItems}, json_each(${libraryItems.servers}) sv`) + .where(where) + .orderBy(sql`${sectionId} ASC`, asc(libraryItems.sortTitle), asc(libraryItems.id)) + .limit(limit + 1); + return toExpandedPage(rows.map(toServerExpandedRow), limit); +} + +/** + * Pages the Quality lens in `(tierRank DESC-fidelity, sort_title, id)` keyset + * order, expanding each owned row across `json_each(quality_tiers)`. The tier + * rank is the `QUALITY_TIERS` ordinal built as a SQL `CASE` ({@link qualityRankCase}); + * the `ORDER BY` sorts it ASCENDING (0 = highest fidelity first) and the keyset + * predicate reuses the IDENTICAL `CASE` expression — never a re-derived rank — + * so a tier section is neither dropped nor duplicated at a page boundary + * (phase-2 lesson). Row dup per tier is INTENDED. Filters apply identically. + */ +export async function selectQualityPage( + userId: string, + filters: LensFilters, + cursor: QualityCursor | undefined, + limit: number, + db: Db = getDb(), +): Promise { + // ONE rank `CASE` shared by the `ORDER BY` and the cursor predicate so the + // sort key and the resume comparison are byte-identical (phase-2 lesson). The + // select projects a SEPARATE `.as()`-aliased rank so the returned row carries + // the ordinal for the hop token, without aliasing the shared comparison copy. + const rank = qualityRankCase(); + const where = and( + ...ownedFilterConditions(userId, filters), + ...qualityCursorCondition(cursor, rank), + ); + const rows = await db + .select({ + ...EXPANDED_ROW_COLUMNS, + tier: sql`qt.value`.as("tier"), + rank: sql`${qualityRankCase()}`.as("tier_rank"), + }) + .from(sql`${libraryItems}, json_each(${libraryItems.qualityTiers}) qt`) + .where(where) + .orderBy(sql`${rank} ASC`, asc(libraryItems.sortTitle), asc(libraryItems.id)) + .limit(limit + 1); + return toExpandedPage(rows.map(toQualityExpandedRow), limit); +} + +/** + * Keyset predicate for the Server lens: expanded rows strictly after + * `(sectionId, sortTitle, id)`. The `sectionId` arg is the SAME + * `sv.value ->> 'id'` SQL the caller orders by, so the comparison and the sort + * never drift. + */ +function serverCursorCondition(cursor: ServerCursor | undefined, sectionId: SQL): SQL[] { + if (!cursor) return []; + const afterSection = sql`${sectionId} > ${cursor.sectionId}`; + const sameSection = sql`${sectionId} = ${cursor.sectionId}`; + return [or(afterSection, and(sameSection, ...afterSortTitle(cursor)))!]; +} + +/** + * Keyset predicate for the Quality lens: expanded rows strictly after + * `(tierRank, sortTitle, id)` in ASCENDING-rank order (a LARGER rank = lower + * fidelity = later). The `rank` arg is the identical `CASE` the caller orders + * by, so a hand-built ordinal never disagrees with the SQL one. + */ +function qualityCursorCondition(cursor: QualityCursor | undefined, rank: SQL): SQL[] { + if (!cursor) return []; + const afterRank = sql`${rank} > ${cursor.tierRank}`; + const sameRank = sql`${rank} = ${cursor.tierRank}`; + return [or(afterRank, and(sameRank, ...afterSortTitle(cursor)))!]; +} + +/** + * The shared `(sortTitle, id)` tail of the grouped-lens keyset predicates: rows + * after `cursor` once the leading section/rank key ties. Both grouped lenses + * tie-break on the same ascending `(sort_title, id)`, so they share this. + */ +function afterSortTitle(cursor: { sortTitle: string; id: string }): SQL[] { + return [ + or( + gt(libraryItems.sortTitle, cursor.sortTitle), + and(eq(libraryItems.sortTitle, cursor.sortTitle), gt(libraryItems.id, cursor.id)), + )!, + ]; +} + +/** + * Builds the Quality lens rank `CASE` from `QUALITY_TIERS`: one arm per tier + * (`WHEN 'label' THEN `) with the bottom sentinel in the `ELSE`, matching + * `rankQualityTier` value-for-value. Tier labels are compile-time constants + * (never user input), so interpolating them is injection-safe; the ordinals are + * bound parameters. + */ +function qualityRankCase(): SQL { + const arms = QUALITY_TIERS.map((label, index) => sql`WHEN ${label} THEN ${index}`); + return sql`CASE qt.value ${sql.join(arms, sql` `)} ELSE ${QUALITY_RANK_UNRANKED} END`; +} + +/** Maps a Server-lens query row onto an `ExpandedLibraryRow` (section ← server `{id,label}`). */ +function toServerExpandedRow( + row: LibraryRow & { sectionId: string; sectionLabel: string }, +): ExpandedLibraryRow { + const { sectionId, sectionLabel, ...base } = row; + return { ...base, section: { id: sectionId, label: sectionLabel } }; +} + +/** + * Maps a Quality-lens query row onto an `ExpandedLibraryRow`. The tier label is + * both the section id and label (it is the group key the FE shows verbatim); + * `rank` rides along so the source's hop token reuses the SQL `CASE` ordinal + * rather than re-deriving it. + */ +function toQualityExpandedRow( + row: LibraryRow & { tier: string; rank: number }, +): ExpandedLibraryRow { + const { tier, rank, ...base } = row; + return { ...base, section: { id: tier, label: tier }, rank }; +} + +/** + * The expanded-row twin of {@link toLensPage}: drops the `limit + 1` overflow + * row and returns the LAST RETURNED expanded row as `nextRow`. The keyset tuple + * is unique per expanded row (section/rank + sortTitle + id), so encoding the + * last returned row makes the next page resume exactly at the overflow row — the + * phase-2 "never encode the overflow row" lesson, applied to the EXPANDED row, + * not the distinct title. + */ +function toExpandedPage(rows: ExpandedLibraryRow[], limit: number): ExpandedLensPage { + if (rows.length <= limit) return { rows }; + const page = rows.slice(0, limit); + return { rows: page, nextRow: page[page.length - 1]! }; +} diff --git a/apps/server/src/library/repo/membership.ts b/apps/server/src/library/repo/membership.ts new file mode 100644 index 00000000..e97c95ca --- /dev/null +++ b/apps/server/src/library/repo/membership.ts @@ -0,0 +1,72 @@ +import { and, eq, notInArray } from "drizzle-orm"; +import type { MediaType } from "@ent-mcp/shared/media"; +import { getDb, type Db } from "../../db/client"; +import { libraryItems } from "../../db/schema/library"; + +/** + * A new owned row to insert during membership sync. `id` is the composite + * `":"`. Denormalized columns (sort/facet keys, franchise, + * `hydratedAt`) are left at their schema defaults — the phase-2 hydrate job + * fills them in. + */ +export interface OwnedRowInput { + id: string; + userId: string; + tmdbId: string; + mediaType: MediaType; + ownedAt: number; +} + +/** + * Inserts new owned rows. Conflicts on the `(user_id, id)` primary key do + * NOTHING, so a previously-tombstoned row (`owned = false`) is never resurrected + * by a later sync (design §Sync + hydrate, watchlist tombstone pattern). The + * composite key means the same title owned by another user is a distinct row, + * not a conflict. Callers pre-filter + * to keys absent from `allKnownKeys`, so the conflict path only guards against + * a concurrent racing sync. Returns the number of rows actually inserted. + */ +export async function upsertOwned(rows: OwnedRowInput[], db: Db = getDb()): Promise { + if (rows.length === 0) return 0; + const inserted = await db + .insert(libraryItems) + .values(rows) + .onConflictDoNothing() + .returning({ id: libraryItems.id }); + return inserted.length; +} + +/** + * Tombstones every currently-owned row for `userId` whose composite id is + * absent from `keepKeys` (the keys present in the latest feed). Sets + * `owned = false` and stamps `unownedAt`. Already-tombstoned rows are untouched + * (the `owned = true` predicate excludes them). Returns the number tombstoned. + */ +export async function tombstoneMissing( + userId: string, + keepKeys: string[], + now: number, + db: Db = getDb(), +): Promise { + const base = and(eq(libraryItems.userId, userId), eq(libraryItems.owned, true)); + const where = keepKeys.length > 0 ? and(base, notInArray(libraryItems.id, keepKeys)) : base; + const tombstoned = await db + .update(libraryItems) + .set({ owned: false, unownedAt: now }) + .where(where) + .returning({ id: libraryItems.id }); + return tombstoned.length; +} + +/** + * Returns the set of composite ids (`":"`) known for the + * user in any state. Membership sync diffs the feed against this so a key the + * user has already tombstoned is never re-inserted as owned. + */ +export async function allKnownKeys(userId: string, db: Db = getDb()): Promise> { + const rows = await db + .select({ id: libraryItems.id }) + .from(libraryItems) + .where(eq(libraryItems.userId, userId)); + return new Set(rows.map((row) => row.id)); +} diff --git a/apps/server/src/library/repo/seed.ts b/apps/server/src/library/repo/seed.ts new file mode 100644 index 00000000..d954ce9d --- /dev/null +++ b/apps/server/src/library/repo/seed.ts @@ -0,0 +1,27 @@ +import { eq } from "drizzle-orm"; +import { getDb, type Db } from "../../db/client"; +import { userLibrarySeed } from "../../db/schema/library"; + +/** + * Inserts the seed marker exactly once for `userId`. Returns true when the + * caller wrote the row (and so should run the membership fetch) and false when + * a concurrent caller already won the race. Mirrors `media/repo/seed.ts`. + */ +export async function trySeedLock(userId: string, now: number, db: Db = getDb()): Promise { + const inserted = await db + .insert(userLibrarySeed) + .values({ userId, seededAt: now }) + .onConflictDoNothing() + .returning({ userId: userLibrarySeed.userId }); + return inserted.length > 0; +} + +/** Rolls back a `trySeedLock` claim. Called on a feed error so the next read retries. */ +export async function clearSeedLock(userId: string, db: Db = getDb()): Promise { + await db.delete(userLibrarySeed).where(eq(userLibrarySeed.userId, userId)); +} + +/** Lists the ids of every seeded user. The sync cron iterates exactly this set. */ +export async function listSeededUserIds(db: Db = getDb()): Promise<{ userId: string }[]> { + return db.select({ userId: userLibrarySeed.userId }).from(userLibrarySeed); +} diff --git a/apps/server/src/library/service.ts b/apps/server/src/library/service.ts new file mode 100644 index 00000000..eea9705a --- /dev/null +++ b/apps/server/src/library/service.ts @@ -0,0 +1,278 @@ +import type { CompactMediaItem } from "@ent-mcp/shared/home"; +import type { + LibraryCollection, + LibraryCollectionsQueryParsed, + LibraryCollectionsResponse, + LibraryFacetCounts, +} from "@ent-mcp/shared/library"; +import { identifyItem, parseItemDate, type RawPluginItem } from "../media"; +import { decodeCollectionsCursor, encodeCollectionsCursor } from "./internal/collections-cursor"; +import { asLibraryContext } from "./internal/context"; +import { buildEnrichRows } from "./internal/enrich"; +import { bustFacets, readFacets, writeFacets } from "./internal/facets-cache"; +import { hydrate, type HydrateOptions, type HydrateResult } from "./internal/hydrate"; +import { ensureSeeded } from "./internal/reads"; +import { + allKnownKeys, + selectCollections, + selectFacets, + selectRowsByIds, + tombstoneMissing, + upsertOwned, + type CollectionGroup, + type LensFilters, + type OwnedRowInput, +} from "./repo"; +import type { LibraryContext, MaybeLibraryContext } from "./types"; + +export type { LibraryContext } from "./types"; +export type { HydrateOptions, HydrateResult } from "./internal/hydrate"; + +/** Outcome of a membership sync. Counts are zero on an empty/absent feed. */ +export interface SyncMembershipResult { + /** New owned rows inserted this run. */ + added: number; + /** Rows tombstoned (`owned → false`) because they left the feed. */ + removed: number; + /** True when the `collection@v1` feed was incomplete (a provider errored). */ + partial: boolean; +} + +interface ParsedFeed { + /** New owned rows for keys not already known, ready to insert. */ + newRows: OwnedRowInput[]; + /** Every composite id present in the feed (`":"`). */ + feedKeys: string[]; + partial: boolean; +} + +/** + * Phase-1 owned-library membership sync (design §Sync + hydrate, phase 1). + * Diffs the `collection@v1` feed against the known projection: + * - keys in the feed but not yet known become new owned rows + * (`ownedAt = parseEpoch(entry.addedAt)`); denormalized columns stay at + * their defaults until the phase-2 hydrate job runs. + * - keys known-and-owned but absent from a COMPLETE feed are tombstoned. A + * tombstoned row is never resurrected (`upsertOwned` conflicts do nothing). + * A partial or empty/absent feed never tombstones — absence there is not + * evidence of un-ownership (see the sweep guard below). + * + * Idempotent: a re-run with the same feed inserts and tombstones nothing. + * Tolerant of an empty/absent feed (no `collection@v1` provider) — no-op, + * zero counts, never throws to the caller, and never wipes the owned library. + */ +export async function syncMembership(ctx: MaybeLibraryContext): Promise { + const c = asLibraryContext(ctx); + const known = await allKnownKeys(c.userId); + const parsed = await fetchAndParseFeed(c, known); + const added = await upsertOwned(parsed.newRows); + // Only sweep tombstones from a COMPLETE, non-empty feed. A `partial` feed (a + // provider errored mid-fan-out) or an empty/absent feed (no provider, a + // disconnected provider, or a terminal all-providers failure swallowed in + // `fetchAndParseFeed`) cannot distinguish "left the collection" from + // "temporarily unreachable" — absence is then not evidence of un-ownership. + // Sweeping on it would tombstone the entire owned library on a transient + // outage, so it is skipped; a later complete sync reconciles real removals. + const removed = + parsed.partial || parsed.feedKeys.length === 0 + ? 0 + : await tombstoneMissing(c.userId, parsed.feedKeys, Date.now()); + // Bust the facets cache whenever the owned set actually changed so the next + // `/facets` read recomputes against the new membership. A no-op sync (zero + // adds, zero removes) leaves the cache so an unchanged library keeps serving + // the cached totals. This is the same-module invalidation the design calls for + // — no event bus needed for a concern wholly inside the library module. + if (added > 0 || removed > 0) await bustFacets(c.userId); + return { added, removed, partial: parsed.partial }; +} + +/** Alias matching the design's `sync(userId)` job entry point. */ +export async function syncLibrary(ctx: MaybeLibraryContext): Promise { + return syncMembership(ctx); +} + +/** + * Phase-2 denormalized hydrate (design §Sync + hydrate, phase 2). Resolves the + * loose context and delegates to the `internal/hydrate` orchestrator, which + * fills the browse projection (sortTitle, year, genres, servers, qualityTiers, + * watchedState, franchise) for the user's new and stale owned rows. Thin by + * design: no drizzle, no fan-out logic here — `internal/hydrate` owns the + * orchestration and `repo/hydrate` owns the SQL. + * + * The 6-hourly membership sync calls this after reconciling membership so freshly + * inserted rows hydrate promptly; the hourly `library.hydrate` job calls it with + * a 1-hour window to refresh availability staleness (availability moves faster + * than membership). + */ +export async function hydrateLibrary( + ctx: MaybeLibraryContext, + opts?: HydrateOptions, +): Promise { + return hydrate(asLibraryContext(ctx), opts ?? {}); +} + +/** + * Returns the unfiltered facet totals for a user's owned library (design + * §Facets), served behind a short-TTL per-user cache. The FE re-reads facets + * whenever the popover re-opens or the rail re-renders, so the cache keeps that + * off the GROUP-BY fan-out; the membership sync busts the entry on a real + * change. Counts are whole-library totals, NOT filter-aware (matches the mock). + * + * Unlike the lens reads, this does NOT eager-seed: a brand-new user's facets are + * empty until the lens read's `fetchRawSet` seeds membership (the watchlist + * precedent — seed rides the primary list read, not every read), at which point + * the sync busts this cache and the next `/facets` read reflects the seed. + */ +export async function getFacets(ctx: MaybeLibraryContext): Promise { + const c = asLibraryContext(ctx); + const cached = await readFacets(c.userId); + if (cached) return cached; + const facets = await selectFacets(c.userId); + await writeFacets(c.userId, facets); + return facets; +} + +/** + * Lists the user's owned franchises group-first (design §Collections lens). + * Eager-seeds membership on a first read exactly as the lens path does (so a + * brand-new user's collections are not empty on first paint), pages the owned + * franchises via the repo keyset, then enriches each group's preview ids into + * `CompactMediaItem`s through the SAME dedup-free enrich the lenses use — no + * availability re-probe, reading the denormalized columns. Owned-only and + * TV/standalone-excluded are enforced in SQL (`owned = true` + + * `collection_id IS NOT NULL`); preview is capped at four in SQL. Thin by + * design: no drizzle here — the repo owns the SQL, this orchestrates. + */ +export async function listCollections( + ctx: MaybeLibraryContext, + query: LibraryCollectionsQueryParsed, +): Promise { + const c = asLibraryContext(ctx); + const decoded = decodeCollectionsCursor(query.cursor); + // Only the first page (no resume cursor) eager-seeds, mirroring the lens + // sources: a paged-into read already saw a seeded library, so it skips the + // seed-lock round trip. + if (!decoded) await ensureSeeded(c); + const page = await selectCollections(c.userId, toFilters(query), decoded, query.limit); + const previews = await enrichPreviews(c, page.groups); + const collections = page.groups.map((group) => toLibraryCollection(group, previews)); + const cursor = page.nextGroup ? encodeCollectionsCursor(page.nextGroup) : null; + return { collections, cursor }; +} + +/** + * Enriches every group's preview ids in ONE batch into a `id → CompactMediaItem` + * lookup. Pooling all groups' previews into a single `selectRowsByIds` + + * `buildEnrichRows` call keeps the metadata/progress fan-out to one round trip + * for the whole page rather than one per franchise. The enrich is the lens' + * dedup-free builder, so it reads the denormalized `servers`/`qualityTiers` + * columns and never re-probes availability. + */ +async function enrichPreviews( + ctx: LibraryContext, + groups: CollectionGroup[], +): Promise> { + const ids = [...new Set(groups.flatMap((group) => group.previewIds))]; + if (ids.length === 0) return new Map(); + const rows = await selectRowsByIds(ctx.userId, ids); + const { items } = await buildEnrichRows(ctx)(rows); + return new Map(items.map((item) => [item.id, item])); +} + +/** + * Maps a repo group + the enriched preview lookup onto the wire + * `LibraryCollection`. The preview keeps the repo's `(sortTitle, id)` ordering + * (it walks `group.previewIds`, not the lookup) and drops any id the enrich + * could not resolve, so a missing-metadata preview shrinks the fan rather than + * surfacing a blank card. + */ +function toLibraryCollection( + group: CollectionGroup, + previews: Map, +): LibraryCollection { + const preview = group.previewIds + .map((id) => previews.get(id)) + .filter((item): item is CompactMediaItem => item != null); + return { + id: `collection:${group.collectionId}`, + title: group.collectionName, + count: group.count, + preview, + }; +} + +/** + * Projects the parsed wire query onto the repo `LensFilters` shape, dropping + * omitted axes so the repo applies no filter for them. Mirrors the item lenses' + * `toLensParams` so the filter axes behave identically across every lens. + */ +function toFilters(query: LibraryCollectionsQueryParsed): LensFilters { + const filters: LensFilters = {}; + if (query.kinds) filters.kinds = query.kinds; + if (query.genres) filters.genres = query.genres; + if (query.qualities) filters.qualities = query.qualities; + if (query.servers) filters.servers = query.servers; + if (query.watched) filters.watched = query.watched; + return filters; +} + +/** + * Fetches the `collection@v1` feed and parses it into insertable new rows plus + * the full set of feed keys. A feed error (terminal all-providers failure) is + * swallowed at this boundary: it logs at info severity and reports an empty, + * `partial` feed so the sync no-ops rather than throwing — the run still + * surfaces the degradation via `partial`, and a later sync self-heals. + */ +async function fetchAndParseFeed(ctx: LibraryContext, known: Set): Promise { + const opts: { deadlineMs?: number } = {}; + if (ctx.deadlineMs != null) opts.deadlineMs = ctx.deadlineMs; + let items: unknown[]; + let partial: boolean; + try { + const feed = await ctx.mediaService.getCollectionFeed(opts); + items = feed.items; + partial = feed.partial; + } catch (err) { + ctx.log.info("[library:sync] getCollectionFeed unavailable; treating library as empty", err); + return { newRows: [], feedKeys: [], partial: true }; + } + return toParsedFeed(ctx.userId, items, known, partial); +} + +/** Diffs the raw feed entries into new rows + the full feed-key set. */ +function toParsedFeed( + userId: string, + items: unknown[], + known: Set, + partial: boolean, +): ParsedFeed { + const newRows: OwnedRowInput[] = []; + const feedKeys: string[] = []; + for (const entry of items) { + const row = toOwnedRow(userId, entry); + if (!row) continue; + feedKeys.push(row.id); + if (!known.has(row.id)) newRows.push(row); + } + return { newRows, feedKeys, partial }; +} + +/** + * Resolves a single `collection@v1` entry (`{ item, addedAt }`) into an owned + * row, or null when the item lacks a usable tmdb id / primary type. `ownedAt` + * falls back to now when `addedAt` is missing or unparseable. + */ +function toOwnedRow(userId: string, entry: unknown): OwnedRowInput | null { + if (!entry || typeof entry !== "object") return null; + const { item, addedAt } = entry as { item?: RawPluginItem; addedAt?: string }; + const identity = identifyItem(item); + if (!identity) return null; + const id = `${identity.type}:${identity.tmdbId}`; + return { + id, + userId, + tmdbId: identity.tmdbId, + mediaType: identity.type, + ownedAt: parseItemDate(addedAt) ?? Date.now(), + }; +} diff --git a/apps/server/src/library/sources/az.ts b/apps/server/src/library/sources/az.ts new file mode 100644 index 00000000..6defcf9d --- /dev/null +++ b/apps/server/src/library/sources/az.ts @@ -0,0 +1,51 @@ +import { selectAzPage, type LensFilters } from "../repo"; +import type { LibraryRow } from "../types"; +import { ensureSeeded } from "../internal/reads"; +import type { Cursor, MediaSource, RawPageToken, SourceContext } from "../../media"; +import { azToken, decodeAz } from "./keyset"; + +/** + * Source params for the A–Z lens: the filter axes plus the page size. The opaque + * cursor is decoded separately by the `paginate` stage / the keyset codec, so it + * is not on this shape (mirrors the watchlist `ItemsParams` split). + */ +export interface AzParams { + filters: LensFilters; + limit: number; +} + +/** + * The A–Z library lens `MediaSource` (design §The 5 lenses). It produces ONLY a + * raw, SQL-pre-sorted page of `library_items` rows; the shared media pipeline + * (`listRows`) owns enrich (via the library `enrichRows` override) / sort / + * paginate. Because the SQL already ordered by `(sort_title, id)`, the source + * declares `sort: "none"` so the pipeline preserves that order, and + * `cursorMode: "keyset"` so `paginate` mints the next cursor from `nextRaw`. + */ +export const azSource: MediaSource = { + sourceId: "library-az", + fetchRawSet, + stages: { sort: "none", cursorMode: "keyset" }, +}; + +/** + * Fetches one A–Z page from the repo (no drizzle here — R2), threading the + * decoded keyset cursor and the requested filters. A first-page read for a + * not-yet-seeded user eagerly seeds membership inline so the page is not empty + * on first paint (design §Sync + hydrate: eager-seed). `partial` is always + * false: the page is a pure indexed table read with no plugin fan-out. The last + * row becomes `nextRaw` only when the page was full (another page may follow); + * a short page exhausts the scan and emits no token so `paginate` ends the + * cursor. + */ +async function fetchRawSet( + ctx: SourceContext, + params: AzParams, + cursor: Cursor | null, +): Promise<{ rows: LibraryRow[]; partial: boolean; nextRaw?: RawPageToken }> { + if (!cursor) await ensureSeeded(ctx); + const decoded = decodeAz(cursor); + const page = await selectAzPage(ctx.userId, params.filters, decoded, params.limit); + const nextRaw = page.nextRow ? azToken(page.nextRow) : undefined; + return { rows: page.rows, partial: false, ...(nextRaw !== undefined ? { nextRaw } : {}) }; +} diff --git a/apps/server/src/library/sources/keyset.ts b/apps/server/src/library/sources/keyset.ts new file mode 100644 index 00000000..30c177ad --- /dev/null +++ b/apps/server/src/library/sources/keyset.ts @@ -0,0 +1,139 @@ +import type { AzCursor, QualityCursor, ServerCursor, TimelineCursor } from "../repo"; +import { rankQualityTier } from "../internal/rank-quality"; +import type { ExpandedLibraryRow, LibraryRow } from "../types"; +import type { Cursor, RawPageToken } from "../../media"; + +/** + * Per-lens keyset hop-token codecs, mirroring `watchlist/sources/keyset.ts`. The + * opaque `k` a keyset {@link Cursor} carries is the lens's resume position; the + * source decodes the incoming cursor with the matching `decode*` and threads the + * next page's position back as a {@link RawPageToken} via the matching `*Token`, + * which `media.paginate` mints into the next cursor. + * + * The token packs ` ` joined on a single space. The library `id` + * (`"movie:550"`) never contains a space, but a `sortTitle` can, so every decode + * splits on the LAST space to recover the id and treats the prefix as the sort + * key. Like watchlist, every `decode*` is total — a bad, foreign, or + * non-keyset cursor returns `undefined`, which the source reads as "first page" + * and NEVER throws (invariant: a hand-edited cursor degrades, never 400s). + */ + +/** A–Z hop token: `" "`. */ +export function azToken(row: LibraryRow): RawPageToken { + return `${row.sortTitle} ${row.id}`; +} + +/** Timeline hop token: `" "` — undated rows page as year 0. */ +export function timelineToken(row: LibraryRow): RawPageToken { + return `${row.year ?? 0} ${row.id}`; +} + +/** Decode an A–Z cursor back to its `(sortTitle, id)` resume position, or undefined. */ +export function decodeAz(cursor: Cursor | null): AzCursor | undefined { + const parts = splitToken(cursor); + if (!parts) return undefined; + return { sortTitle: parts.head, id: parts.id }; +} + +/** + * Decode a Timeline cursor back to its `(year, id)` resume position, or + * undefined. The year must parse to a finite integer; anything else (a + * hand-edited token) takes the first-page path rather than flowing a `NaN` into + * the keyset comparison. + */ +export function decodeTimeline(cursor: Cursor | null): TimelineCursor | undefined { + const parts = splitToken(cursor); + if (!parts) return undefined; + const year = Number(parts.head); + if (!Number.isFinite(year)) return undefined; + return { year, id: parts.id }; +} + +/** + * Server hop token: `" "`. The leading section is the + * server connection id (the `json_each` value's `id`), which never contains a + * space; the trailing `id` is the composite library id, also space-free; only + * the middle `sortTitle` can contain spaces, so the decode peels the first and + * last spaces off (see {@link splitTriToken}). + */ +export function serverToken(row: ExpandedLibraryRow): RawPageToken { + return `${row.section.id} ${row.sortTitle} ${row.id}`; +} + +/** + * Quality hop token: `" "`. The rank is the EXACT + * `QUALITY_TIERS` ordinal the SQL `CASE` produced — taken from the expanded + * row's `rank` when present, else re-derived from the tier label via + * `rankQualityTier` (the two agree value-for-value by construction). Encoding the + * ordinal, not the label, keeps the token comparable to the cursor predicate's + * numeric rank. + */ +export function qualityToken(row: ExpandedLibraryRow): RawPageToken { + const rank = row.rank ?? rankQualityTier(row.section.id); + return `${rank} ${row.sortTitle} ${row.id}`; +} + +/** + * Decode a Server cursor back to its `(sectionId, sortTitle, id)` resume + * position, or undefined for any bad/foreign/non-keyset cursor (→ first page, + * never throws). + */ +export function decodeServer(cursor: Cursor | null): ServerCursor | undefined { + const parts = splitTriToken(cursor); + if (!parts) return undefined; + return { sectionId: parts.head, sortTitle: parts.mid, id: parts.id }; +} + +/** + * Decode a Quality cursor back to its `(tierRank, sortTitle, id)` resume + * position, or undefined. The rank must parse to a finite integer; a hand-edited + * non-numeric rank takes the first-page path rather than flowing `NaN` into the + * keyset comparison. + */ +export function decodeQuality(cursor: Cursor | null): QualityCursor | undefined { + const parts = splitTriToken(cursor); + if (!parts) return undefined; + const tierRank = Number(parts.head); + if (!Number.isFinite(tierRank)) return undefined; + return { tierRank, sortTitle: parts.mid, id: parts.id }; +} + +/** + * Pull the keyset `k` off a cursor and split it on the LAST space into the sort + * key prefix (`head`) and the trailing composite `id`. Returns undefined for a + * null/non-keyset cursor, a token with no space, or an empty id — every + * bad-cursor path the `decode*` functions fold into "first page". + */ +function splitToken(cursor: Cursor | null): { head: string; id: string } | undefined { + if (!cursor || cursor.mode !== "keyset") return undefined; + const sep = cursor.k.lastIndexOf(" "); + if (sep < 0) return undefined; + const head = cursor.k.slice(0, sep); + const id = cursor.k.slice(sep + 1); + if (id.length === 0) return undefined; + return { head, id }; +} + +/** + * Split a three-part grouped-lens token `" "` into its leading + * section/rank key (`head`), the middle `sortTitle` (`mid`, the only part that + * may contain spaces), and the trailing composite `id`. The split peels the + * FIRST space (head) and the LAST space (id); whatever lies between is the sort + * title. Returns undefined for a null/non-keyset cursor, fewer than three parts, + * or an empty id — every bad-cursor path the grouped `decode*` functions fold + * into "first page", never a throw. + */ +function splitTriToken( + cursor: Cursor | null, +): { head: string; mid: string; id: string } | undefined { + if (!cursor || cursor.mode !== "keyset") return undefined; + const first = cursor.k.indexOf(" "); + const last = cursor.k.lastIndexOf(" "); + // Need two distinct separators: a single space would mean only two parts. + if (first < 0 || first === last) return undefined; + const head = cursor.k.slice(0, first); + const mid = cursor.k.slice(first + 1, last); + const id = cursor.k.slice(last + 1); + if (id.length === 0) return undefined; + return { head, mid, id }; +} diff --git a/apps/server/src/library/sources/quality.ts b/apps/server/src/library/sources/quality.ts new file mode 100644 index 00000000..88afa9e3 --- /dev/null +++ b/apps/server/src/library/sources/quality.ts @@ -0,0 +1,54 @@ +import { selectQualityPage, type LensFilters } from "../repo"; +import type { ExpandedLibraryRow } from "../types"; +import { ensureSeeded } from "../internal/reads"; +import type { Cursor, MediaSource, RawPageToken, SourceContext } from "../../media"; +import { decodeQuality, qualityToken } from "./keyset"; + +/** + * Source params for the Quality lens: the filter axes plus the page size. Same + * split as the flat lenses — the opaque cursor is decoded by the keyset codec, + * not carried here. + */ +export interface QualityParams { + filters: LensFilters; + limit: number; +} + +/** + * The Quality library lens `MediaSource` (design §The 5 lenses). It pages the + * owned set EXPANDED across `json_each(quality_tiers)`, so a title held in two + * tiers appears once per tier section — the row set is intentionally not + * distinct by title. Its row type is {@link ExpandedLibraryRow}: each row + * carries the tier section (the tier label is both id and label) and the SQL + * rank ordinal the page sorted by, which the keyset codec reuses verbatim so the + * hop token never re-derives a rank that could disagree with the `ORDER BY`. The + * SQL pre-sorts by `(tierRank, sortTitle, id)`, so the pipeline runs + * `sort: "none"`; `cursorMode: "keyset"` mints the next cursor from the + * rank-keyed hop token. + */ +export const qualitySource: MediaSource = { + sourceId: "library-quality", + fetchRawSet, + stages: { sort: "none", cursorMode: "keyset" }, +}; + +/** + * Fetches one Quality page from the repo (no drizzle here — R2), threading the + * decoded `(tierRank, sortTitle, id)` keyset cursor and the requested filters. A + * first-page read eagerly seeds a not-yet-seeded user inline (design §Sync + + * hydrate: eager-seed). `partial` is always false (a pure indexed table read). + * `nextRaw` is built from the LAST RETURNED expanded row — never the dropped + * overflow row — via {@link qualityToken}, and only on a full page so the cursor + * ends on a short read. + */ +async function fetchRawSet( + ctx: SourceContext, + params: QualityParams, + cursor: Cursor | null, +): Promise<{ rows: ExpandedLibraryRow[]; partial: boolean; nextRaw?: RawPageToken }> { + if (!cursor) await ensureSeeded(ctx); + const decoded = decodeQuality(cursor); + const page = await selectQualityPage(ctx.userId, params.filters, decoded, params.limit); + const nextRaw = page.nextRow ? qualityToken(page.nextRow) : undefined; + return { rows: page.rows, partial: false, ...(nextRaw !== undefined ? { nextRaw } : {}) }; +} diff --git a/apps/server/src/library/sources/server.ts b/apps/server/src/library/sources/server.ts new file mode 100644 index 00000000..0f04d28a --- /dev/null +++ b/apps/server/src/library/sources/server.ts @@ -0,0 +1,52 @@ +import { selectServerPage, type LensFilters } from "../repo"; +import type { ExpandedLibraryRow } from "../types"; +import { ensureSeeded } from "../internal/reads"; +import type { Cursor, MediaSource, RawPageToken, SourceContext } from "../../media"; +import { decodeServer, serverToken } from "./keyset"; + +/** + * Source params for the Server lens: the filter axes plus the page size. Same + * split as the flat lenses — the opaque cursor is decoded by the keyset codec, + * not carried here. + */ +export interface ServerParams { + filters: LensFilters; + limit: number; +} + +/** + * The Server library lens `MediaSource` (design §The 5 lenses). It pages the + * owned set EXPANDED across `json_each(servers)`, so a title on two servers + * appears once per server section — the row set is intentionally not distinct by + * title. Its row type is therefore {@link ExpandedLibraryRow}: each row carries + * the server section it expanded into, which the library `enrichRows` override + * surfaces onto the `CompactMediaItem`. The SQL pre-sorts by + * `(server, sortTitle, id)`, so the pipeline runs `sort: "none"`; + * `cursorMode: "keyset"` mints the next cursor from the section-keyed hop token. + */ +export const serverSource: MediaSource = { + sourceId: "library-server", + fetchRawSet, + stages: { sort: "none", cursorMode: "keyset" }, +}; + +/** + * Fetches one Server page from the repo (no drizzle here — R2), threading the + * decoded `(sectionId, sortTitle, id)` keyset cursor and the requested filters. + * A first-page read eagerly seeds a not-yet-seeded user inline (design §Sync + + * hydrate: eager-seed). `partial` is always false (a pure indexed table read). + * `nextRaw` is built from the LAST RETURNED expanded row — never the dropped + * overflow row — via {@link serverToken}, and only on a full page so the cursor + * ends on a short read. + */ +async function fetchRawSet( + ctx: SourceContext, + params: ServerParams, + cursor: Cursor | null, +): Promise<{ rows: ExpandedLibraryRow[]; partial: boolean; nextRaw?: RawPageToken }> { + if (!cursor) await ensureSeeded(ctx); + const decoded = decodeServer(cursor); + const page = await selectServerPage(ctx.userId, params.filters, decoded, params.limit); + const nextRaw = page.nextRow ? serverToken(page.nextRow) : undefined; + return { rows: page.rows, partial: false, ...(nextRaw !== undefined ? { nextRaw } : {}) }; +} diff --git a/apps/server/src/library/sources/timeline.ts b/apps/server/src/library/sources/timeline.ts new file mode 100644 index 00000000..0ca9a46b --- /dev/null +++ b/apps/server/src/library/sources/timeline.ts @@ -0,0 +1,46 @@ +import { selectTimelinePage, type LensFilters } from "../repo"; +import type { LibraryRow } from "../types"; +import { ensureSeeded } from "../internal/reads"; +import type { Cursor, MediaSource, RawPageToken, SourceContext } from "../../media"; +import { decodeTimeline, timelineToken } from "./keyset"; + +/** + * Source params for the Timeline lens: the filter axes plus the page size. Same + * split as {@link AzParams} — the opaque cursor is handled by the keyset codec, + * not carried here. + */ +export interface TimelineParams { + filters: LensFilters; + limit: number; +} + +/** + * The Timeline library lens `MediaSource` (design §The 5 lenses). Identical in + * shape to the A–Z source but pages in `(year DESC, id)` order. The SQL + * pre-sorts, so the pipeline runs `sort: "none"`; `cursorMode: "keyset"` lets + * `paginate` mint the next cursor from the year-keyed hop token. + */ +export const timelineSource: MediaSource = { + sourceId: "library-timeline", + fetchRawSet, + stages: { sort: "none", cursorMode: "keyset" }, +}; + +/** + * Fetches one Timeline page from the repo (no drizzle here — R2), threading the + * decoded `(year, id)` keyset cursor and the requested filters. A first-page + * read eagerly seeds a not-yet-seeded user inline (design §Sync + hydrate: + * eager-seed). `partial` is always false (a pure indexed table read); `nextRaw` + * is emitted only on a full page so the cursor ends on a short read. + */ +async function fetchRawSet( + ctx: SourceContext, + params: TimelineParams, + cursor: Cursor | null, +): Promise<{ rows: LibraryRow[]; partial: boolean; nextRaw?: RawPageToken }> { + if (!cursor) await ensureSeeded(ctx); + const decoded = decodeTimeline(cursor); + const page = await selectTimelinePage(ctx.userId, params.filters, decoded, params.limit); + const nextRaw = page.nextRow ? timelineToken(page.nextRow) : undefined; + return { rows: page.rows, partial: false, ...(nextRaw !== undefined ? { nextRaw } : {}) }; +} diff --git a/apps/server/src/library/types.ts b/apps/server/src/library/types.ts new file mode 100644 index 00000000..d6b4b989 --- /dev/null +++ b/apps/server/src/library/types.ts @@ -0,0 +1,78 @@ +import type { ConsolaInstance } from "consola"; +import type { MediaType } from "@ent-mcp/shared/media"; +import type { WatchedState } from "@ent-mcp/shared/library"; +import type { CatalogService } from "../catalog"; +import type { MediaService } from "../media"; + +/** + * One row the lens sources page off the `library_items` browse projection. It + * is the raw row a `MediaSource.fetchRawSet` emits and the `enrichRows` hook + * consumes; the denormalized columns (`servers`, `qualityTiers`, `watchedState`) + * are read straight off it during enrich rather than re-probed live, which is + * the whole point of the projection (design §Enrich). It is NOT the wire shape — + * `enrichRows` maps it to a `CompactMediaItem`. + */ +export interface LibraryRow { + id: string; + tmdbId: string; + mediaType: MediaType; + sortTitle: string; + year: number | null; + genres: string[]; + servers: { id: string; label: string }[]; + qualityTiers: string[]; + watchedState: WatchedState | null; + collectionId: string | null; + collectionName: string | null; +} + +/** + * A `LibraryRow` carrying the section it expanded into for the `json_each` + * grouped lenses (server/quality). The server/quality SQL joins each owned row + * against `json_each(servers)` / `json_each(quality_tiers)`, so one title yields + * one expanded row per value; `section` is that value (design §The 5 lenses: + * "row dup per value is INTENDED"). `section.id` is the keyset/group key (the + * server connection id, or the quality tier label — which is its own id); + * `section.label` is the human-readable header (the server label, or the tier + * label). `rank` carries the Quality lens's SQL `CASE` ordinal back to the + * source so the hop token reuses the EXACT rank that ordered the page; it is + * absent on the Server lens, whose section id IS the sort key. + */ +export interface ExpandedLibraryRow extends LibraryRow { + section: { id: string; label: string }; + rank?: number; +} + +/** + * Per-request context the library sync surface consumes. Mirrors + * `WatchlistContext`: the resolved handles the read/sync paths need + * (`mediaService` for the `collection@v1` feed, `catalog` for the metadata + * pipeline that later phases hydrate from, and a logger). `log`/`logger` are + * both accepted on the loose input so a home-style `RowContext` flows in + * unchanged; `asLibraryContext` resolves it into this canonical shape. + * + * Phase 1 (membership sync) only needs `userId`, `mediaService`, and `log`; + * `catalog` is carried so the phase-2 hydrate path can read it without + * widening the context again. + */ +export interface LibraryContext { + userId: string; + mediaService: MediaService; + catalog: CatalogService; + deadlineMs?: number; + log: ConsolaInstance; +} + +/** + * The loose per-request context the public surface accepts. `log`/`logger` + * are interchangeable so a home `RowContext` (which names it `logger`) flows in + * unchanged; `asLibraryContext` resolves it into the canonical `LibraryContext`. + */ +export interface MaybeLibraryContext { + userId: string; + mediaService: MediaService; + catalog: CatalogService; + deadlineMs?: number; + log?: ConsolaInstance; + logger?: ConsolaInstance; +} diff --git a/apps/server/src/media/service/index.ts b/apps/server/src/media/service/index.ts index cef205ec..083be32d 100644 --- a/apps/server/src/media/service/index.ts +++ b/apps/server/src/media/service/index.ts @@ -4,7 +4,7 @@ import { dispatchSingle, type AggregateResult, } from "./dispatch"; -import type { CapabilityScope } from "@ent-mcp/shared/plugins"; +import type { CapabilityScope, LibraryItemQuality } from "@ent-mcp/shared/plugins"; import type { SeasonInfo } from "@ent-mcp/shared/home"; import { mediaRequestSchema, @@ -679,6 +679,26 @@ export class MediaService { return interpretAggregate("watchlist@v1", result); } + /** + * Aggregate `collection@v1.getCollection` for the owned-library membership + * sync. Mirrors `getWatchlistFeed`: surfaces the `partial` flag and throws + * `AllPluginsFailedError` on a terminal all-providers failure so the library + * sync can classify the run outcome. The library module is the first consumer + * of this capability (design §Sync + hydrate). + */ + // fallow-ignore-next-line unused-class-member + async getCollectionFeed(opts: { deadlineMs?: number } = {}): Promise> { + const result = await dispatchAggregate({ + userId: this.userId, + capability: "collection", + version: "v1", + method: "getCollection", + input: {}, + deadlineMs: opts.deadlineMs, + }); + return interpretAggregate("collection@v1", result); + } + /** Aggregate `recommendations@v1.getTrending`. */ // fallow-ignore-next-line unused-class-member async getTrendingFeed(opts: { @@ -790,6 +810,77 @@ export class MediaService { return promise; } + /** + * Per-copy quality lookup across every `libraryAvailability@v1` provider for + * the user. Unlike `getMatchingServers` — which only needs the chip and so + * discards `items[].quality` — this returns the raw quality descriptor of + * every owned copy so the library hydrate job can derive its `qualityTiers` + * projection (design §Sync + hydrate: "quality ← checkAvailability PER item"). + * + * This is the N-call fan-out the design flags: one `checkAvailability` per + * provider per title. It is intended for the background hydrate job, never a + * request hot path. Per-plugin failures are dropped (best-effort) and an empty + * array is returned when no provider has the title — a title with no resolvable + * copies hydrates to empty quality tiers rather than throwing. + */ + async getAvailabilityQuality( + tmdbId: string, + type: "movie" | "tv", + opts: { deadlineMs?: number } = {}, + ): Promise { + const providers = capabilityRegistry.listProviders("libraryAvailability", "v1", "user"); + if (providers.length === 0) return []; + const capability = requireCapability("libraryAvailability", "v1"); + const queryType = type === "tv" ? "show" : "movie"; + const perProvider = await Promise.all( + providers.map((pluginId) => + this.probeQuality(pluginId, tmdbId, queryType, capability, opts.deadlineMs), + ), + ); + return perProvider.flat(); + } + + /** + * Returns the quality descriptor of every copy of `tmdbId` on `pluginId`, or + * an empty array when the plugin has no usable connection or the title is + * absent. Mirrors `probeServerLegacy`'s connection walk but keeps the copies + * instead of collapsing them to a single chip. A malformed `quality` payload + * is skipped rather than failing the whole probe. + */ + // fallow-ignore-next-line complexity + private async probeQuality( + pluginId: string, + tmdbId: string, + queryType: "movie" | "show", + capability: ReturnType, + deadlineMs: number | undefined, + ): Promise { + // libraryAvailability@v1 is user-scoped: never borrow admin shared creds. + const conns = await resolveConnections(this.userId, pluginId, "user"); + if (conns.length === 0) return []; + for (const conn of conns) { + const outcome = await invokeOne<{ items: { quality?: LibraryItemQuality }[] }>( + { + userId: this.userId, + pluginId, + capability: "libraryAvailability", + version: "v1", + method: "checkAvailability", + input: { id: tmdbId, idType: "tmdb", type: queryType }, + timeoutMs: capability.defaultTimeoutMs, + deadlineMs, + }, + conn, + ); + if (!outcome.error && Array.isArray(outcome.data?.items) && outcome.data.items.length > 0) { + return outcome.data.items + .map((item) => item.quality) + .filter((quality): quality is LibraryItemQuality => quality != null); + } + } + return []; + } + // fallow-ignore-next-line complexity private async computeMatchingServers( tmdbId: string, diff --git a/docs/2026-06-02-library-backend-design.md b/docs/2026-06-02-library-backend-design.md new file mode 100644 index 00000000..75dac88b --- /dev/null +++ b/docs/2026-06-02-library-backend-design.md @@ -0,0 +1,440 @@ +# Library Backend Design + +Date: 2026-06-02. Status: approved, pre-impl. + +> Style note: doc written ultra-terse + compressed pseudo-code by request. All substance kept; prose stripped. Pseudo-code = shorthand (`→` flow/causality, `?` nullable, `pk/fk/uq` keys, `←` sourced-from). Not literal TS. + +## Goal + +FE library page exists, mock data only. Build BE → wire FE to real data. 5 browse lenses. Priority: max code-share w/ `media`/`home`/`watchlist`. + +## Decisions (locked) + +| # | Topic | Choice | +|---|---|---| +| D1 | Item set | Owned collection ← `collection@v1.getCollection` | +| D2 | Collections lens | Owned-only TMDB franchise grouping (`belongs_to_collection`) | +| D3 | Read model | Paginated lens sources via `media.listRows` | +| D4 | Persistence | Denormalized `library_items` read-model tbl + hydrate job | + +FE = mock-only → its *look* is truth, its data-fetch is not. Rewire FE data layer; keep components. + +## Non-goals + +Full-franchise w/ unowned gaps (owned-only). Filter-aware facet counts (totals only, matches mock). User-curated collections. Write ops (add/remove owned) — read-only v1. + +--- + +## Architecture — thin product shell over `media` (watchlist sibling) + +``` +apps/server/src/library/ + index.ts # barrel = public API only (fallow boundary) + service.ts # thin: facets summary, collections grouping + internal/ + context.ts # buildCtx → {MediaService, CatalogService, StatusBatchMemo} + media-sources.ts # libraryMediaSources registry (4 lens regs) + facets.ts # facet-count agg (SQL GROUP BY) + collections.ts # group-first franchise logic + hydrate.ts # denorm hydrate (catalog + availability + progress → cols) + sources/{az,timeline,server,quality}.ts # 1 MediaSource per lens + sources/keyset.ts # lens cursor codecs (mirror watchlist/sources/keyset.ts) + jobs/sync-library.ts # collection@v1 → membership + hydrate + errors.ts events.ts __tests__/ +db/schema/library/library-items.ts # library-owned tbl +``` + +Library owns its tbl (distinct owned-set; `media` core untouched). Reuse: `listRows`, enrich, classify, cursor, `Page`, registry, sync-job pattern. Run `backend-feature-architecture` skill @ impl. + +Boundaries (fallow): `server-mod-library` (barrel only out), `server-mod-library-internal` (sources/repo/hydrate). Import `media` barrel only — never media internals. + +--- + +## Data model + +### `library_items` (denorm browse projection) + +``` +library_items tbl { + id pk # composite "movie:550" + userId fk→user cascade + tmdbId ; mediaType:enum MEDIA_TYPES + owned bool=T ; ownedAt ; unownedAt? # lifecycle, tombstone (no-resurrect, watchlist pattern) + + # --- denorm sort keys ← canonical_metadata --- + sortTitle="" : str # normalized: articles stripped, lowercased, diacritics fold + year? : int # release year + + # --- denorm facet/filter keys --- + genres : json[str] = [] + servers : json[{id,label}] = [] # ← libraryAvailability@v1 + qualityTiers : json[str] = [] # ← libraryAvailability@v1 quality copies + watchedState? : enum WATCHED_STATES # watched|partial|unwatched ← progress + + # --- franchise (collections lens) --- + collectionId? ; collectionName? # TMDB belongs_to_collection + + hydratedAt? : int # denorm freshness marker + + idx: + uq(userId, tmdbId, mediaType) + (userId, owned, sortTitle, id) # az keyset + (userId, owned, year, id) # timeline keyset + (userId, owned, collectionId) # collections group + # servers/qualityTiers multi-valued → facet/filter via json_each +} +``` + +`servers`/`qualityTiers` multi-valued: item on Plex+Jellyfin in 4K+1080p → 1 row, arrays hold all. Server/quality lenses expand via `json_each` → item appears per matching section. + +### Franchise threading (D2) — orthogonal, needed for collections lens + +TMDB `/movie/{id}` already returns `belongs_to_collection` → no extra call. Thread through existing metadata→catalog pipeline: + +``` +tmdb MovieRaw + belongs_to_collection?:{id,name,poster_path,backdrop_path} +tmdb mapMovie() → emit collection:{id:str,name:str} | null (movies only; TV→null) +shared mediaItem zod + collection?: {id,name}.nullable() +CanonicalMetadata + collectionId:str|null + collectionName:str|null +canonical_metadata tbl + 2 cols (migration) +catalog toCanonicalRow() → persist from mediaItem.collection +``` + +--- + +## Sync + hydrate — 1 job, mirror watchlist + +`jobs/sync-library.ts` → `library.sync` (cron `0 */6 * * *` + eager-seed on first read, seed-lock): + +``` +sync(userId): + # phase 1: membership + feed = dispatchAggregate(collection@v1.getCollection, {}) # library = 1st consumer of this cap + known = allKnownKeys(userId) + upsert owned rows ∀ feed∌known # owned=T, ownedAt=parseEpoch(entry.addedAt) // addedAt = ISO str + tombstone ∀ known∌feed # owned=F, unownedAt=now (no resurrect) + + # phase 2: hydrate denorm (new + stale rows) + rows = staleOrNew(userId) # hydratedAt null | older than TTL + meta = catalog.getMetadataBatch(keys) → sortTitle, year, genres, collectionId/Name + # avail NOT free: listAvailable → {tmdbIds} presence ONLY (no server id, no quality). + # server id ← getMatchingServers/probeServer (existing path) + # quality ← checkAvailability PER item → items[].quality (getMatchingServers DISCARDS quality) + # ∴ qualityTiers hydrate = N-call fan-out (N = owned titles × providers). OK in bg job, not a free ride. + servers ← getMatchingServers(key) + qualityTiers ← checkAvailability(key).items[].quality # N-call fan-out + prog = loadProgressMap(keys) → watchedState + write cols ; set hydratedAt=now +``` + +Cadence: membership 6h. Availability re-hydrate hourly (staleness window = A's cost). Eager-seed = membership-only fast path on first read; hydrate lazy/async. + +`collection@v1` dispatched nowhere today → new `MediaService.getCollectionFeed()` wrapper (mirror `getWatchlistFeed`). + +--- + +## The 5 lenses + +| Lens | Endpoint | Res shape | Sort / cursor | +|---|---|---|---| +| A–Z | `GET /api/media/sources/library-az` | `Page` | keyset `(sortTitle, id)` | +| Timeline | `…/library-timeline` | `Page` | keyset `(year DESC, id)` | +| Server | `…/library-server` | `Page` | keyset `(server, sortTitle, id)`, json_each | +| Quality | `…/library-quality` | `Page` | keyset `(tierRank DESC, sortTitle, id)`, json_each | +| Collections | `GET /api/library/collections` | `{collections:[{id,title,count,preview:CompactMediaItem[≤4]}], cursor}` | group-first by `collectionId` | + +### Item lenses (az/timeline/server/quality) — unified registry + +Register in `media` unified `REGISTRY` → served by **existing** `GET /api/media/sources/:sourceId`. Zero new read-routing. + +``` +# 1 MediaSource per lens. SQL pre-sorts → stages.sort="none". +azSource: MediaSource { + sourceId: "library-az" + fetchRawSet(ctx, params, cursor): + rows = SQL select from library_items + where userId AND owned AND + [keyset (sortTitle,id) > cursor] + order by sortTitle, id limit N+1 + → { rows, partial:F, nextRaw: rawToken(last) } + stages: { sort:"none", cursorMode:"keyset" } # filter applied in SQL, not pipeline +} + +# registration (mirror watchlist itemsRegistration): +libraryMediaSources = { + "library-az": reg(azSource, paramSchema, cursorMode:"keyset", rateLimit:"read") + "library-timeline": reg(timelineSource, …) + "library-server": reg(serverSource, …) + "library-quality": reg(qualitySource, …) +} +# api/procedures/media.ts: +REGISTRY = { ...homeMediaSources, ...watchlistMediaSources, ...libraryMediaSources } +``` + +server/quality SQL (multi-valued): + +``` +select li.* from library_items li, json_each(li.servers) sv +where userId AND owned AND +[keyset (sv.value->>'id', sortTitle, id) > cursor] +order by sv.value->>'id', sortTitle, id limit N+1 +# row dup per server → that's intended (item in each server section) +``` + +quality: same w/ `json_each(qualityTiers)`, order by `rankQualityTier(value) DESC`. + +### Cursor codecs (`sources/keyset.ts`) — mirror watchlist + +``` +# per-lens token. reuse Cursor {mode:"keyset", k:str}, decode never throws. +azToken(row) = `${sortTitle}${id}` +timelineToken(row) = `${year ?? 0}${id}` +serverToken(row,sv)= `${sv.id}${sortTitle}${id}` +qualityToken(...) = `${tierRank}${sortTitle}${id}` +decodeX(cursor) → fields | undefined # bad/foreign → undefined → first page +``` + +### Enrich — custom `enrichRows` path (home pattern), availability from denorm (no re-probe) + +MUST use `enrichRows` hook, NOT default `batchLoad`+`enrich`. Default re-probes availability live (`getMatchingServersCached`) → defeats denorm; AND collapses to 1 item per `(tmdbId,mediaType)` → kills json_each fan. enrichRows reads denorm cols. + +``` +listRows(librarySource, cfg, ctx, enrichRows) + → fetchRawSet → page rows (library_items; server/quality = json_each-expanded, dup per value) + → enrichRows(rows): + title/year/poster/backdrop/genres/overview ← catalog (batchLoad) # reuse + status, progress, watchedState ← batchLoad / denorm # reuse + availability.servers, tags(quality) ← row.servers/qualityTiers # denorm, NO re-probe + → classify (reuse) → sort:"none" → paginate(keyset) +``` + +server/quality dup rules (az/timeline = 1 row/item, no dup): +- page `limit` counts EXPANDED rows (item×value), not distinct titles. +- enrichRows MUST NOT dedup/collapse on `id` → 1 CompactMediaItem per expanded row; same title repeats across sections (intended). +- keyset tuple unique per expanded row (`(server,sortTitle,id)` / `(tierRank,sortTitle,id)`) = monotonic → cursor stable. + +Page touches O(page) — SQL filter/sort by index. No whole-set materialize. + +### Collections lens — group-first (`/api/library/collections`) + +``` +GET /api/library/collections?cursor&limit& + groups = SQL select collectionId, collectionName, count(*), + group_concat preview ids (≤4) + from library_items + where userId AND owned AND collectionId NOT NULL AND + group by collectionId order by collectionName [keyset] limit N+1 + preview = enrich(previewIds) → CompactMediaItem[≤4] # for poster fan + → { collections:[{id:"collection:", title, count, preview}], cursor } +``` + +Owned-only → only franchises w/ ≥1 owned movie. Standalone/TV → collectionId null → excluded. + +### Facets (`/api/library/facets`) + +``` +GET /api/library/facets # unfiltered totals (matches mock look) + → { + kinds: {movie:n, tv:n} # GROUP BY mediaType + genres: {:n} # json_each(genres) GROUP BY + qualities:{:n} # json_each(qualityTiers) + servers: {:n} # json_each(servers) + watched: {watched:n, partial:n, unwatched:n} # GROUP BY watchedState + letters: ["A".."Z","#"] # distinct first-char(sortTitle) — az rail + decades: [2020,2010,…] # distinct (year/10*10) DESC — timeline + } + cache short-TTL, invalidate on sync. +``` + +--- + +## Shared pkg `@ent-mcp/shared/library` — mirror watchlist + +``` +packages/shared/src/library/ + enums.ts: + LIBRARY_LENSES = ["az","timeline","collections","server","quality"] as const # move from client + WATCHED_STATES = ["watched","partial","unwatched"] as const # move from client + QUALITY_TIERS = [ordered hi→lo] as const # rank ref + types.ts: + LibraryCollection = {id, title, count, preview: CompactMediaItem[]} + LibraryFacetCounts = {kinds,genres,qualities,servers,watched,letters,decades} + LibraryCollectionsResponse = {collections: LibraryCollection[], cursor: str|null} + # lens res = media Page (reuse, no new type) + schemas.ts (zod): + libraryLensQuerySchema = {cursor?, limit≤200=60, kinds[]?, genres[]?, qualities[]?, servers[]?, watched[]?} + libraryCollectionsQuerySchema = {cursor?, limit?, ...filters} + index.ts: export * from enums/types/schemas +``` + +`packages/shared/package.json` exports: `+ "./library": "./src/library/index.ts"`. + +Filters = query params → SQL `WHERE` (kinds=mediaType IN, genres/qualities/servers=json_each EXISTS, watched=watchedState IN). Empty axis → no filter. + +--- + +## API routes + +``` +api/router.ts: + .route("/library", libraryApp) +libraryApp = Hono().use(requireSession) + .get("/collections", zValidator(query), → service.listCollections) + .get("/facets", → service.getFacets) +# item lenses NOT here → unified /api/media/sources/:sourceId (registry) +rateLimit: reuse read TokenBucketLimiter bucket +``` + +--- + +## FE rewire (data layer only — look preserved) + +Grid/tabs/filter-popover layout unchanged (same `CompactMediaItem`). Card = net-new quality-tag chip render (`tags` was always-undefined in mock → rendered nothing). Swap data: + +``` +hooks/use-library-content.ts → per-lens useInfiniteQuery: + az/timeline/server/quality → api GET /media/sources/library- {cursor, ...filters} + → flat sorted item stream; client inserts section header on group-key change +collections lens → api GET /library/collections +facets → useQuery /library/facets → popover badges + az letter rail + timeline decades +filters (URL search params) → query params on lens req +query-keys: per-lens + filter params +DELETE: __fixtures__ mock + fetchLibrary() stub + lib/grouping (server groups now) +ADD: infinite scroll → reuse virtualized-card-grids (docs/2026-05-21) +KEY: server/quality lens repeats a title per section → list key = id+section (not id alone) +``` + +Run `frontend-feature-architecture` skill @ impl. infinite scroll = new vs current "fetch-all"; look same (grid identical). + +--- + +## Cache / errors / tests + +**Cache**: facets + collections short-TTL, invalidate on sync. Lens pages → SQL + batchLoad caches. + +**Errors**: +- plugin fan-out fail → `Page.partial=T` (pipeline supports). enrich tolerates null meta. +- no `collection@v1` provider → empty library, eager-seed no-op, FE empty state. +- typed `errors.ts` + reuse api error mapping. + +**Tests** (Rule 9 — encode intent): +- sync: diff upsert/tombstone idempotent; tombstone NOT resurrected next sync. +- hydrate: cols populated ← catalog+avail+progress; stale → re-hydrate. +- lens: each sort/keyset stable across page boundary; filters applied; multi-server item appears in BOTH server sections (json_each). +- facets: counts correct incl json_each multi-valued; letters/decades present-only. +- collections: owned-only; preview ≤4; pagination; TV/standalone excluded. +- franchise: belongs_to_collection mapped+persisted; TV→null. +- FE: lens hook → correct source+params; section-header insert; filter round-trip; infinite scroll fetches next cursor. + +--- + +## Code-share scorecard (priority) + +**Reused**: `media.listRows`/enrich/classify/batchLoad · cursor + `Page` · `MediaSourceRegistration` + unified REGISTRY + `/api/media/sources` route · sync-job pattern (seed/sync + cron + eager-seed) · repo keyset mechanics · catalog metadata pipeline · virtualized grid · shared-subpath + feature-arch conventions. + +**New (justified)**: `library_items` denorm tbl + repo · 1 sync/hydrate job · 4 lens sources + codecs · facets agg · collections endpoint · `@ent-mcp/shared/library` · franchise threading · FE data-layer swap. + +--- + +## Phasing + +| Ph | Scope | Shippable | +|----|-------|-----------| +| 1 | shared/library + `library_items` tbl + `.fallowrc.json` zones (`server-mod-library` + `-internal`) + franchise threading (metadata→catalog) + sync (membership) | ✓ | +| 2 | hydrate denorm + az/timeline sources + registry wiring + facets endpoint | ✓ | +| 3 | server/quality (json_each) + collections endpoint | ✓ | +| 4 | FE rewire (real endpoints, drop mock, infinite scroll) | ✓ | + +### Changesets (per CLAUDE.md) + +``` +@ent-mcp/client minor # library page now backed by real data +@ent-mcp/server minor # added a media library browser +@ent-mcp/plugin-sdk minor # metadata items can carry collection membership +@ent-mcp/plugin-tmdb minor # tmdb reports movie franchise/collection +# shared = internal, never listed +``` + +1 logical change per file. End-user language, past tense. + +--- + +## Known fuzzy areas (Rule 12) + +- **Quality tier rank**: `qualityTiers` = free-form plugin strings ("4K HDR","Atmos","1080p") not fixed enum. Quality lens needs hi→lo fidelity order → `rankQualityTier(label)` heuristic: 2160p/4K > 1080p > 720p > SD; HDR/DV/Atmos = modifiers; unknown → bottom. Inexact by nature. `QUALITY_TIERS` tuple = canonical anchor. +- **Facets unfiltered**: counts = totals, not filter-aware. Matches mock. Flip later if wanted. +- **Collections shape**: return `preview:CompactMediaItem[≤4]` not mock's `itemIds:string[]` → card fans posters w/o 2nd fetch. Minor FE type change. +- **Eager-seed latency**: first-ever read blocks on collection@v1 membership fetch. Hydrate async after → first paint may show un-hydrated rows (no servers/quality/franchise). Acceptable; FE skeleton covers. + +--- + +## Implementation status + +### Phase 1 — done (✓ shippable) + +Shipped: `@ent-mcp/shared/library` subpath (`LIBRARY_LENSES`/`WATCHED_STATES`/`QUALITY_TIERS` + types + zod schemas), `library_items` + `user_library_seed` tables (migration `0004_ordinary_whizzer.sql`), fallow zones (`server-mod-library` / `-internal` / `server-schema-library`), franchise threading (tmdb `belongs_to_collection` → `mapMovie` → mediaItem zod → `CanonicalMetadata.collectionId/Name` → `canonical_metadata` 2 cols → `toCanonicalRow`), `MediaService.getCollectionFeed()`, membership sync job (`library.sync`, cron `0 */6 * * *`). Tests: sync idempotent/no-resurrect/no-wipe, tmdb mapping, catalog persist. Changesets: server, plugin-tmdb, plugin-sdk. + +Deviations from the sketch above (all deliberate): +- **Drizzle lives in `repo.ts`, not `internal/{facets,collections,hydrate}.ts`.** The `backend-feature-architecture` skill (Rule 2) is authoritative over the doc's pseudo-layout: the "SQL in internal/" lines are shorthand; real queries are in `repo.ts` (→ `repo/` dir once it grows). `internal/`/`sources/` orchestrate and call repo. +- **`CanonicalMetadata.collectionId/collectionName` are `?: string | null`** (optional) to keep the change non-breaking across ~15 existing literals. Data still always flows (toCanonicalRow emits both). +- **Tombstone sweep runs only on a COMPLETE, non-empty feed.** The phase-1 pseudo-code's unconditional `tombstone ∀ known∌feed` would wipe the entire owned library on a transient all-providers outage or a disconnected provider (empty/partial feed). The sweep is now guarded by `!partial && feedKeys.length > 0`, matching the doc's own §Errors "no provider → eager-seed no-op" intent. Trade-off: a collection legitimately emptied to exactly zero is not swept (a no-op per design). +- **`writeMetadata` conflict-UPDATE now sets `collectionId/collectionName`** (was insert-only) so franchise data learned on a metadata re-fetch persists. +- **`worker.ts` intentionally does NOT register `library.registerJobs()`** — croner cron jobs cannot run in the Workers isolate (test-pinned carve-out; sibling `watchlist.sync` is likewise excluded). Wired in `index.ts` only. +- **Deferred to later phases**: `events.ts` (cache invalidation — phase 2 when a facets cache exists); the eager-seed call-site (`trySeedLock`/`clearSeedLock` infra exists, but no read endpoint exists yet to trigger first-read seeding — wire in phase 2); the client tuple move + `QUALITY_TIERS` reconciliation (phase 4 FE rewire). + +### Phase 2 — done (✓ shippable) + +Shipped: `repo/` promoted to a dir (`membership`/`seed`/`hydrate`/`lens-pages`/`facets` + barrel); denorm hydrate (`internal/hydrate.ts` → catalog `getMetadataBatch` + `getMatchingServers` + new `MediaService.getAvailabilityQuality` quality fan-out + `loadProgressMap`); az + timeline `MediaSource`s with keyset codecs + `enrichRows` reading denorm (no re-probe, no collapse) wired into the unified `REGISTRY` (served by existing `/api/media/sources/:sourceId`); `GET /api/library/facets` (unfiltered totals, json_each multi-valued, present-only letters/decades) with a 60s per-user cache busted on sync; eager-seed-on-first-read on the lens path; second cron `library.hydrate` (hourly) for availability staleness. `library-az`/`library-timeline` added to `MEDIA_SOURCE_IDS`. + +Deviations / fixes (adversarial verify caught 2 paging blockers + 1 facet bug, all fixed + regression-tested): +- **Lens keyset cursor** now encodes the *last returned* row, not the dropped `limit+1` overflow row (the overflow encoding silently skipped exactly one row per page on both lenses). +- **Timeline `ORDER BY`** uses `COALESCE(year,0) DESC` to match the cursor predicate (raw `year DESC` is NULLS-last in SQLite and disagreed with the `COALESCE` predicate → dropped/duplicated undated rows at the page boundary). +- **Facet json_each counts** use `count(DISTINCT id)` so a row with a duplicated array value (dirty metadata) counts once. +- **`watchedState` is sparse (known limitation).** It is derived from `loadProgressMap`, which only surfaces *active, unfinished* continue-watching entries — so `partial` populates but `watched` (fully played) is unreachable and never-started maps to `null`. The `watched` facet/filter axis is therefore near-empty in phase 2. Proper fix (followup): source a played/`watchHistory@v1` signal to populate the full three-way axis. Surfaced rather than silently shipped. +- **Multi-value filter axes** (`?genres=A&genres=B`) collapse to the first value through the unified `c.req.query()` resolver; encoding (comma-join vs `c.req.queries()`) is settled in the phase-4 FE rewire. +- **`getAvailabilityQuality`** added to `MediaService` (the one media touch) — `getMatchingServers` discards `items[].quality`, so the quality fan-out needed its own public method. + +### Phase 3 — done (✓ shippable) + +Shipped: server/quality `json_each` lenses (`sources/{server,quality}.ts` + `repo/lens-pages` `selectServerPage`/`selectQualityPage` + grouped keyset codecs, `CompactMediaItem.section` surfacing, `library-server`/`library-quality` in `MEDIA_SOURCE_IDS` + the unified `REGISTRY`); and the group-first collections endpoint — `repo/collections.ts` (`selectCollections`/`selectRowsByIds`), `service.listCollections`, and `GET /api/library/collections` (`requireSession` + the shared read `TokenBucketLimiter`). + +Collections endpoint detail: +- **Owned-only + TV/standalone excluded enforced in SQL.** The grouping WHERE scopes to `owned = true` AND `collection_id IS NOT NULL`, so a franchise surfaces only with ≥1 owned movie and standalone/TV (null `collection_id`) never appears. +- **Per-group preview (≤4) via a correlated subquery, NOT a bare `group_concat`.** SQLite `group_concat` cannot order-and-limit per group, so each group's preview ids are selected in an inner `ORDER BY sort_title, id LIMIT 4` subquery and concatenated in the outer one. Preview order is documented as `(sortTitle, id)` ascending (same as the A–Z lens) so the poster fan is stable run-to-run. +- **Keyset on `(collection_name, collection_id)`, phase-2 discipline applied.** The next cursor encodes the LAST RETURNED group (never the dropped `limit+1` overflow group), and the cursor predicate compares the SAME `(collection_name, collection_id)` the `ORDER BY` uses — no group dropped/duplicated at a page boundary. The cursor is an opaque `" "` token (`internal/collections-cursor.ts`), split on the last space (id is space-free), total-decode (bad/foreign → first page, never 400) — mirroring the lens keyset codecs. This endpoint mints its own cursor because it does NOT ride the media `paginate` stage. +- **Preview enrich reuses the lens dedup-free `buildEnrichRows`** in ONE batch for the whole page (one metadata/progress round trip, not one per franchise) — reads the denormalized `servers`/`qualityTiers`, no availability re-probe. A preview id whose metadata could not resolve is dropped from the fan rather than rendering a blank card. +- **Eager-seed on first read only.** A no-cursor read seeds membership via the same `ensureSeeded` path the lenses use; a paged-into read skips the seed-lock round trip. +- **`ROW_COLUMNS` + `ownedFilterConditions` exported from `repo/lens-pages`** so the collections repo selects the identical `LibraryRow` projection and applies the identical owned + filter predicate (one source of truth; the filter axes behave the same on every lens). + +Deviations / fixes (adversarial verify caught a keyset blocker, a tenancy gap, a filter-leak, and a latent multi-tenancy bug — all fixed + regression-tested): +- **Multi-tenancy PK fix (the load-bearing one).** `library_items.id` was a single global `text PRIMARY KEY`, but `id` is `":"` (no user) — so two users owning the same title collided on the PK and the membership upsert's `ON CONFLICT DO NOTHING` silently dropped the second owner. The `uq(userId, tmdbId, mediaType)` index is itself the proof the design intends multi-user same-title. Fixed to a **composite primary key `(user_id, id)`** (`id` stays `"type:tmdbId"` so it still equals the catalog metadata `candidateId` enrich keys on; it is unique only *within* a user). Migration `0004` regenerated. +- **Collections null-name keyset.** The `ORDER BY` and cursor predicate compared the raw nullable `collection_name`, but the encoded cursor uses `collection_name ?? collection_id` — so null-name franchises were silently dropped across a page boundary (the phase-2 timeline COALESCE lesson, not carried over). Both now compare `COALESCE(collection_name, collection_id)`. +- **Collections preview was not filter-aware.** The group `count` honored the active filters but the preview poster fan did not, so it could surface titles excluded from the count. The preview correlated subquery now applies the same filter predicates → preview ⊆ the counted set. +- **`selectRowsByIds` tenancy scope.** Preview hydration read rows by bare `id IN (…)`; the composite id is global, so it now also scopes to `user_id` + `owned = true` (every library read is owned-set scoped). +- **Quality rank direction.** "tierRank DESC fidelity" is realized as an ASCENDING sort on the `QUALITY_TIERS` ordinal (0 = highest), so the keyset predicate is correctly `rank > cursor` (a larger ordinal = lower fidelity = later). `QUALITY_RANK_UNRANKED === QUALITY_TIERS.length` keeps the SQL `CASE … ELSE` arm and the JS `rankQualityTier` in lockstep. +- **Section surfacing.** Server/quality lenses repeat a title per section; each expanded row carries the section via an additive optional `CompactMediaItem.section?: { id, label }` (set only by these two lenses) so the FE inserts headers on group-key change and keys list rows by `id + section`. + +### Phase 4 — done (✓ shippable) + +Shipped: the library FE rewired from mock to real data, look preserved. The four item lenses go through the existing shared `useMediaRows` infinite-query hook against `GET /api/media/sources/library-` (no new client fetch path — same as home/watchlist); collections via `api.library.collections`, facets via `api.library.facets` (non-blocking `useQuery`). `lib/fetchers.ts` (Hono `api.*` + `LibraryApiError`), per-lens+filter query keys, and a pure `section-groups` helper that inserts headers on group-key change over the flat sorted stream (az letter / timeline decade / server-quality `item.section`), keying server/quality rows by `id + section`. Infinite scroll via the shared `VirtualGrid` (`onEndReached` guarded by `shouldFetchNext`). Quality chips render from `CompactMediaItem.tags`; collections render the `preview: CompactMediaItem[]` poster fan with the server `count` badge. A-Z letter rail and timeline decade rail both driven by `/facets` (`letters` / `decades`, present-only). Filters round-trip URL → query params. `LIBRARY_LENSES`/`WATCHED_STATES`/`QUALITY_TIERS` now imported from `@ent-mcp/shared/library`; the mock fixtures, `fetchLibrary` stub, and client-side grouping/filtering deleted. Added `.fallowrc.json` allows for `client-feat-library` → `client-shared-virtualized` + `client-shared-media` (the mandated reuse). Changeset: `@ent-mcp/client` minor. + +Deviations / fixes (adversarial verify + tests caught a functional filter bug + an i18n regression): +- **Servers filter alignment.** The `servers` facet keys on the human `label`, but the lens + collections filter predicates matched on the connection `id` — so any server filter matched nothing. Fixed: the filter predicates now match on `value ->> 'label'` (facet key == popover value == filter value); the server lens still *sections* by `id`. Regression-tested. +- **Timeline `unknown` localized.** `section-groups` now emits a stable i18n-free key; the display label resolves at the render boundary via `timelineSectionLabel` → `m.library_timeline_unknown()` (was rendering the raw English literal in all locales). +- **`decades` facet now consumed** — wired into a timeline decade jump-rail mirroring the A-Z letter rail (was computed by `/facets` but unused). +- **Known limitation (carried):** multi-value filter axes (`?genres=A&genres=B`) on the *item lenses* collapse to the first value because the unified `/media/sources/:id` resolver reads `c.req.query()` (single-value); collections + facets honor multi-value via their own routes. Server-side followup: have the resolver read `c.req.queries()`. + +### Cross-phase bug ledger (found by adversarial verify / Rule-9 tests, all fixed + regression-guarded) + +1. Membership sync wiped the whole owned library on a transient all-providers outage (empty `feedKeys` swept every row) → sweep gated on a complete non-empty feed. +2. `writeMetadata` conflict-UPDATE dropped franchise columns → added to the SET clause. +3. Lens keyset encoded the dropped `limit+1` overflow row → encode the last *returned* row. +4. Timeline `ORDER BY` disagreed with the `COALESCE` cursor predicate on NULL years → aligned. +5. Facet `count(*)` over `json_each` double-counted duplicate array values → `count(DISTINCT id)` (table-qualified to avoid the `json_each` `id` ambiguity). +6. **Multi-tenancy:** single global `id` PK forbade two users owning the same title → composite `(user_id, id)` PK. +7. Collections keyset dropped NULL-name franchises across a page boundary → `COALESCE(collection_name, collection_id)` in ORDER BY + predicate. +8. Collections preview fan wasn't filter-aware while the count was → filters threaded into the preview subquery. +9. `selectRowsByIds` preview hydration was unscoped (cross-tenant read) → scoped to `user_id` + `owned`. +10. Server/quality lenses selected a drizzle column-object over a raw `json_each` FROM (libsql runtime error on every request) → table-qualified `EXPANDED_ROW_COLUMNS`. +11. Servers filter matched `id` while the facet/popover used `label` → matched on `label`. + +**Test totals:** server `apps/server` 648 + full monorepo 2674 passing; client library 44. `vp check` clean (1553 files); `fallow dead-code` → 0 boundary violations, baseline unchanged. diff --git a/packages/plugin-sdk/src/capabilities/shared-schemas.ts b/packages/plugin-sdk/src/capabilities/shared-schemas.ts index bc8b62f0..70bc9288 100644 --- a/packages/plugin-sdk/src/capabilities/shared-schemas.ts +++ b/packages/plugin-sdk/src/capabilities/shared-schemas.ts @@ -38,6 +38,10 @@ export const mediaItem = z.object({ writers: z.array(z.string()).optional(), creators: z.array(z.string()).optional(), keywords: z.array(z.string()).optional(), + // Franchise membership for the collections lens. Optional and nullable so + // existing plugins that never set it stay valid; movies set it when the + // title belongs to a TMDB collection, everything else reports null. + collection: z.object({ id: z.string(), name: z.string() }).nullable().optional(), }); export type MediaItemShape = z.infer; diff --git a/packages/plugins/tmdb/__tests__/mappers.test.ts b/packages/plugins/tmdb/__tests__/mappers.test.ts index 73b7f81f..f1dc13f9 100644 --- a/packages/plugins/tmdb/__tests__/mappers.test.ts +++ b/packages/plugins/tmdb/__tests__/mappers.test.ts @@ -37,3 +37,50 @@ describe("tmdb mappers — backdrop lift", () => { expect(out.backdropUrl).toBeNull(); }); }); + +// The collections lens (design D2) groups owned movies by their TMDB franchise. +// That grouping only works if mapMovie threads `belongs_to_collection` into a +// stable `collection: { id, name } | null` field — and TV never carries one, +// since TMDB has no franchise concept for shows. These tests fail loudly if any +// of those three invariants regress. +describe("tmdb mappers — franchise threading (D2)", () => { + it("mapMovie: belongs_to_collection → collection with STRINGIFIED id", () => { + const ctx = makeCtx([]) as unknown as Ctx; + // TMDB sends the collection id as a number alongside artwork paths; the + // canonical row must drop the artwork and stringify the id so downstream + // keying (`collection:`) and persistence stay text-based. + const raw = { + ...MOVIE_RAW, + belongs_to_collection: { + id: 10, + name: "Some Collection", + poster_path: "/coll-poster.jpg", + backdrop_path: "/coll-backdrop.jpg", + }, + } as MovieRaw; + const out = mapMovie(ctx, raw) as { collection: { id: string; name: string } | null }; + // The id must be the string "10", not the number 10 — keying depends on it. + expect(out.collection).toEqual({ id: "10", name: "Some Collection" }); + expect(typeof out.collection?.id).toBe("string"); + }); + + it("mapMovie: no belongs_to_collection → collection null (standalone film)", () => { + const ctx = makeCtx([]) as unknown as Ctx; + // MOVIE_RAW has no belongs_to_collection, so a standalone film maps to null + // and is excluded from the owned-only collections lens. + const out = mapMovie(ctx, MOVIE_RAW as MovieRaw) as { collection: unknown }; + expect(out.collection).toBeNull(); + }); + + it("mapShow: TV item always emits collection null, even given franchise-like data", () => { + const ctx = makeCtx([]) as unknown as Ctx; + // TMDB shows have no franchise concept; even if a collection-shaped payload + // is forced onto the raw, the show mapper must never thread it through. + const raw = { + ...SHOW_RAW, + belongs_to_collection: { id: 99, name: "Bogus Show Collection" }, + } as unknown as TvRaw; + const out = mapShow(ctx, raw) as { collection: unknown }; + expect(out.collection).toBeNull(); + }); +}); diff --git a/packages/plugins/tmdb/src/mappers.ts b/packages/plugins/tmdb/src/mappers.ts index 965fb1de..16a046b6 100644 --- a/packages/plugins/tmdb/src/mappers.ts +++ b/packages/plugins/tmdb/src/mappers.ts @@ -122,6 +122,10 @@ export function mapMovie(ctx: Ctx, m: MovieRaw): unknown { overview: m.overview ?? "", posterUrl: buildPosterUrl(ctx, m.poster_path ?? null), backdropUrl: buildBackdropUrl(ctx, m.backdrop_path ?? null), + // Franchise grouping for the collections lens; null when the film stands alone. + collection: m.belongs_to_collection + ? { id: String(m.belongs_to_collection.id), name: m.belongs_to_collection.name } + : null, ids: { tmdb_id: String(m.id), imdb_id: imdb || undefined, @@ -148,6 +152,8 @@ export function mapShow(ctx: Ctx, s: TvRaw): unknown { overview: s.overview ?? "", posterUrl: buildPosterUrl(ctx, s.poster_path ?? null), backdropUrl: buildBackdropUrl(ctx, s.backdrop_path ?? null), + // TMDB has no franchise concept for shows, so TV items never carry a collection. + collection: null, ids: { tmdb_id: String(s.id), imdb_id: imdb || undefined, diff --git a/packages/plugins/tmdb/src/types.ts b/packages/plugins/tmdb/src/types.ts index db884923..f992ff95 100644 --- a/packages/plugins/tmdb/src/types.ts +++ b/packages/plugins/tmdb/src/types.ts @@ -77,6 +77,14 @@ export interface MovieRaw { imdb_id?: string | null; credits?: Credits; keywords?: { keywords?: Keyword[] }; + // TMDB returns this on `/movie/{id}` when the film is part of a franchise. + // Threaded into the canonical row to power the collections lens. + belongs_to_collection?: { + id: number; + name: string; + poster_path: string | null; + backdrop_path: string | null; + }; } export interface TvRaw { diff --git a/packages/shared/package.json b/packages/shared/package.json index e4519de9..43374e4a 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -14,6 +14,7 @@ "./diagnostics": "./src/diagnostics/index.ts", "./home": "./src/home/index.ts", "./jobs": "./src/jobs/index.ts", + "./library": "./src/library/index.ts", "./media": "./src/media/index.ts", "./notifications": "./src/notifications/index.ts", "./plugins": "./src/plugins/index.ts", diff --git a/packages/shared/src/catalog/types.ts b/packages/shared/src/catalog/types.ts index c5260a72..97d02893 100644 --- a/packages/shared/src/catalog/types.ts +++ b/packages/shared/src/catalog/types.ts @@ -18,6 +18,12 @@ export interface CanonicalMetadata { overview: string | null; originalLanguage: string | null; genres: string[] | null; + // TMDB franchise grouping that powers the collections lens. Null for + // standalone titles and all TV, which TMDB never groups into collections. + // Optional on the interface so existing `CanonicalMetadata` literals stay + // valid; `toCanonicalRow` always emits both, defaulting to null. + collectionId?: string | null; + collectionName?: string | null; features: CanonicalFeatures | null; lastRefreshedAt: number; lastAccessedAt: number; diff --git a/packages/shared/src/home/types.ts b/packages/shared/src/home/types.ts index 50e7deb7..26f07a02 100644 --- a/packages/shared/src/home/types.ts +++ b/packages/shared/src/home/types.ts @@ -108,8 +108,11 @@ export interface CompactMediaItem { name?: string; }; /** - * Reserved for a future media-features capability (e.g. `["4K","HDR","Atmos"]`). - * Always undefined in v1; the client renders nothing when absent. + * Free-form display tags. The library lenses populate this with the title's + * quality-tier strings (e.g. `["1080p","4K"]`), which the card chip strip and + * the quality facet read. Home and discovery sources leave it undefined (the + * reserved media-features capability, e.g. `["4K","HDR","Atmos"]`, is not yet + * emitted there); the client renders nothing when absent. */ tags?: string[]; /** @@ -119,6 +122,18 @@ export interface CompactMediaItem { addedAt?: number | null; /** How a persistent-table row entered the watchlist; absent/null on discovery rows. */ addedSource?: WatchlistSource | null; + /** + * Which section this row belongs to within a section-grouped lens. Set ONLY + * by the library `server`/`quality` lenses, whose `json_each` expansion makes + * the same title appear once per server / quality tier — so a flat page can + * carry the same `id` more than once, each occurrence tagged with the section + * it expanded into. `id` is the group key (the server connection id, or the + * tier label); `label` is the human-readable header text. The library FE + * inserts a section header whenever `section.id` changes down the flat stream + * and keys its list on `id + section.id` (not `id` alone). Absent on every + * non-grouped source, so those callers render nothing extra. + */ + section?: { id: string; label: string }; } /** diff --git a/packages/shared/src/library/enums.ts b/packages/shared/src/library/enums.ts new file mode 100644 index 00000000..ee57ff1f --- /dev/null +++ b/packages/shared/src/library/enums.ts @@ -0,0 +1,34 @@ +/** + * The five viewing "lenses" the library page slices its owned collection + * through. Each lens is a different grouping/sort of the same filtered owned + * set (design `docs/2026-06-02-library-backend-design.md` §The 5 lenses). The + * order is the tab order; `az` is the index lens. + */ +export const LIBRARY_LENSES = ["az", "timeline", "collections", "server", "quality"] as const; +export type LibraryLens = (typeof LIBRARY_LENSES)[number]; + +/** + * Watched-progress buckets derived server-side from a title's watch position. + * `partial` is a title with progress on some but not all of its parts; the + * facet and the `watched` filter axis both key off this tuple. + */ +export const WATCHED_STATES = ["watched", "partial", "unwatched"] as const; +export type WatchedState = (typeof WATCHED_STATES)[number]; + +/** + * Canonical quality tiers in descending fidelity. This tuple is only the + * anchor for the `rankQualityTier` heuristic that orders the Quality lens; the + * actual `qualityTiers` strings come from plugins and are free-form + * (`"4K HDR"`, `"Atmos"`, `"1080p"`, etc.), so any label not found here ranks + * below every listed tier. Keep this hi→lo: a higher index is lower fidelity. + */ +export const QUALITY_TIERS = [ + "4K HDR", + "4K", + "HDR", + "Dolby Vision", + "1080p", + "720p", + "SD", +] as const; +export type QualityTier = (typeof QUALITY_TIERS)[number]; diff --git a/packages/shared/src/library/index.ts b/packages/shared/src/library/index.ts new file mode 100644 index 00000000..eb3d9f18 --- /dev/null +++ b/packages/shared/src/library/index.ts @@ -0,0 +1,3 @@ +export * from "./enums"; +export * from "./types"; +export * from "./schemas"; diff --git a/packages/shared/src/library/schemas.ts b/packages/shared/src/library/schemas.ts new file mode 100644 index 00000000..b34753f1 --- /dev/null +++ b/packages/shared/src/library/schemas.ts @@ -0,0 +1,64 @@ +import { z } from "zod"; +import { MEDIA_TYPES } from "../media/enums"; +import { WATCHED_STATES } from "./enums"; + +export const LIBRARY_LIST_DEFAULT_LIMIT = 60; +export const LIBRARY_LIST_MAX_LIMIT = 200; + +/** + * A tolerant array query param. Hono yields a bare string for a single + * occurrence (`?genres=Drama`) and an array for a repeated one, so this coerces + * the single value to a one-element array. Anything that fails item validation + * (a stray value from a hand-edited link) degrades to an open axis rather than + * 400-ing the request, matching the client's URL parsing. Mirrors + * `apps/client/src/features/library/lib/search.ts`. + */ +function arrayParam(item: T) { + return z + .preprocess( + (value) => (value == null ? undefined : Array.isArray(value) ? value : [value]), + z.array(item).optional(), + ) + .catch(undefined); +} + +/** Shared facet filter axes; an empty/omitted axis applies no filter. */ +const filterShape = { + kinds: arrayParam(z.enum(MEDIA_TYPES)), + genres: arrayParam(z.string()), + qualities: arrayParam(z.string()), + servers: arrayParam(z.string()), + watched: arrayParam(z.enum(WATCHED_STATES)), +}; + +const cursorSchema = z.string().min(1).max(512).optional(); + +const limitSchema = z.coerce + .number() + .int() + .positive() + .max(LIBRARY_LIST_MAX_LIMIT) + .default(LIBRARY_LIST_DEFAULT_LIMIT); + +/** + * Query schema shared by the four item lenses (`library-az`, `library-timeline`, + * `library-server`, `library-quality`) served through the unified + * `/api/media/sources/:sourceId` route. The opaque `cursor` is decoded + * separately by the source resolver, so this is intentionally not `.strict()`. + */ +export const libraryLensQuerySchema = z.object({ + cursor: cursorSchema, + limit: limitSchema, + ...filterShape, +}); +export type LibraryLensQueryInput = z.input; +export type LibraryLensQueryParsed = z.infer; + +/** `GET /api/library/collections` query: same filter axes as the item lenses. */ +export const libraryCollectionsQuerySchema = z.object({ + cursor: cursorSchema, + limit: limitSchema, + ...filterShape, +}); +export type LibraryCollectionsQueryInput = z.input; +export type LibraryCollectionsQueryParsed = z.infer; diff --git a/packages/shared/src/library/types.ts b/packages/shared/src/library/types.ts new file mode 100644 index 00000000..69a1b852 --- /dev/null +++ b/packages/shared/src/library/types.ts @@ -0,0 +1,51 @@ +import type { CompactMediaItem } from "../media/page"; +import type { MediaType } from "../media/enums"; +import type { WatchedState } from "./enums"; + +/** + * One owned franchise grouping returned by the Collections lens. `preview` + * holds up to four enriched items so the card can fan their posters without a + * second fetch (design §Collections lens). `count` is the total owned titles in + * the franchise, which may exceed `preview.length`. + */ +export interface LibraryCollection { + /** Composite id `collection:`. */ + id: string; + title: string; + count: number; + preview: CompactMediaItem[]; +} + +/** + * Unfiltered facet totals for the library, served by `/api/library/facets`. + * Counts are whole-library totals (not filter-aware) to match the mock look + * (design §Facets). `letters` and `decades` are present-only — they list only + * the buckets that have at least one owned title, powering the A→Z rail and the + * timeline decade markers respectively. + */ +export interface LibraryFacetCounts { + /** Owned titles per media type, keyed by `MediaType`. */ + kinds: Record; + /** Owned titles per genre, expanded via `json_each(genres)`. */ + genres: Record; + /** Owned titles per quality tier, expanded via `json_each(qualityTiers)`. */ + qualities: Record; + /** Owned titles per server, expanded via `json_each(servers)`. */ + servers: Record; + /** Owned titles per watched state. */ + watched: Record; + /** Distinct first characters of `sortTitle` (e.g. `"A".."Z"`, `"#"`). */ + letters: string[]; + /** Distinct decades present, newest first (e.g. `[2020, 2010]`). */ + decades: number[]; +} + +/** + * Paginated response shape for `/api/library/collections`. The `cursor` is an + * opaque keyset token (mirrors the media `Page` cursor convention) and is + * `null` once the caller reaches the last group. + */ +export interface LibraryCollectionsResponse { + collections: LibraryCollection[]; + cursor: string | null; +} diff --git a/packages/shared/src/media/enums.ts b/packages/shared/src/media/enums.ts index 7e1aee34..8bcfa802 100644 --- a/packages/shared/src/media/enums.ts +++ b/packages/shared/src/media/enums.ts @@ -61,6 +61,15 @@ export const MEDIA_SOURCE_IDS = [ "watchlist-mood-items", "watchlist-tonight", "watchlist-recently", + // Library item lenses (served through the unified resolver). The franchise + // (`collections`) lens is NOT here — it is a group-first endpoint, not a + // `Page` source, so it has its own `/api/library/collections` route. The + // `server`/`quality` lenses expand a title once per server / quality tier via + // `json_each`, so the same title can repeat across sections of one page. + "library-az", + "library-timeline", + "library-server", + "library-quality", ] as const; export type MediaSourceId = (typeof MEDIA_SOURCE_IDS)[number];