Files
silo-server/internal/scanner/episode_added_denorm_test.go
c0f209a936 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>
2026-07-04 23:43:19 -04:00

161 lines
5.7 KiB
Go

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)
}
}