* 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>
89 lines
2.9 KiB
Go
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
|
|
}
|
|
}
|