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:
fluxis
2026-07-04 23:43:19 -04:00
committed by GitHub
co-authored by Claude Fable 5 Quick
parent ade3a3c5d4
commit c0f209a936
14 changed files with 574 additions and 87 deletions
+7
View File
@@ -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":
+38
View File
@@ -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
}
+56 -24
View File
@@ -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
}
+38
View File
@@ -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",
+22 -16
View File
@@ -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},
+12 -1
View File
@@ -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"
}
+51
View File
@@ -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"}
+3
View File
@@ -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)
@@ -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)
}
}
+113 -37
View File
@@ -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)
+24 -9
View File
@@ -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)
}
@@ -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
+1
View File
@@ -1343,6 +1343,7 @@ export interface QuerySort {
| "added_at"
| "release_date"
| "last_air_date"
| "latest_episode_added"
| "year"
| "content_rating"
| "runtime"
+8
View File
@@ -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",