feat(catalog): Latest Episodes sort — order series by newest episode file (#283)
* feat(catalog): Latest Episodes sort — order series by newest episode file Adds a latest_episode_added sort so users can see which shows received new episodes. Today's recently-added surfaces reflect when the SERIES was first added: linking a new episode file never bumps the series' media_item_libraries.first_seen_at (ON CONFLICT DO NOTHING), so a long-running show with a fresh episode sorts as stale (#202). - New denorm media_items.latest_episode_added_at (migration + backfill + partial series index), mirroring the last_air_date_at precedent. Source of truth is episode_libraries.first_seen_at; the three insert paths (UpdateEpisodeLink, BulkLinkEpisodesBySeries, scanner folder restore) bump the parent series atomically in the same statement, monotonically via GREATEST, and only for genuinely new links. - Sort registered in both frameworks: querySortDefs (sections + smart collections + /v1/catalog pick it up automatically via QuerySortFieldSet) and the browse buildOrderByPlan path. - Jellyfin compat: SortBy=DateLastContentAdded now maps to the new sort instead of silently collapsing to series creation date — Jellyfin clients already send this for the TV "Latest" shelf, so they get the correct behavior with no client changes. DatePlayed keeps its old created_at mapping instead of piggybacking. - Web sort picker gains "Latest Episode Added" (series scope). Additive-only per v1 API rules: new sort value, no field/status changes. Part of #202 Fixes #202 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(web): include latest_episode_added in the api QuerySort field union The picker-side QuerySortField gained the value but the api-layer QuerySort union did not, breaking the production tsc build. Part of #202 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(scanner): recompute latest_episode_added_at when episode memberships are removed The denorm was only ever bumped upward (GREATEST) at insert time, but UpdateEpisodeLink also deletes the old episode's library membership on re-link, and reconciliation/path-prefix clears remove memberships too — leaving a stale timestamp that kept the series sorting as recently updated. All removal paths now run in a transaction and finish with a shared full MAX() recompute (catalog.RecomputeSeriesLatestEpisodeAdded) that also resets to NULL when no memberships remain, mirroring the last_air_date_at maintenance pattern. Sequential statements are load-bearing here: data-modifying CTEs are invisible to reads in the same statement, which also silently no-op'd the old path-prefix membership delete. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(jellycompat): keep DateLastContentAdded scoped to series-only requests mapSortBy runs for every /Items browse, so the latest_episode_added mapping leaked into movie and untyped requests where the column is always NULL, destroying the previous created_at ordering. The sort now falls back to created_at unless IncludeItemTypes is exactly Series. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
co-authored by
Claude Fable 5
Quick
parent
ade3a3c5d4
commit
c0f209a936
@@ -0,0 +1,41 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
-- Denormalized "latest episode file added" on media_items, powering the
|
||||
-- "Latest Episodes" sort (issue #202): series ordered by when their newest
|
||||
-- episode FILE arrived, not by when the series itself was first added.
|
||||
-- Source of truth is episode_libraries.first_seen_at (per-episode first-seen,
|
||||
-- stamped from media_files.created_at at link time). Maintained by the three
|
||||
-- episode_libraries insert paths (FileRepository.UpdateEpisodeLink,
|
||||
-- FileRepository.BulkLinkEpisodesBySeries, scanner folder-restore), which
|
||||
-- bump the parent series in the same statement. Mirrors the
|
||||
-- last_air_date_at denorm (migration 103).
|
||||
--
|
||||
-- Like last_air_date_at, the value is global (not access-scoped): a viewer
|
||||
-- without access to the folder that received the newest episode still sees
|
||||
-- the series sorted by that arrival. Accepted trade-off for an O(1) sort key.
|
||||
|
||||
ALTER TABLE public.media_items
|
||||
ADD COLUMN IF NOT EXISTS latest_episode_added_at timestamptz;
|
||||
|
||||
-- Backfill from existing episode links.
|
||||
UPDATE public.media_items mi
|
||||
SET latest_episode_added_at = sub.latest_added
|
||||
FROM (
|
||||
SELECT e.series_id, MAX(el.first_seen_at) AS latest_added
|
||||
FROM public.episode_libraries el
|
||||
JOIN public.episodes e ON e.content_id = el.episode_id
|
||||
GROUP BY e.series_id
|
||||
) sub
|
||||
WHERE mi.content_id = sub.series_id
|
||||
AND mi.type = 'series';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_media_items_latest_episode_added_at
|
||||
ON public.media_items USING btree (latest_episode_added_at DESC NULLS LAST)
|
||||
WHERE type = 'series';
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP INDEX IF EXISTS public.idx_media_items_latest_episode_added_at;
|
||||
ALTER TABLE public.media_items DROP COLUMN IF EXISTS latest_episode_added_at;
|
||||
-- +goose StatementEnd
|
||||
Reference in New Issue
Block a user