Files
silo-server/internal/scanner/file_repo_duration_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

118 lines
3.8 KiB
Go

package scanner
import (
"context"
"fmt"
"os"
"reflect"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestFirstDurationsByIDs(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()
movieID := fmt.Sprintf("duration-movie-%d", suffix)
seriesID := fmt.Sprintf("duration-series-%d", suffix)
episodeOneID := fmt.Sprintf("duration-episode-one-%d", suffix)
episodeTwoID := fmt.Sprintf("duration-episode-two-%d", suffix)
var folderID int
if err := pool.QueryRow(ctx, `
INSERT INTO media_folders (type, name, enabled)
VALUES ('mixed', 'Duration Lookup Test', true)
RETURNING id
`).Scan(&folderID); err != nil {
t.Fatalf("seed folder: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM media_files WHERE media_folder_id = $1`, folderID)
_, _ = pool.Exec(ctx, `DELETE FROM media_items WHERE content_id = ANY($1)`, []string{movieID, seriesID})
_, _ = 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, 'movie', 'Duration Movie', 'matched', '{}'::text[]),
($2, 'series', 'Duration Series', 'matched', '{}'::text[])
`, movieID, seriesID); err != nil {
t.Fatalf("seed items: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO episodes (content_id, series_id, season_number, episode_number, title)
VALUES
($1, $3, 1, 1, 'Episode One'),
($2, $3, 1, 2, 'Episode Two')
`, episodeOneID, episodeTwoID, seriesID); err != nil {
t.Fatalf("seed episodes: %v", err)
}
fileNumber := 0
seedFile := func(contentID, episodeID string, duration int, missing bool) {
t.Helper()
fileNumber++
var episodeValue any
if episodeID != "" {
episodeValue = episodeID
}
if _, err := pool.Exec(ctx, `
INSERT INTO media_files (
content_id, episode_id, media_folder_id, file_path, file_size,
duration, missing_since
) VALUES ($1, $2, $3, $4, 1024, $5,
CASE WHEN $6 THEN NOW() ELSE NULL END)
`, contentID, episodeValue, folderID,
fmt.Sprintf("/tmp/duration-lookup-%d-%d.mkv", suffix, fileNumber),
duration, missing,
); err != nil {
t.Fatalf("seed file %d: %v", fileNumber, err)
}
}
// Content lookup: skip missing and zero-duration files, then choose the
// first positive live file by id.
seedFile(movieID, "", 900, true)
seedFile(movieID, "", 0, false)
seedFile(movieID, "", 120, false)
seedFile(movieID, "", 240, false)
// Episode files share the parent series content_id and must never make the
// series appear to have a directly-backed duration. The episode lookup
// independently follows the same live/positive/first-id rule.
seedFile(seriesID, episodeOneID, 800, true)
seedFile(seriesID, episodeOneID, 0, false)
seedFile(seriesID, episodeOneID, 300, false)
seedFile(seriesID, episodeOneID, 600, false)
seedFile(seriesID, episodeTwoID, 0, false)
repo := NewFileRepository(pool)
contentDurations, err := repo.FirstDurationsByContentIDs(ctx, []string{movieID, seriesID})
if err != nil {
t.Fatalf("FirstDurationsByContentIDs: %v", err)
}
if want := map[string]int{movieID: 120}; !reflect.DeepEqual(contentDurations, want) {
t.Fatalf("content durations = %v, want %v", contentDurations, want)
}
episodeDurations, err := repo.FirstDurationsByEpisodeIDs(ctx, []string{episodeOneID, episodeTwoID})
if err != nil {
t.Fatalf("FirstDurationsByEpisodeIDs: %v", err)
}
if want := map[string]int{episodeOneID: 300}; !reflect.DeepEqual(episodeDurations, want) {
t.Fatalf("episode durations = %v, want %v", episodeDurations, want)
}
}