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

89 lines
2.9 KiB
Go

package jellycompat
import (
"context"
"strings"
)
// probedDurationSource is the DetailService subset used to resolve probed
// file durations. Declared as an interface so tests can substitute a
// counting fake without standing up a Postgres pool.
type probedDurationSource interface {
ProbedDurationsByContentIDs(ctx context.Context, ids []string) map[string]int
ProbedDurationsByEpisodeIDs(ctx context.Context, ids []string) map[string]int
}
// fillListItemDurations resolves movie and episode durations in separate
// batches and mutates items in place.
func fillListItemDurations(ctx context.Context, src probedDurationSource, items []upstreamListItem) {
if src == nil || len(items) == 0 {
return
}
movieIDs := make([]string, 0, len(items))
episodeIDs := make([]string, 0, len(items))
seenMovies := make(map[string]struct{}, len(items))
seenEpisodes := make(map[string]struct{}, len(items))
for _, item := range items {
switch {
case strings.EqualFold(item.Type, "movie"):
if item.ContentID != "" {
if _, ok := seenMovies[item.ContentID]; !ok {
seenMovies[item.ContentID] = struct{}{}
movieIDs = append(movieIDs, item.ContentID)
}
}
case strings.EqualFold(item.Type, "episode"):
if item.ContentID != "" {
if _, ok := seenEpisodes[item.ContentID]; !ok {
seenEpisodes[item.ContentID] = struct{}{}
episodeIDs = append(episodeIDs, item.ContentID)
}
}
}
}
var movieDurations, episodeDurations map[string]int
if len(movieIDs) > 0 {
movieDurations = src.ProbedDurationsByContentIDs(ctx, movieIDs)
}
if len(episodeIDs) > 0 {
episodeDurations = src.ProbedDurationsByEpisodeIDs(ctx, episodeIDs)
}
// Only a positive lookup overwrites the field: an absent id, or a nil map
// from a failed query, must leave an already-resolved duration intact
// rather than silently zeroing it back to "unknown".
for i := range items {
switch {
case strings.EqualFold(items[i].Type, "movie"):
if duration := movieDurations[items[i].ContentID]; duration > 0 {
items[i].DurationSeconds = duration
}
case strings.EqualFold(items[i].Type, "episode"):
if duration := episodeDurations[items[i].ContentID]; duration > 0 {
items[i].DurationSeconds = duration
}
}
}
}
// fillEpisodeTargetDurations resolves durations when a caller builds its DTOs
// directly from compat episode targets. Overlay-only callers already have an
// enriched list item and must not pay for the same lookup again.
func fillEpisodeTargetDurations(ctx context.Context, src probedDurationSource, targets map[string]compatEpisodeTarget) {
if len(targets) == 0 {
return
}
items := make([]upstreamListItem, 0, len(targets))
for _, target := range targets {
items = append(items, target.Item)
}
fillListItemDurations(ctx, src, items)
for _, item := range items {
target := targets[item.ContentID]
target.Item = item
targets[item.ContentID] = target
}
}