Files
silo-server/internal/jellycompat/probed_runtime_test.go
7ab393fc3a fix(jellycompat): resolve item duration probed-first with runtime fallback (#493)
* fix(jellycompat): resolve item duration probed-first with runtime fallback

Jellyfin-protocol clients received no runtime at all for items whose catalog
runtime is 0. RunTimeTicks is omitempty, so a zero value is dropped from the
JSON entirely rather than sent as 0, and strict clients (Infuse) abandon
playback on those items. On the production deployment 5,245 movies have
media_items.runtime = 0 while 5,239 of them have a correct probed
media_files.duration.

Resolve duration at read time the way /api/v1 already does: probed file
duration first, catalog runtime as the fallback. The item row is deliberately
not backfilled — one item can have several versions of different lengths, so
per-file data does not belong there.

- scanner: FirstDurationsByContentIDs / FirstDurationsByEpisodeIDs, batched
  lookups using the same "first live file with duration > 0, ordered by id"
  rule as the v1 API's contentDurationSeconds. The episode_id IS NULL guard on
  the content-id query is load-bearing: every episode file carries its series'
  content_id, so without it a series row would report an episode's duration.
- catalog: optional batchDurationFetcher extension on DetailService, following
  the existing extraFileFetcher pattern so test fakes need no changes.
  Nil-receiver safe and fail-soft — a failed lookup logs and degrades to the
  catalog runtime rather than failing the page.
- jellycompat: DurationSeconds on upstreamListItem/upstreamEpisode, a shared
  runtimeTicks resolver, and fillListItemDurations wired into the nine page
  producers. Fixes the three sites that had no fallback (itemFromList,
  episodeFromUpstream, HandleSearchHints); the detail and PlaybackInfo paths
  were already correct.

This is additive within the v1 rules — it populates a field that was
previously omitted. No field is renamed, removed, retyped, or repurposed.

* fix(jellycompat): avoid duplicate duration lookups

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-07-28 21:33:30 -04:00

227 lines
8.0 KiB
Go

package jellycompat
import (
"context"
"encoding/json"
"reflect"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/models"
)
func TestRuntimeTicks(t *testing.T) {
tests := []struct {
name string
durationSeconds int
runtimeMinutes int
want int64
}{
{name: "probe only", durationSeconds: 3600, want: secondsToTicks(3600)},
{name: "runtime only", runtimeMinutes: 60, want: minutesToTicks(60)},
{name: "neither", want: 0},
{name: "probe wins", durationSeconds: 120, runtimeMinutes: 90, want: secondsToTicks(120)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := runtimeTicks(tt.durationSeconds, tt.runtimeMinutes); got != tt.want {
t.Fatalf("runtimeTicks(%d, %d) = %d, want %d", tt.durationSeconds, tt.runtimeMinutes, got, tt.want)
}
})
}
if secondsToTicks(3600) != minutesToTicks(60) {
t.Fatalf("unit mismatch: 3600 seconds = %d ticks, 60 minutes = %d ticks", secondsToTicks(3600), minutesToTicks(60))
}
}
func TestItemFromListRuntimeResolution(t *testing.T) {
m := newMapper(NewResourceIDCodec(), &config.Config{})
dto := m.itemFromList(upstreamListItem{
ContentID: "movie-1",
Type: "movie",
Title: "Movie",
DurationSeconds: 5400,
}, false, nil, nil)
if dto.RunTimeTicks != secondsToTicks(5400) {
t.Fatalf("RunTimeTicks = %d, want %d", dto.RunTimeTicks, secondsToTicks(5400))
}
unknown := m.itemFromList(upstreamListItem{ContentID: "movie-2", Type: "movie", Title: "Unknown"}, false, nil, nil)
raw, err := json.Marshal(unknown)
if err != nil {
t.Fatalf("marshal dto: %v", err)
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(raw, &fields); err != nil {
t.Fatalf("unmarshal dto: %v", err)
}
if _, ok := fields["RunTimeTicks"]; ok {
t.Fatalf("RunTimeTicks should be absent when neither duration is known: %s", raw)
}
}
func TestEpisodeFromUpstreamRuntimeResolution(t *testing.T) {
m := newMapper(NewResourceIDCodec(), &config.Config{})
tests := []struct {
name string
durationSeconds int
runtimeMinutes int
want int64
}{
{name: "probe only", durationSeconds: 1800, want: secondsToTicks(1800)},
{name: "runtime only", runtimeMinutes: 30, want: minutesToTicks(30)},
{name: "neither", want: 0},
{name: "probe wins", durationSeconds: 1800, runtimeMinutes: 45, want: secondsToTicks(1800)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dto := m.episodeFromUpstream(upstreamEpisode{
ContentID: "episode-1",
Title: "Episode",
Runtime: tt.runtimeMinutes,
DurationSeconds: tt.durationSeconds,
}, false, nil)
if dto.RunTimeTicks != tt.want {
t.Fatalf("RunTimeTicks = %d, want %d", dto.RunTimeTicks, tt.want)
}
})
}
}
type countingProbedDurationSource struct {
contentCalls int
episodeCalls int
contentIDs []string
episodeIDs []string
content map[string]int
episodes map[string]int
}
func (s *countingProbedDurationSource) ProbedDurationsByContentIDs(_ context.Context, ids []string) map[string]int {
s.contentCalls++
s.contentIDs = append([]string(nil), ids...)
return s.content
}
func (s *countingProbedDurationSource) ProbedDurationsByEpisodeIDs(_ context.Context, ids []string) map[string]int {
s.episodeCalls++
s.episodeIDs = append([]string(nil), ids...)
return s.episodes
}
func TestFillListItemDurationsBucketsAndDeduplicates(t *testing.T) {
src := &countingProbedDurationSource{
content: map[string]int{"movie-1": 600},
episodes: map[string]int{"episode-1": 1200},
}
items := []upstreamListItem{
{ContentID: "movie-1", Type: "movie"},
{ContentID: "movie-1", Type: "MOVIE"},
{ContentID: "episode-1", Type: "episode"},
{ContentID: "episode-1", Type: "Episode"},
{ContentID: "series-1", Type: "series"},
{ContentID: "season-1", Type: "season"},
}
fillListItemDurations(context.Background(), src, items)
if src.contentCalls != 1 || !reflect.DeepEqual(src.contentIDs, []string{"movie-1"}) {
t.Fatalf("content calls/ids = %d/%v, want 1/[movie-1]", src.contentCalls, src.contentIDs)
}
if src.episodeCalls != 1 || !reflect.DeepEqual(src.episodeIDs, []string{"episode-1"}) {
t.Fatalf("episode calls/ids = %d/%v, want 1/[episode-1]", src.episodeCalls, src.episodeIDs)
}
if items[0].DurationSeconds != 600 || items[1].DurationSeconds != 600 {
t.Fatalf("movie durations = %d/%d, want 600/600", items[0].DurationSeconds, items[1].DurationSeconds)
}
if items[2].DurationSeconds != 1200 || items[3].DurationSeconds != 1200 {
t.Fatalf("episode durations = %d/%d, want 1200/1200", items[2].DurationSeconds, items[3].DurationSeconds)
}
if items[4].DurationSeconds != 0 || items[5].DurationSeconds != 0 {
t.Fatalf("non-playable durations changed: %d/%d", items[4].DurationSeconds, items[5].DurationSeconds)
}
}
func TestFillListItemDurationsSkipsEmptyBucketsAndNilSource(t *testing.T) {
src := &countingProbedDurationSource{content: nil}
items := []upstreamListItem{{ContentID: "movie-1", Type: "movie", Runtime: 90}}
fillListItemDurations(context.Background(), src, items)
if src.contentCalls != 1 || src.episodeCalls != 0 {
t.Fatalf("content/episode calls = %d/%d, want 1/0", src.contentCalls, src.episodeCalls)
}
if items[0].DurationSeconds != 0 {
t.Fatalf("DurationSeconds = %d, want 0 after nil result", items[0].DurationSeconds)
}
dto := newMapper(NewResourceIDCodec(), &config.Config{}).itemFromList(items[0], false, nil, nil)
if dto.RunTimeTicks != minutesToTicks(90) {
t.Fatalf("nil probe result did not fall back to catalog runtime: %d", dto.RunTimeTicks)
}
before := append([]upstreamListItem(nil), items...)
fillListItemDurations(context.Background(), nil, items)
if !reflect.DeepEqual(items, before) {
t.Fatalf("nil source mutated items: got %+v, want %+v", items, before)
}
}
// A failed lookup returns a nil map. That must not wipe a duration an earlier
// producer already resolved, otherwise a transient DB error would downgrade an
// item that was already correct.
func TestFillListItemDurationsKeepsResolvedDurationOnEmptyResult(t *testing.T) {
src := &countingProbedDurationSource{content: nil, episodes: nil}
items := []upstreamListItem{
{ContentID: "movie-1", Type: "movie", DurationSeconds: 5400},
{ContentID: "episode-1", Type: "episode", DurationSeconds: 1500},
}
fillListItemDurations(context.Background(), src, items)
if items[0].DurationSeconds != 5400 || items[1].DurationSeconds != 1500 {
t.Fatalf("durations = %d/%d, want 5400/1500 preserved", items[0].DurationSeconds, items[1].DurationSeconds)
}
}
func TestWriteEpisodeModelsPageUsesProbedTargetDuration(t *testing.T) {
codec := NewResourceIDCodec()
seriesID := "series-1"
seasonID := "season-1"
episodeID := "episode-1"
episodeRepo := &fakeSeasonEpisodeRepo{bySeason: map[string][]*models.Episode{
episodeBySeasonKey(seriesID, 1): {
{
ContentID: episodeID,
SeriesID: seriesID,
SeasonID: seasonID,
SeasonNumber: 1,
EpisodeNumber: 1,
Title: "Episode",
Runtime: 0,
},
},
}}
itemRepo := &countingItemRepo{itemsByID: map[string]*models.MediaItem{
seriesID: {ContentID: seriesID, Type: "series", Title: "Series"},
}}
durationSrc := &countingProbedDurationSource{episodes: map[string]int{episodeID: 1500}}
h := &ItemsHandler{
content: &countingContentService{seasons: []upstreamSeason{{ContentID: seasonID, SeasonNumber: 1, Title: "Season 1", EpisodeCount: 1}}},
userData: &mockUserDataService{},
codec: codec,
mapper: newMapper(codec, &config.Config{}),
images: NewImageCache(time.Hour, time.Now),
itemRepo: itemRepo,
episodeRepo: episodeRepo,
durationSrc: durationSrc,
}
result := performEpisodesRequest(t, h, "/Shows/"+codec.EncodeStringID(EncodedIDItem, seriesID)+"/Episodes", codec.EncodeStringID(EncodedIDItem, seriesID))
if len(result.Items) != 1 {
t.Fatalf("len(Items) = %d, want 1", len(result.Items))
}
if result.Items[0].RunTimeTicks != secondsToTicks(1500) {
t.Fatalf("RunTimeTicks = %d, want %d", result.Items[0].RunTimeTicks, secondsToTicks(1500))
}
}