* 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>
227 lines
8.0 KiB
Go
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))
|
|
}
|
|
}
|