From c0f209a936396b2dd32c3a2e85e524954fdfe4e1 Mon Sep 17 00:00:00 2001 From: fluxis Date: Sat, 4 Jul 2026 23:43:19 -0400 Subject: [PATCH] =?UTF-8?q?feat(catalog):=20Latest=20Episodes=20sort=20?= =?UTF-8?q?=E2=80=94=20order=20series=20by=20newest=20episode=20file=20(#2?= =?UTF-8?q?83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * 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 --------- Co-authored-by: Claude Fable 5 Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com> --- internal/catalog/browse.go | 7 + internal/catalog/episode_added_denorm.go | 38 +++++ internal/catalog/episode_library_repo.go | 80 ++++++--- internal/catalog/query_builder_test.go | 38 +++++ internal/catalog/query_definition.go | 38 +++-- internal/jellycompat/query.go | 13 +- internal/jellycompat/query_test.go | 51 ++++++ internal/metadata/service.go | 3 + internal/scanner/episode_added_denorm_test.go | 160 ++++++++++++++++++ internal/scanner/file_repo.go | 150 ++++++++++++---- internal/scanner/scanner.go | 33 +++- ...edia_items_latest_episode_added_denorm.sql | 41 +++++ web/src/api/types.ts | 1 + web/src/lib/querySortOptions.ts | 8 + 14 files changed, 574 insertions(+), 87 deletions(-) create mode 100644 internal/catalog/episode_added_denorm.go create mode 100644 internal/scanner/episode_added_denorm_test.go create mode 100644 migrations/sql/20260702183852_media_items_latest_episode_added_denorm.sql diff --git a/internal/catalog/browse.go b/internal/catalog/browse.go index 132534f1..2cbe77c7 100644 --- a/internal/catalog/browse.go +++ b/internal/catalog/browse.go @@ -1482,6 +1482,13 @@ func buildOrderByPlan(sort, order string, snapshot *time.Time, argIdx int, singl direction, nullsClause, ), nil + case "latest_episode_added": + // Latest Episodes (issue #202): denormalized newest-episode-file + // arrival; NULLS LAST so movies and episode-less series trail. + return fmt.Sprintf( + "ORDER BY mi.latest_episode_added_at %s NULLS LAST, mi.content_id ASC", + direction, + ), nil case "rating_imdb": return fmt.Sprintf("ORDER BY mi.rating_imdb %s%s, mi.content_id ASC", direction, nullsClause), nil case "rating_tmdb": diff --git a/internal/catalog/episode_added_denorm.go b/internal/catalog/episode_added_denorm.go new file mode 100644 index 00000000..9d145126 --- /dev/null +++ b/internal/catalog/episode_added_denorm.go @@ -0,0 +1,38 @@ +package catalog + +import ( + "context" + "fmt" +) + +// updateSeriesLatestEpisodeAddedSQL fully recomputes the denorm from +// episode_libraries after membership changes; series with no memberships reset +// to NULL. +const updateSeriesLatestEpisodeAddedSQL = ` + UPDATE media_items mi + SET latest_episode_added_at = sub.latest_added + FROM ( + SELECT s.series_id, ( + SELECT MAX(el.first_seen_at) + FROM episode_libraries el + JOIN episodes e ON e.content_id = el.episode_id + WHERE e.series_id = s.series_id + ) AS latest_added + FROM unnest($1::text[]) AS s(series_id) + ) sub + WHERE mi.content_id = sub.series_id + AND mi.type = 'series' + AND (mi.latest_episode_added_at IS DISTINCT FROM sub.latest_added)` + +// RecomputeSeriesLatestEpisodeAdded refreshes latest_episode_added_at for the +// supplied series IDs after episode library membership changes. +func RecomputeSeriesLatestEpisodeAdded(ctx context.Context, execer itemExecer, seriesIDs []string) error { + seriesIDs = compactNonEmptyStrings(seriesIDs) + if len(seriesIDs) == 0 { + return nil + } + if _, err := execer.Exec(ctx, updateSeriesLatestEpisodeAddedSQL, seriesIDs); err != nil { + return fmt.Errorf("recomputing latest episode added denorm: %w", err) + } + return nil +} diff --git a/internal/catalog/episode_library_repo.go b/internal/catalog/episode_library_repo.go index f07500e7..63bdbb31 100644 --- a/internal/catalog/episode_library_repo.go +++ b/internal/catalog/episode_library_repo.go @@ -21,33 +21,65 @@ func NewEpisodeLibraryRepository(pool *pgxpool.Pool) *EpisodeLibraryRepository { // memberships for episodes that no longer have any present files in the given // folder. The returned count is the number of removed stale memberships. func (r *EpisodeLibraryRepository) ReconcileFolderMembership(ctx context.Context, folderID int) (int, error) { - if _, err := r.pool.Exec(ctx, ` - INSERT INTO episode_libraries (episode_id, media_folder_id, first_seen_at) - SELECT mf.episode_id, mf.media_folder_id, MIN(mf.created_at) - FROM media_files mf - JOIN episodes e ON e.content_id = mf.episode_id - WHERE mf.media_folder_id = $1 - AND mf.missing_since IS NULL - AND mf.episode_id IS NOT NULL - GROUP BY mf.episode_id, mf.media_folder_id - ON CONFLICT (episode_id, media_folder_id) DO NOTHING - `, folderID); err != nil { + tx, err := r.pool.Begin(ctx) + if err != nil { + return 0, fmt.Errorf("beginning episode membership reconciliation transaction: %w", err) + } + defer tx.Rollback(ctx) //nolint:errcheck + + var insertedSeriesIDs []string + if err := tx.QueryRow(ctx, ` + WITH inserted AS ( + INSERT INTO episode_libraries (episode_id, media_folder_id, first_seen_at) + SELECT mf.episode_id, mf.media_folder_id, MIN(mf.created_at) + FROM media_files mf + JOIN episodes e ON e.content_id = mf.episode_id + WHERE mf.media_folder_id = $1 + AND mf.missing_since IS NULL + AND mf.episode_id IS NOT NULL + GROUP BY mf.episode_id, mf.media_folder_id + ON CONFLICT (episode_id, media_folder_id) DO NOTHING + RETURNING episode_id + ) + SELECT COALESCE(array_agg(DISTINCT e.series_id), ARRAY[]::text[]) + FROM inserted i + JOIN episodes e ON e.content_id = i.episode_id + `, folderID).Scan(&insertedSeriesIDs); err != nil { return 0, fmt.Errorf("restoring episode library membership: %w", err) } - tag, err := r.pool.Exec(ctx, ` - DELETE FROM episode_libraries el - WHERE el.media_folder_id = $1 - AND NOT EXISTS ( - SELECT 1 - FROM media_files mf - WHERE mf.media_folder_id = el.media_folder_id - AND mf.episode_id = el.episode_id - AND mf.missing_since IS NULL - ) - `, folderID) - if err != nil { + var removed int + var deletedSeriesIDs []string + if err := tx.QueryRow(ctx, ` + WITH deleted AS ( + DELETE FROM episode_libraries el + WHERE el.media_folder_id = $1 + AND NOT EXISTS ( + SELECT 1 + FROM media_files mf + WHERE mf.media_folder_id = el.media_folder_id + AND mf.episode_id = el.episode_id + AND mf.missing_since IS NULL + ) + RETURNING el.episode_id + ) + SELECT COUNT(*)::int, + COALESCE( + array_agg(DISTINCT e.series_id) FILTER (WHERE e.series_id IS NOT NULL), + ARRAY[]::text[] + ) + FROM deleted d + LEFT JOIN episodes e ON e.content_id = d.episode_id + `, folderID).Scan(&removed, &deletedSeriesIDs); err != nil { return 0, fmt.Errorf("reconciling episode library membership: %w", err) } - return int(tag.RowsAffected()), nil + + affectedSeriesIDs := append(insertedSeriesIDs, deletedSeriesIDs...) + if err := RecomputeSeriesLatestEpisodeAdded(ctx, tx, affectedSeriesIDs); err != nil { + return 0, err + } + if err := tx.Commit(ctx); err != nil { + return 0, fmt.Errorf("committing episode membership reconciliation transaction: %w", err) + } + return removed, nil } diff --git a/internal/catalog/query_builder_test.go b/internal/catalog/query_builder_test.go index b4a59b64..ad4ae5b4 100644 --- a/internal/catalog/query_builder_test.go +++ b/internal/catalog/query_builder_test.go @@ -34,6 +34,44 @@ func TestBuildSortClause_LastAirDateUsesNullsLast(t *testing.T) { } } +func TestBuildSortClause_LatestEpisodeAddedUsesDenormColumn(t *testing.T) { + // "Latest Episodes" sort (issue #202): series ordered by when their + // newest episode file arrived, read from the denormalized + // media_items.latest_episode_added_at — no JOIN, no args. + plan, err := NewQueryBuilder("mi").BuildSortPlan(QuerySort{ + Field: "latest_episode_added", + Order: "desc", + }) + if err != nil { + t.Fatalf("BuildSortPlan returned error: %v", err) + } + if len(plan.Args) != 0 { + t.Fatalf("expected no args, got %v", plan.Args) + } + if len(plan.Joins) != 0 { + t.Fatalf("expected no joins (denormalized column), got %v", plan.Joins) + } + if !strings.Contains(plan.OrderBy, "mi.latest_episode_added_at") { + t.Fatalf("expected sort to read mi.latest_episode_added_at, got %q", plan.OrderBy) + } + if !strings.Contains(plan.OrderBy, "NULLS LAST") { + t.Fatalf("expected series without episode files to sort last, got %q", plan.OrderBy) + } +} + +func TestBuildOrderByPlan_LatestEpisodeAdded(t *testing.T) { + clause, args := buildOrderByPlan("latest_episode_added", "desc", nil, 0, false, false) + if len(args) != 0 { + t.Fatalf("expected no args, got %v", args) + } + if !strings.Contains(clause, "mi.latest_episode_added_at DESC NULLS LAST") { + t.Fatalf("expected order by mi.latest_episode_added_at DESC NULLS LAST, got %q", clause) + } + if !strings.Contains(clause, "mi.content_id ASC") { + t.Fatalf("expected content_id tiebreaker, got %q", clause) + } +} + func TestBuildSortClause_ReleaseDateUsesNullsLast(t *testing.T) { clause, args, err := NewQueryBuilder("mi").BuildSortClause(QuerySort{ Field: "release_date", diff --git a/internal/catalog/query_definition.go b/internal/catalog/query_definition.go index 6cae6820..219e4bfe 100644 --- a/internal/catalog/query_definition.go +++ b/internal/catalog/query_definition.go @@ -72,22 +72,28 @@ var queryFieldDefs = map[string]queryFieldDef{ } var querySortDefs = map[string]querySortDef{ - "title": {columnSQL: "LOWER(COALESCE(NULLIF(BTRIM(%s.sort_title), ''), %s.title))", defaultOrder: "asc", titleSortOnly: true}, - "added_at": {defaultOrder: "desc"}, - "release_date": {columnSQL: "COALESCE(%s.release_date::text, NULLIF(BTRIM(%s.first_air_date), ''))", defaultOrder: "desc", nullsLast: true}, - "last_air_date": {columnSQL: "last_air_date", defaultOrder: "desc", nullsLast: true}, - "year": {columnSQL: "year", defaultOrder: "desc"}, - "content_rating": {defaultOrder: "asc"}, - "runtime": {columnSQL: "runtime", defaultOrder: "desc", nullsLast: true}, - "rating_imdb": {columnSQL: "rating_imdb", defaultOrder: "desc", nullsLast: true}, - "rating_tmdb": {columnSQL: "rating_tmdb", defaultOrder: "desc", nullsLast: true}, - "rating_rt_critic": {columnSQL: "rating_rt_critic", defaultOrder: "desc", nullsLast: true}, - "rating_rt_audience": {columnSQL: "rating_rt_audience", defaultOrder: "desc", nullsLast: true}, - "resolution": {defaultOrder: "desc", nullsLast: true}, - "bitrate": {defaultOrder: "desc", nullsLast: true}, - "progress": {defaultOrder: "desc", nullsLast: true, personalized: true}, - "date_viewed": {defaultOrder: "desc", nullsLast: true, personalized: true}, - "plays": {defaultOrder: "desc", nullsLast: true, personalized: true}, + "title": {columnSQL: "LOWER(COALESCE(NULLIF(BTRIM(%s.sort_title), ''), %s.title))", defaultOrder: "asc", titleSortOnly: true}, + "added_at": {defaultOrder: "desc"}, + "release_date": {columnSQL: "COALESCE(%s.release_date::text, NULLIF(BTRIM(%s.first_air_date), ''))", defaultOrder: "desc", nullsLast: true}, + "last_air_date": {columnSQL: "last_air_date", defaultOrder: "desc", nullsLast: true}, + // Latest Episodes (issue #202): series ordered by when their newest + // episode FILE arrived (denormalized media_items.latest_episode_added_at, + // maintained on the episode_libraries insert paths). Distinct from + // added_at (when the series itself was first added) and last_air_date + // (when the newest episode aired). + "latest_episode_added": {columnSQL: "latest_episode_added_at", defaultOrder: "desc", nullsLast: true}, + "year": {columnSQL: "year", defaultOrder: "desc"}, + "content_rating": {defaultOrder: "asc"}, + "runtime": {columnSQL: "runtime", defaultOrder: "desc", nullsLast: true}, + "rating_imdb": {columnSQL: "rating_imdb", defaultOrder: "desc", nullsLast: true}, + "rating_tmdb": {columnSQL: "rating_tmdb", defaultOrder: "desc", nullsLast: true}, + "rating_rt_critic": {columnSQL: "rating_rt_critic", defaultOrder: "desc", nullsLast: true}, + "rating_rt_audience": {columnSQL: "rating_rt_audience", defaultOrder: "desc", nullsLast: true}, + "resolution": {defaultOrder: "desc", nullsLast: true}, + "bitrate": {defaultOrder: "desc", nullsLast: true}, + "progress": {defaultOrder: "desc", nullsLast: true, personalized: true}, + "date_viewed": {defaultOrder: "desc", nullsLast: true, personalized: true}, + "plays": {defaultOrder: "desc", nullsLast: true, personalized: true}, // Audiobook-native sorts. nullsLast so items without an author / // narrator / series association still appear (sorted to the end). "author": {defaultOrder: "asc", nullsLast: true}, diff --git a/internal/jellycompat/query.go b/internal/jellycompat/query.go index f966e901..5a254e1a 100644 --- a/internal/jellycompat/query.go +++ b/internal/jellycompat/query.go @@ -118,6 +118,9 @@ func parseItemsQuery(r *http.Request, codec *ResourceIDCodec) itemsQuery { result.wantsBoxSets = includeItemTypesContain(rawItemTypes, "boxset") result.wantsViews = includeItemTypesContain(rawItemTypes, "collectionfolder") result.sortExplicit = strings.TrimSpace(q.Get("SortBy")) != "" + if result.sort == "latest_episode_added" && !itemTypesOnlySeries(result.itemTypes) { + result.sort = "created_at" + } if len(result.itemTypes) > 0 { result.itemType = result.itemTypes[0] } @@ -386,6 +389,10 @@ func hasNonEmptyValues(values []string) bool { return false } +func itemTypesOnlySeries(itemTypes []string) bool { + return len(itemTypes) == 1 && itemTypes[0] == "series" +} + // includeItemTypesContain reports whether a raw IncludeItemTypes value list // contains the given (lowercase) type, before mapIncludeItemTypes drops // entries it cannot map to catalog types (e.g. BoxSet). @@ -434,8 +441,12 @@ func mapSortBy(raw string) string { return "rating_imdb" case "random": return "random" - case "dateplayed", "datelastcontentadded": + case "dateplayed": return "created_at" + case "datelastcontentadded": + // Jellyfin's standard "Latest" sort for TV libraries: shows ordered + // by their most recently added episode (issue #202). + return "latest_episode_added" default: return "created_at" } diff --git a/internal/jellycompat/query_test.go b/internal/jellycompat/query_test.go index 425b344b..1529e944 100644 --- a/internal/jellycompat/query_test.go +++ b/internal/jellycompat/query_test.go @@ -77,6 +77,57 @@ func TestMapSortByReleaseDate(t *testing.T) { } } +func TestMapSortByDateLastContentAdded(t *testing.T) { + // Jellyfin's standard "Latest" sort for TV libraries orders shows by + // their most recently added episode. It must map to the + // latest_episode_added sort (issue #202), not series creation date. + for _, raw := range []string{"DateLastContentAdded", "DateLastContentAdded,SortName"} { + if got := mapSortBy(raw); got != "latest_episode_added" { + t.Fatalf("mapSortBy(%q) = %q, want latest_episode_added", raw, got) + } + } + // DatePlayed used to piggyback on the same case; it must keep its old + // created_at behavior rather than inherit the episode-added sort. + if got := mapSortBy("DatePlayed"); got != "created_at" { + t.Fatalf("mapSortBy(DatePlayed) = %q, want created_at", got) + } +} + +func TestParseItemsQueryDateLastContentAddedSortScope(t *testing.T) { + tests := []struct { + name string + path string + want string + }{ + { + name: "series only", + path: "/Items?IncludeItemTypes=Series&SortBy=DateLastContentAdded", + want: "latest_episode_added", + }, + { + name: "movie", + path: "/Items?IncludeItemTypes=Movie&SortBy=DateLastContentAdded", + want: "created_at", + }, + { + name: "no type", + path: "/Items?SortBy=DateLastContentAdded", + want: "created_at", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest("GET", tc.path, nil) + + query := parseItemsQuery(req, NewResourceIDCodec()) + + if query.sort != tc.want { + t.Fatalf("sort = %q, want %q", query.sort, tc.want) + } + }) + } +} + func TestParseContentIDParam(t *testing.T) { got := parseContentIDParam(" movie-1, movie-2, movie-1 ,, ") want := []string{"movie-1", "movie-2"} diff --git a/internal/metadata/service.go b/internal/metadata/service.go index 3b91eb3d..4558dd4e 100644 --- a/internal/metadata/service.go +++ b/internal/metadata/service.go @@ -4902,6 +4902,9 @@ func (s *MetadataService) rebindItemToExistingItem(ctx context.Context, fromCont return fmt.Errorf("%s: %w", step.name, err) } } + if err := catalog.RecomputeSeriesLatestEpisodeAdded(ctx, tx, []string{fromContentID}); err != nil { + return err + } if err := catalog.EnqueueSearchIndexRename(ctx, tx, fromContentID, toContentID); err != nil { return fmt.Errorf("enqueue catalog search skeleton rebind %s -> %s: %w", fromContentID, toContentID, err) diff --git a/internal/scanner/episode_added_denorm_test.go b/internal/scanner/episode_added_denorm_test.go new file mode 100644 index 00000000..c3882d3b --- /dev/null +++ b/internal/scanner/episode_added_denorm_test.go @@ -0,0 +1,160 @@ +package scanner + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// TestEpisodeLinkMaintainsLatestEpisodeAdded covers the maintenance half of the +// "Latest Episodes" sort (issue #202): linking a new episode file must bump +// the parent series' media_items.latest_episode_added_at denorm for both the +// single-file and bulk link paths, while re-linking away from an episode must +// fully recompute the old and new parent series. +func TestEpisodeLinkMaintainsLatestEpisodeAdded(t *testing.T) { + dsn := os.Getenv("SILO_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("SILO_TEST_DATABASE_URL is not set") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("connect test database: %v", err) + } + t.Cleanup(pool.Close) + + suffix := time.Now().UnixNano() + seriesID := fmt.Sprintf("lea-series-%d", suffix) + otherSeriesID := fmt.Sprintf("lea-other-series-%d", suffix) + ep1 := fmt.Sprintf("lea-ep1-%d", suffix) + ep2 := fmt.Sprintf("lea-ep2-%d", suffix) + ep3 := fmt.Sprintf("lea-ep3-%d", suffix) + + var folderID int + if err := pool.QueryRow(ctx, ` + INSERT INTO media_folders (type, name, enabled) VALUES ('series', 'LEA Test', true) RETURNING id + `).Scan(&folderID); err != nil { + t.Fatalf("seed folder: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = $1`, seriesID) + _, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = $1`, otherSeriesID) + _, _ = pool.Exec(ctx, `DELETE FROM media_folders WHERE id = $1`, folderID) + }) + + if _, err := pool.Exec(ctx, ` + INSERT INTO media_items (content_id, type, title, status, genres) + VALUES ($1, 'series', 'LEA Series', 'matched', '{}'::text[]) + `, seriesID); err != nil { + t.Fatalf("seed series: %v", err) + } + if _, err := pool.Exec(ctx, ` + INSERT INTO media_items (content_id, type, title, status, genres) + VALUES ($1, 'series', 'LEA Other Series', 'matched', '{}'::text[]) + `, otherSeriesID); err != nil { + t.Fatalf("seed other series: %v", err) + } + for i, epID := range []string{ep1, ep2} { + if _, err := pool.Exec(ctx, ` + INSERT INTO episodes (content_id, series_id, season_number, episode_number, title) + VALUES ($1, $2, 1, $3, 'Ep') + `, epID, seriesID, i+1); err != nil { + t.Fatalf("seed episode %s: %v", epID, err) + } + } + if _, err := pool.Exec(ctx, ` + INSERT INTO episodes (content_id, series_id, season_number, episode_number, title) + VALUES ($1, $2, 1, 1, 'Other Ep') + `, ep3, otherSeriesID); err != nil { + t.Fatalf("seed other episode: %v", err) + } + + seedFile := func(path string, createdAt time.Time, season, episode int) int { + var id int + if err := pool.QueryRow(ctx, ` + INSERT INTO media_files (content_id, media_folder_id, file_path, file_size, season_number, episode_number, created_at) + VALUES ($1, $2, $3, 1024, $4, $5, $6) RETURNING id + `, seriesID, folderID, path, season, episode, createdAt).Scan(&id); err != nil { + t.Fatalf("seed media file %s: %v", path, err) + } + return id + } + latest := func(contentID string) *time.Time { + var v *time.Time + if err := pool.QueryRow(ctx, ` + SELECT latest_episode_added_at FROM media_items WHERE content_id = $1 + `, contentID).Scan(&v); err != nil { + t.Fatalf("read latest_episode_added_at: %v", err) + } + return v + } + + repo := NewFileRepository(pool) + firstAdded := time.Now().Add(-48 * time.Hour).UTC().Truncate(time.Second) + secondAdded := time.Now().Add(-1 * time.Hour).UTC().Truncate(time.Second) + + // Path 1: UpdateEpisodeLink on a single file. + pathPrefix := fmt.Sprintf("/tmp/lea-%d", suffix) + file1 := seedFile(fmt.Sprintf("%s/e1.mkv", pathPrefix), firstAdded, 1, 1) + if err := repo.UpdateEpisodeLink(ctx, file1, ep1, 1, 1); err != nil { + t.Fatalf("UpdateEpisodeLink: %v", err) + } + got := latest(seriesID) + if got == nil || !got.Equal(firstAdded) { + t.Fatalf("latest_episode_added_at after first link = %v, want %v", got, firstAdded) + } + + // Path 2: BulkLinkEpisodesBySeries picks up the newer file and bumps. + file2 := seedFile(fmt.Sprintf("%s/e2.mkv", pathPrefix), secondAdded, 1, 2) + if _, err := repo.BulkLinkEpisodesBySeries(ctx, seriesID); err != nil { + t.Fatalf("BulkLinkEpisodesBySeries: %v", err) + } + got = latest(seriesID) + if got == nil || !got.Equal(secondAdded) { + t.Fatalf("latest_episode_added_at after bulk link = %v, want %v", got, secondAdded) + } + + // Re-linking the same episode is an ON CONFLICT no-op: no re-bump, the + // value stays at the newest arrival. + if err := repo.UpdateEpisodeLink(ctx, file1, ep1, 1, 1); err != nil { + t.Fatalf("re-link UpdateEpisodeLink: %v", err) + } + got = latest(seriesID) + if got == nil || !got.Equal(secondAdded) { + t.Fatalf("latest_episode_added_at after no-op re-link = %v, want unchanged %v", got, secondAdded) + } + + if err := repo.UpdateEpisodeLink(ctx, file2, ep3, 1, 1); err != nil { + t.Fatalf("re-link newer file to other series: %v", err) + } + got = latest(seriesID) + if got == nil || !got.Equal(firstAdded) { + t.Fatalf("latest_episode_added_at after moving newest episode = %v, want %v", got, firstAdded) + } + got = latest(otherSeriesID) + if got == nil || !got.Equal(secondAdded) { + t.Fatalf("other latest_episode_added_at after re-link = %v, want %v", got, secondAdded) + } + + if err := repo.UpdateEpisodeLink(ctx, file1, ep3, 1, 1); err != nil { + t.Fatalf("re-link remaining file to other series: %v", err) + } + if got = latest(seriesID); got != nil { + t.Fatalf("latest_episode_added_at after moving all episodes = %v, want nil", got) + } + + cleared, err := repo.ClearContentLinksByPathPrefix(ctx, folderID, pathPrefix) + if err != nil { + t.Fatalf("ClearContentLinksByPathPrefix: %v", err) + } + if cleared != 2 { + t.Fatalf("cleared links = %d, want 2", cleared) + } + if got = latest(otherSeriesID); got != nil { + t.Fatalf("other latest_episode_added_at after clearing links = %v, want nil", got) + } +} diff --git a/internal/scanner/file_repo.go b/internal/scanner/file_repo.go index 99ec2242..ad360912 100644 --- a/internal/scanner/file_repo.go +++ b/internal/scanner/file_repo.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/Silo-Server/silo-server/internal/catalog" "github.com/Silo-Server/silo-server/internal/markers" "github.com/Silo-Server/silo-server/internal/models" "github.com/Silo-Server/silo-server/internal/pathscope" @@ -2861,8 +2862,16 @@ func (r *FileRepository) ClearContentID(ctx context.Context, fileID int) error { // ClearContentLinksByPathPrefix removes content and episode link fields for // present files beneath a specific root path in one media folder. func (r *FileRepository) ClearContentLinksByPathPrefix(ctx context.Context, folderID int, pathPrefix string) (int, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return 0, fmt.Errorf("begin clearing media file content links transaction: %w", err) + } + defer tx.Rollback(ctx) //nolint:errcheck + var cleared int - err := r.pool.QueryRow(ctx, ` + var oldEpisodeIDs []string + var affectedSeriesIDs []string + err = tx.QueryRow(ctx, ` WITH previous AS ( SELECT id, media_folder_id, episode_id AS old_episode_id FROM media_files @@ -2885,16 +2894,28 @@ func (r *FileRepository) ClearContentLinksByPathPrefix(ctx context.Context, fold updated_at = NOW() WHERE id IN (SELECT id FROM previous) RETURNING id - ), - deleted AS ( + ) + SELECT COUNT(*)::int, + COALESCE( + array_agg(DISTINCT p.old_episode_id) FILTER (WHERE p.old_episode_id IS NOT NULL), + ARRAY[]::text[] + ), + COALESCE( + array_agg(DISTINCT e.series_id) FILTER (WHERE e.series_id IS NOT NULL), + ARRAY[]::text[] + ) + FROM cleared c + JOIN previous p ON p.id = c.id + LEFT JOIN episodes e ON e.content_id = p.old_episode_id + `, folderID, pathPrefix, pathPrefixLike(pathPrefix)).Scan(&cleared, &oldEpisodeIDs, &affectedSeriesIDs) + if err != nil { + return 0, fmt.Errorf("clearing media file content links by path prefix: %w", err) + } + if len(oldEpisodeIDs) > 0 { + if _, err := tx.Exec(ctx, ` DELETE FROM episode_libraries el - USING ( - SELECT DISTINCT media_folder_id, old_episode_id AS episode_id - FROM previous - WHERE old_episode_id IS NOT NULL - ) touched - WHERE el.media_folder_id = touched.media_folder_id - AND el.episode_id = touched.episode_id + WHERE el.media_folder_id = $1 + AND el.episode_id = ANY($2::text[]) AND NOT EXISTS ( SELECT 1 FROM media_files mf @@ -2902,24 +2923,43 @@ func (r *FileRepository) ClearContentLinksByPathPrefix(ctx context.Context, fold AND mf.episode_id = el.episode_id AND mf.missing_since IS NULL ) - ) - SELECT COUNT(*) FROM cleared - `, folderID, pathPrefix, pathPrefixLike(pathPrefix)).Scan(&cleared) - if err != nil { - return 0, fmt.Errorf("clearing media file content links by path prefix: %w", err) + `, folderID, oldEpisodeIDs); err != nil { + return 0, fmt.Errorf("deleting stale episode library links by path prefix: %w", err) + } + if err := catalog.RecomputeSeriesLatestEpisodeAdded(ctx, tx, affectedSeriesIDs); err != nil { + return 0, err + } + } + if err := tx.Commit(ctx); err != nil { + return 0, fmt.Errorf("commit clearing media file content links transaction: %w", err) } return cleared, nil } // UpdateEpisodeLink sets the episode linkage fields on a media file. func (r *FileRepository) UpdateEpisodeLink(ctx context.Context, fileID int, episodeID string, seasonNum, episodeNum int) error { - _, err := r.pool.Exec(ctx, ` - WITH previous AS ( - SELECT media_folder_id, episode_id AS old_episode_id - FROM media_files - WHERE id = $4 - ), - updated AS ( + tx, err := r.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin episode link transaction: %w", err) + } + defer tx.Rollback(ctx) //nolint:errcheck + + var folderID int + var oldEpisodeID *string + if err := tx.QueryRow(ctx, ` + SELECT media_folder_id, episode_id + FROM media_files + WHERE id = $1 + FOR UPDATE + `, fileID).Scan(&folderID, &oldEpisodeID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil + } + return fmt.Errorf("loading existing episode link: %w", err) + } + + if _, err := tx.Exec(ctx, ` + WITH updated AS ( UPDATE media_files SET episode_id = $1, season_number = $2, @@ -2936,21 +2976,45 @@ func (r *FileRepository) UpdateEpisodeLink(ctx context.Context, fileID int, epis AND missing_since IS NULL ON CONFLICT (episode_id, media_folder_id) DO NOTHING ) - DELETE FROM episode_libraries el - USING previous p - WHERE p.old_episode_id IS NOT NULL - AND p.old_episode_id <> $1 - AND el.media_folder_id = p.media_folder_id - AND el.episode_id = p.old_episode_id - AND NOT EXISTS ( - SELECT 1 - FROM media_files mf - WHERE mf.media_folder_id = el.media_folder_id - AND mf.episode_id = el.episode_id - AND mf.missing_since IS NULL - ) - `, episodeID, seasonNum, episodeNum, fileID) - return err + SELECT COUNT(*) FROM inserted + `, episodeID, seasonNum, episodeNum, fileID); err != nil { + return fmt.Errorf("updating episode link: %w", err) + } + + affectedEpisodeIDs := []string{episodeID} + if oldEpisodeID != nil && *oldEpisodeID != episodeID { + affectedEpisodeIDs = append(affectedEpisodeIDs, *oldEpisodeID) + if _, err := tx.Exec(ctx, ` + DELETE FROM episode_libraries el + WHERE el.media_folder_id = $1 + AND el.episode_id = $2 + AND NOT EXISTS ( + SELECT 1 + FROM media_files mf + WHERE mf.media_folder_id = el.media_folder_id + AND mf.episode_id = el.episode_id + AND mf.missing_since IS NULL + ) + `, folderID, *oldEpisodeID); err != nil { + return fmt.Errorf("deleting old episode library link: %w", err) + } + } + + var affectedSeriesIDs []string + if err := tx.QueryRow(ctx, ` + SELECT COALESCE(array_agg(DISTINCT series_id), ARRAY[]::text[]) + FROM episodes + WHERE content_id = ANY($1::text[]) + `, affectedEpisodeIDs).Scan(&affectedSeriesIDs); err != nil { + return fmt.Errorf("collecting affected episode series: %w", err) + } + if err := catalog.RecomputeSeriesLatestEpisodeAdded(ctx, tx, affectedSeriesIDs); err != nil { + return err + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit episode link transaction: %w", err) + } + return nil } // BulkLinkEpisodesBySeries links all already-numbered files for a series to @@ -2982,6 +3046,18 @@ func (r *FileRepository) BulkLinkEpisodesBySeries(ctx context.Context, seriesCon FROM updated GROUP BY episode_id, media_folder_id ON CONFLICT (episode_id, media_folder_id) DO NOTHING + RETURNING first_seen_at + ), + -- Bump the series' latest-episode-added denorm for genuinely new + -- links only ("Latest Episodes" sort, issue #202). All inserted + -- rows belong to $1, so no per-series grouping is needed. + bumped AS ( + UPDATE media_items mi + SET latest_episode_added_at = GREATEST(COALESCE(mi.latest_episode_added_at, sub.latest_added), sub.latest_added) + FROM (SELECT MAX(first_seen_at) AS latest_added FROM inserted) sub + WHERE mi.content_id = $1 + AND mi.type = 'series' + AND sub.latest_added IS NOT NULL ) SELECT COUNT(*) FROM updated `, seriesContentID).Scan(&linked) diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go index 6905acc0..0360875b 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -1442,15 +1442,30 @@ func (s *Scanner) syncPresentLibraryState(ctx context.Context, folderID int) err } if _, err := s.fileRepo.Pool().Exec(ctx, ` - INSERT INTO episode_libraries (episode_id, media_folder_id, first_seen_at) - SELECT mf.episode_id, mf.media_folder_id, MIN(mf.created_at) - FROM media_files mf - JOIN episodes e ON e.content_id = mf.episode_id - WHERE mf.media_folder_id = $1 - AND mf.missing_since IS NULL - AND mf.episode_id IS NOT NULL - GROUP BY mf.episode_id, mf.media_folder_id - ON CONFLICT (episode_id, media_folder_id) DO NOTHING + WITH inserted AS ( + INSERT INTO episode_libraries (episode_id, media_folder_id, first_seen_at) + SELECT mf.episode_id, mf.media_folder_id, MIN(mf.created_at) + FROM media_files mf + JOIN episodes e ON e.content_id = mf.episode_id + WHERE mf.media_folder_id = $1 + AND mf.missing_since IS NULL + AND mf.episode_id IS NOT NULL + GROUP BY mf.episode_id, mf.media_folder_id + ON CONFLICT (episode_id, media_folder_id) DO NOTHING + RETURNING episode_id, first_seen_at + ) + -- Bump each parent series' latest-episode-added denorm for the + -- genuinely new links ("Latest Episodes" sort, issue #202). + UPDATE media_items mi + SET latest_episode_added_at = GREATEST(COALESCE(mi.latest_episode_added_at, sub.latest_added), sub.latest_added) + FROM ( + SELECT e.series_id, MAX(i.first_seen_at) AS latest_added + FROM inserted i + JOIN episodes e ON e.content_id = i.episode_id + GROUP BY e.series_id + ) sub + WHERE mi.content_id = sub.series_id + AND mi.type = 'series' `, folderID); err != nil { return fmt.Errorf("restoring episode folder memberships: %w", err) } diff --git a/migrations/sql/20260702183852_media_items_latest_episode_added_denorm.sql b/migrations/sql/20260702183852_media_items_latest_episode_added_denorm.sql new file mode 100644 index 00000000..a1ae4dfe --- /dev/null +++ b/migrations/sql/20260702183852_media_items_latest_episode_added_denorm.sql @@ -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 diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 33d9f465..b58ec913 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -1343,6 +1343,7 @@ export interface QuerySort { | "added_at" | "release_date" | "last_air_date" + | "latest_episode_added" | "year" | "content_rating" | "runtime" diff --git a/web/src/lib/querySortOptions.ts b/web/src/lib/querySortOptions.ts index e810a24c..ad3d07c0 100644 --- a/web/src/lib/querySortOptions.ts +++ b/web/src/lib/querySortOptions.ts @@ -12,6 +12,7 @@ export type QuerySortField = | "added_at" | "release_date" | "last_air_date" + | "latest_episode_added" | "year" | "content_rating" | "runtime" @@ -99,6 +100,13 @@ export const QUERY_SORT_OPTIONS: QuerySortOption[] = [ applicableMediaScopes: ["series", "episode"], preferredMediaScope: "episode", }, + { + value: "latest_episode_added", + label: "Latest Episode Added", + defaultOrder: "desc", + personalized: false, + applicableMediaScopes: ["series"], + }, { value: "year", label: "Year",