* 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>
39 lines
1.2 KiB
Go
39 lines
1.2 KiB
Go
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
|
|
}
|