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
@@ -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":
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -1343,6 +1343,7 @@ export interface QuerySort {
|
||||
| "added_at"
|
||||
| "release_date"
|
||||
| "last_air_date"
|
||||
| "latest_episode_added"
|
||||
| "year"
|
||||
| "content_rating"
|
||||
| "runtime"
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user