Files
silo-server/internal/jellycompat/query_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

143 lines
4.2 KiB
Go

package jellycompat
import (
"net/http/httptest"
"testing"
)
func TestFavoriteItemsNeedBrowseFilters(t *testing.T) {
if favoriteItemsNeedBrowseFilters(itemsQuery{}) {
t.Fatal("plain favorite query should keep the lightweight favorites path")
}
query := itemsQuery{parentLibraryID: 42}
if !favoriteItemsNeedBrowseFilters(query) {
t.Fatal("favorite query with a parent library should use catalog browse filters")
}
}
func TestParseItemsQueryAcceptsIsFavoriteParam(t *testing.T) {
req := httptest.NewRequest("GET", "/Users/user/Items?isFavorite=true&IncludeItemTypes=Series", nil)
query := parseItemsQuery(req, NewResourceIDCodec())
if !query.isFavorite {
t.Fatal("expected isFavorite=true to enable favorite filtering")
}
if len(query.itemTypes) != 1 || query.itemTypes[0] != "series" {
t.Fatalf("got item types %v, want [series]", query.itemTypes)
}
}
func TestBuildBrowseParamsPropagatesEnableTotalRecordCount(t *testing.T) {
req := httptest.NewRequest("GET", "/Items?EnableTotalRecordCount=false", nil)
query := parseItemsQuery(req, NewResourceIDCodec())
params := buildBrowseParams(query)
if got := params.Get("include_total"); got != "false" {
t.Fatalf("include_total = %q, want false", got)
}
}
func TestParseItemsQueryAppliesExcludeItemTypesToDefaultVideoScope(t *testing.T) {
req := httptest.NewRequest("GET", "/Items?SearchTerm=sponge+bob"+
"&ExcludeItemTypes=Movie&ExcludeItemTypes=Episode&ExcludeItemTypes=TvChannel", nil)
query := parseItemsQuery(req, NewResourceIDCodec())
if !query.hasItemTypeFilter {
t.Fatal("expected ExcludeItemTypes to count as an item type filter")
}
if len(query.itemTypes) != 1 || query.itemTypes[0] != "series" {
t.Fatalf("itemTypes = %v, want [series]", query.itemTypes)
}
}
func TestParseItemsQuerySubtractsExcludeItemTypesFromIncludeItemTypes(t *testing.T) {
req := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Movie,Series&ExcludeItemTypes=Movie", nil)
query := parseItemsQuery(req, NewResourceIDCodec())
if len(query.itemTypes) != 1 || query.itemTypes[0] != "series" {
t.Fatalf("itemTypes = %v, want [series]", query.itemTypes)
}
}
func TestMapSortByReleaseDate(t *testing.T) {
tests := []string{
"PremiereDate",
"PremiereDate,SortName,ProductionYear",
"Premiered",
}
for _, raw := range tests {
if got := mapSortBy(raw); got != "release_date" {
t.Fatalf("mapSortBy(%q) = %q, want release_date", raw, got)
}
}
}
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"}
if len(got) != len(want) {
t.Fatalf("got %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("got %v, want %v", got, want)
}
}
}