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

968 lines
32 KiB
Go

package jellycompat
import (
"crypto/sha1"
"encoding/hex"
"log/slog"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/models"
)
// allDetailFields is a sentinel passed to itemFromList so detail views include all fields.
var allDetailFields = map[string]bool{
"*": true, "overview": true, "genres": true, "premieredate": true,
"studios": true, "tags": true, "taglines": true, "etag": true,
"sortname": true, "productionlocations": true, "criticrating": true,
"providerids": true, "externalurls": true, "remotetrailers": true,
"datecreated": true, "mediastreams": true, "path": true,
}
type mapper struct {
codec *ResourceIDCodec
serverID string
imageTagSigner *imageTagSigner
}
func newMapper(codec *ResourceIDCodec, cfg *config.Config) *mapper {
serverID := ""
imageTagSecret := ""
if cfg != nil {
serverID = cfg.JellyfinCompat.ServerID
imageTagSecret = cfg.Auth.JWTSecret
}
return &mapper{codec: codec, serverID: serverID, imageTagSigner: newImageTagSigner(imageTagSecret)}
}
func (m *mapper) viewFromLibrary(library upstreamUserLibrary) baseItemDTO {
imgTags := map[string]string{}
routeID := m.codec.EncodeIntID(EncodedIDLibrary, int64(library.ID))
if library.PosterPath != "" {
imgTags["Primary"] = m.imageTagSigner.Tag(
imageTagSeed(routeID, "Primary", compatCardImageSize, library.PosterPath, "", time.Time{}),
library.PosterURL,
)
}
return baseItemDTO{
ID: routeID,
Type: "CollectionFolder",
MediaType: "Unknown",
IsFolder: true,
Name: library.Name,
ServerID: m.serverID,
CollectionType: libraryCollectionType(library.Type),
SortName: strings.ToLower(library.Name),
ImageTags: imgTags,
UserData: &itemUserDataDTO{
Key: routeID,
ItemID: routeID,
},
}
}
// applyPlayableLocation stamps location fields for a movie/episode based on
// whether any media file backs it. Fileless (provider-metadata-only) items are
// Jellyfin "Virtual" and carry no VideoType, so clients exclude them from
// playback queues. Items whose file went missing are currently also reported
// as Virtual rather than Jellyfin's "Offline".
func applyPlayableLocation(dto *baseItemDTO, hasFile bool) {
if hasFile {
dto.LocationType = "FileSystem"
dto.VideoType = "VideoFile"
} else {
dto.LocationType = "Virtual"
dto.VideoType = ""
}
}
func (m *mapper) itemFromList(item upstreamListItem, isFavorite bool, progress *upstreamProgress, fields map[string]bool) baseItemDTO {
_, allFields := fields["*"] // detail views pass allDetailFields sentinel
dto := baseItemDTO{
ID: m.codec.EncodeStringID(EncodedIDItem, item.ContentID),
Type: jellyfinItemType(item.Type),
IsFolder: jellyfinIsFolder(item.Type),
Name: item.Title,
ServerID: m.serverID,
ProductionYear: item.Year,
OfficialRating: item.ContentRating,
CommunityRating: item.RatingIMDB,
ImageTags: map[string]string{},
UserData: userDataDTO(m.codec.EncodeStringID(EncodedIDItem, item.ContentID), item.UserData, isFavorite, progress),
}
if mt := jellyfinMediaType(item.Type); mt != "" {
dto.MediaType = mt
}
if ticks := runtimeTicks(item.DurationSeconds, item.Runtime); ticks > 0 {
dto.RunTimeTicks = ticks
}
if item.Type == "movie" || item.Type == "episode" {
applyPlayableLocation(&dto, item.HasMediaFiles == nil || *item.HasMediaFiles)
}
if item.SeriesID != "" {
dto.SeriesID = m.codec.EncodeStringID(EncodedIDItem, item.SeriesID)
dto.SeriesName = item.SeriesTitle
}
if item.SeasonNumber != nil {
dto.ParentIndexNumber = item.SeasonNumber
}
if item.EpisodeNumber != nil {
dto.IndexNumber = item.EpisodeNumber
}
if item.EpisodeCount != nil {
dto.ChildCount = *item.EpisodeCount
dto.RecursiveItemCount = *item.EpisodeCount
}
if item.SeasonCount != nil {
seasonCount := *item.SeasonCount
dto.ChildCount = seasonCount
dto.RecursiveItemCount = seasonCount
dto.SeasonCount = seasonCount
}
primaryPath, primaryThumbhash := listItemPrimaryImageSeedParts(item)
if tags := imageTagsWithSeed(m.imageTagSigner,
imageTagSeed(item.ContentID, "Primary", compatCardImageSize, primaryPath, primaryThumbhash, item.UpdatedAt),
item.PosterURL,
); tags != nil {
dto.ImageTags = tags
}
if tags := backdropTagsWithSeed(m.imageTagSigner,
imageTagSeed(item.ContentID, "Backdrop", compatCardImageSize, item.BackdropPath, item.BackdropThumbhash, item.UpdatedAt),
item.BackdropURL,
); tags != nil {
dto.BackdropImageTags = tags
}
if ratio := primaryAspectRatio(item.Type); ratio != nil {
dto.PrimaryImageAspectRatio = ratio
}
// Optional fields — only included when explicitly requested via Fields param.
if allFields || fields["overview"] {
dto.Overview = item.Overview
}
if allFields || fields["genres"] {
dto.Genres = nonNilStrings(item.Genres)
dto.GenreItems = m.genreItems(item.Genres)
}
// PremiereDate is always included (part of Jellyfin's minimal default set).
if item.AirDate != "" {
dto.PremiereDate = ensureRFC3339(item.AirDate)
}
if allFields || fields["etag"] {
dto.Etag = itemEtag(item)
}
if allFields || fields["sortname"] {
dto.SortName = firstNonEmpty(item.SortTitle, item.Title)
}
if allFields || fields["studios"] {
dto.Studios = m.namePairs(item.Studios, EncodedIDStudio)
}
if allFields || fields["taglines"] {
if item.Tagline != "" {
dto.Taglines = []string{item.Tagline}
}
}
if allFields || fields["tags"] {
dto.Tags = []string{}
}
if allFields || fields["productionlocations"] {
dto.ProductionLocations = append([]string{}, item.Countries...)
}
if allFields || fields["criticrating"] {
dto.CriticRating = item.RatingTMDB
}
if allFields || fields["mediasourcecount"] {
// The list path has no version data, so assume matched playable items
// have exactly one source. Unmatched/file-missing items leave this
// unset so clients fall back to the detail path (which reports the
// real count via len(item.Versions)).
if isPlayableItemType(item.Type) && item.Status == "matched" {
dto.MediaSourceCount = 1
}
}
if allFields || fields["providerids"] {
dto.ProviderIDs = map[string]string{}
}
// Fields that are never populated in list view — only include in detail.
if allFields {
dto.PlayAccess = "Full"
dto.EnableMediaSourceDisplay = true
dto.DisplayPreferencesID = m.codec.EncodeStringID(EncodedIDItem, item.ContentID)
dto.ExternalURLs = []map[string]any{}
dto.RemoteTrailers = []map[string]any{}
dto.ImageBlurHashes = map[string]map[string]string{}
dto.LockedFields = []string{}
dto.Chapters = []map[string]any{}
dto.Trickplay = map[string]any{}
dto.MediaStreams = []mediaStreamDTO{}
}
return dto
}
// stubDetailPerson, stubDetailMediaSource, and stubDetailMediaStream are the
// single placeholder elements injected into a list-mapped DTO by
// stubDetailListFields when the client requested a detail-only Fields value
// but the endpoint (currently Resume) deliberately skips the per-item
// GetItemDetail fetch. Using a non-empty single-element slice keeps the
// field present in JSON regardless of the omitempty tag, so strict client
// deserializers see the same shape they'd get from a populated catalog
// (every real item has at least one media source, stream, and cast/crew
// entry — the empty-array case never happens in practice, so we should not
// rely on omitempty doing anything useful for these fields).
//
// The values are minimal: each carries the required (non-omitempty) fields
// only. Clients that consume this data downstream will see a single source
// with a stub ID, a single Video stream at index 0, and a single placeholder
// person. The stub is a FALLBACK, not a no-op: Infuse and SenPlayer build
// their Continue Watching rows from the listing's MediaSources, so pages
// within the detail-upgrade cap get real sources (upgradeProgressPageToDetail)
// and only overflow/error entries see the stub — which must therefore still
// look playable and serialize collections as empty, never null.
var (
stubDetailPerson = personDTO{ID: "0", Name: ""}
stubDetailMediaSource = mediaSourceDTO{ID: "0"}
stubDetailMediaStream = mediaStreamDTO{Index: 0, Type: "Video"}
)
// stubDetailListFields sets the four detail-only fields on a list-mapped DTO
// to single-element placeholder slices when the client requested them via
// Fields=People|Chapters|MediaStreams|MediaSources. Used by the Resume/NextUp
// scan phase, which deliberately skips per-item GetItemDetail fanout; the
// returned page is then re-mapped through the real detail path
// (upgradeProgressPageToDetail), so these stubs reach clients only for
// entries past the detail-upgrade cap or whose detail fetch failed.
func stubDetailListFields(dto *baseItemDTO, fields map[string]bool) {
if len(fields) == 0 {
return
}
if fields["people"] && dto.People == nil {
dto.People = []personDTO{stubDetailPerson}
}
if fields["chapters"] && dto.Chapters == nil {
// Constructed fresh per call because map values are reference-typed
// and could be mutated by downstream code.
dto.Chapters = []map[string]any{{"Name": "", "StartPositionTicks": int64(0)}}
}
if fields["mediastreams"] && dto.MediaStreams == nil {
dto.MediaStreams = []mediaStreamDTO{stubDetailMediaStream}
}
if fields["mediasources"] && dto.MediaSources == nil {
// The stub must still look PLAYABLE: some clients (SenPlayer) decide
// whether to render a Resume row entry from the listing's
// MediaSources, and an all-false playability stub makes them drop
// every item. Keep the stub ID ("0" — never a registered media source
// owner) but mirror the shape detailMediaSourceDTO produces; the real
// source set is still refetched via /Items/{id}/PlaybackInfo on play.
src := stubDetailMediaSource
src.Protocol = "File"
src.Type = "Default"
src.VideoType = "VideoFile"
src.Name = dto.Name
src.RunTimeTicks = dto.RunTimeTicks
src.SupportsTranscoding = true
src.SupportsDirectStream = true
src.SupportsDirectPlay = true
src.SupportsProbing = true
src.TranscodingSubProtocol = "hls"
src.MediaStreams = []mediaStreamDTO{stubDetailMediaStream}
// Real servers never serialize these as JSON null (always []/{});
// strict client deserializers fail the whole response on a null
// array, which empties the Resume/NextUp rows entirely.
src.Formats = []string{}
src.RequiredHTTPHeaders = map[string]string{}
src.MediaAttachments = []map[string]any{}
dto.MediaSources = []mediaSourceDTO{src}
}
}
// itemFromDetail maps a detail payload into a full baseItemDTO, including every
// heavy field (People, MediaSources, MediaStreams, Chapters). Use this from
// single-item detail endpoints where the client expects the full payload.
func (m *mapper) itemFromDetail(item upstreamItemDetail, isFavorite bool, progress *upstreamProgress) baseItemDTO {
return m.itemFromDetailWithFields(item, isFavorite, progress, nil)
}
// itemFromDetailWithFields is the field-aware variant used by list endpoints
// that fall into the detail path. A nil requestedFields map means "all fields"
// (legacy detail-endpoint behavior). When non-nil, heavy nested arrays are only
// populated if the client explicitly requested them via the Fields query
// parameter — matching Jellyfin's opt-in semantics for People, MediaSources,
// MediaStreams, and Chapters.
func (m *mapper) itemFromDetailWithFields(item upstreamItemDetail, isFavorite bool, progress *upstreamProgress, requestedFields map[string]bool) baseItemDTO {
allFields := requestedFields == nil
wantField := func(name string) bool {
return allFields || requestedFields[name]
}
dto := m.itemFromList(upstreamListItem{
ContentID: item.ContentID,
Type: item.Type,
Title: item.Title,
SortTitle: item.SortTitle,
Year: item.Year,
Genres: item.Genres,
ContentRating: item.ContentRating,
RatingIMDB: item.RatingIMDB,
Overview: item.Overview,
PosterURL: item.PosterURL,
BackdropURL: item.BackdropURL,
PosterPath: item.PosterPath,
PosterThumbhash: item.PosterThumbhash,
BackdropPath: item.BackdropPath,
BackdropThumbhash: item.BackdropThumbhash,
LogoPath: item.LogoPath,
UpdatedAt: item.UpdatedAt,
SeasonCount: item.SeasonCount,
SeriesID: item.SeriesID,
SeriesTitle: item.SeriesTitle,
SeasonNumber: item.SeasonNumber,
EpisodeNumber: item.EpisodeNumber,
EpisodeCount: item.EpisodeCount,
Runtime: item.Runtime,
AirDate: derefString(item.AirDate),
UserData: item.UserData,
}, isFavorite, progress, allDetailFields)
if wantField("people") {
dto.People = make([]personDTO, 0, len(item.Cast)+len(item.Crew))
for _, cast := range item.Cast {
personID, _ := strconv.ParseInt(cast.PersonID, 10, 64)
dto.People = append(dto.People, personDTO{
ID: m.codec.EncodeIntID(EncodedIDPerson, personID),
Name: cast.Name,
Role: cast.Character,
Type: "Actor",
PrimaryImageTag: tagValue(cast.PhotoURL),
})
}
for _, crew := range item.Crew {
personID, _ := strconv.ParseInt(crew.PersonID, 10, 64)
dto.People = append(dto.People, personDTO{
ID: m.codec.EncodeIntID(EncodedIDPerson, personID),
Name: crew.Name,
Role: crew.Job,
Type: crew.Job,
PrimaryImageTag: tagValue(crew.PhotoURL),
})
}
}
// Remote provider trailers and local extras counts. The itemFromList base
// stamped RemoteTrailers as an empty slice; override with real data here
// on the detail path.
dto.RemoteTrailers = remoteTrailerDTOs(item.Videos)
localTrailers, specialFeatures := countLocalExtras(item.Extras)
dto.LocalTrailerCount = localTrailers
dto.SpecialFeatureCount = specialFeatures
if item.SeriesID != "" {
dto.SeriesID = m.codec.EncodeStringID(EncodedIDItem, item.SeriesID)
}
if item.Type == "season" {
dto.ID = m.codec.EncodeStringID(EncodedIDSeason, item.ContentID)
}
dto.ServerID = m.serverID
dto.CanDelete = false
dto.OriginalTitle = firstNonEmpty(item.OriginalTitle, item.Title)
dto.SortName = firstNonEmpty(item.SortTitle, item.OriginalTitle, item.Title)
dto.ForcedSortName = dto.SortName
dto.CriticRating = item.RatingTMDB
dto.Studios = m.namePairs(item.Studios, EncodedIDStudio)
dto.ProductionLocations = append([]string{}, item.Countries...)
if item.Tagline != "" {
dto.Taglines = []string{item.Tagline}
}
if item.AirDate != nil {
dto.PremiereDate = ensureRFC3339(*item.AirDate)
} else if item.Year > 0 {
dto.PremiereDate = syntheticPremiereDate(item.Year)
}
if isPlayableItemType(item.Type) && len(item.Versions) > 0 {
dto.MediaSourceCount = len(item.Versions)
firstVersion := item.Versions[0]
if firstVersion.Duration > 0 {
dto.RunTimeTicks = secondsToTicks(float64(firstVersion.Duration))
}
dto.DateCreated = formatCompatTime(firstVersion.AddedAt)
// CanDownload is load-bearing for Infuse: it refuses Direct Play
// (Static=true streaming) of items it believes it cannot download.
// The flag is backed by the /Items/{id}/Download route (streams.go).
dto.CanDownload = true
dto.HasSubtitles = versionsHaveSubtitles(item.Versions)
dto.SupportsSync = false
dto.Container = strings.ToLower(firstVersion.Container)
applyPlayableLocation(&dto, true)
dto.Path = compatMediaPath(firstVersion)
if len(firstVersion.VideoTracks) > 0 {
dto.Width = firstVersion.VideoTracks[0].Width
dto.Height = firstVersion.VideoTracks[0].Height
dto.IsHD = firstVersion.VideoTracks[0].Height >= 720 || firstVersion.VideoTracks[0].Width >= 1280
}
routeItemID := m.codec.EncodeStringID(EncodedIDItem, item.ContentID)
wantMediaSources := wantField("mediasources")
wantMediaStreams := wantField("mediastreams")
if wantMediaSources {
dto.MediaSources = make([]mediaSourceDTO, 0, len(item.Versions))
}
// Register every version's file ID as owned by this item, even when the
// caller didn't ask for MediaSources/MediaStreams. Skipping this breaks
// later /Items/{mediaSourceId} lookups (LookupMediaSourceOwner returns
// ok=false → 404) whenever the detail was first materialized through a
// list endpoint without those Fields.
for _, version := range item.Versions {
m.codec.RegisterMediaSourceOwner(int64(version.FileID), item.ContentID)
if !wantMediaSources && !wantMediaStreams {
continue
}
sourceID := m.codec.EncodeIntID(EncodedIDMediaSource, int64(version.FileID))
streams := buildMediaStreams(routeItemID, sourceID, version)
if wantMediaStreams {
dto.MediaStreams = append(dto.MediaStreams, streams...)
}
if wantMediaSources {
dto.MediaSources = append(dto.MediaSources, detailMediaSourceDTO(sourceID, version, streams))
}
}
if wantField("chapters") {
dto.Chapters = compatChapters(firstVersion.Chapters, firstVersion.AddedAt)
}
} else if isPlayableItemType(item.Type) {
// Provider-metadata-only (unaired/missing) item: version data is
// authoritative here, so override the FileSystem default set by
// itemFromList with Jellyfin's Virtual contract.
applyPlayableLocation(&dto, false)
}
slog.Info("jellycompat item detail mapped",
"content_id", item.ContentID,
"type", item.Type,
"is_folder", dto.IsFolder,
"media_type", dto.MediaType,
"versions", len(item.Versions),
"media_sources", len(dto.MediaSources),
)
return dto
}
func (m *mapper) seasonFromUpstream(season upstreamSeason, seriesID string, isFavorite bool) baseItemDTO {
dto := baseItemDTO{
ID: m.codec.EncodeStringID(EncodedIDSeason, season.ContentID),
Type: "Season",
Name: season.Title,
IsFolder: true,
ServerID: m.serverID,
ImageTags: map[string]string{},
SeriesID: m.codec.EncodeStringID(EncodedIDItem, seriesID),
ParentID: m.codec.EncodeStringID(EncodedIDItem, seriesID),
UserData: userDataDTO(m.codec.EncodeStringID(EncodedIDSeason, season.ContentID), season.UserData, isFavorite, nil),
ChildCount: season.EpisodeCount,
RecursiveItemCount: season.EpisodeCount,
}
dto.IndexNumber = &season.SeasonNumber
if tags := imageTagsWithSeed(m.imageTagSigner,
imageTagSeed(season.ContentID, "Primary", compatCardImageSize, season.PosterPath, season.PosterThumbhash, season.UpdatedAt),
season.PosterURL,
); tags != nil {
dto.ImageTags = tags
}
return dto
}
func (m *mapper) episodeFromUpstream(ep upstreamEpisode, isFavorite bool, progress *upstreamProgress) baseItemDTO {
dto := baseItemDTO{
ID: m.codec.EncodeStringID(EncodedIDItem, ep.ContentID),
Type: "Episode",
MediaType: "Video",
Name: ep.Title,
ServerID: m.serverID,
Overview: ep.Overview,
RunTimeTicks: runtimeTicks(ep.DurationSeconds, ep.Runtime),
ImageTags: map[string]string{},
SeriesName: ep.SeriesTitle,
UserData: userDataDTO(m.codec.EncodeStringID(EncodedIDItem, ep.ContentID), ep.UserData, isFavorite, progress),
}
applyPlayableLocation(&dto, ep.HasMediaFiles == nil || *ep.HasMediaFiles)
dto.IndexNumber = &ep.EpisodeNumber
dto.ParentIndexNumber = &ep.SeasonNumber
if ep.SeriesID != "" {
dto.SeriesID = m.codec.EncodeStringID(EncodedIDItem, ep.SeriesID)
}
if ep.SeasonID != "" {
dto.SeasonID = m.codec.EncodeStringID(EncodedIDSeason, ep.SeasonID)
dto.ParentID = m.codec.EncodeStringID(EncodedIDSeason, ep.SeasonID)
}
if tags := imageTagsWithSeed(m.imageTagSigner,
imageTagSeed(ep.ContentID, "Primary", compatCardImageSize, ep.StillPath, ep.StillThumbhash, ep.UpdatedAt),
ep.StillURL,
); tags != nil {
dto.ImageTags = tags
}
return dto
}
type seriesImageSet struct {
ContentID string
PosterURL string
PosterPath string
PosterThumbhash string
BackdropURL string
BackdropPath string
BackdropThumbhash string
UpdatedAt time.Time
}
// applySeriesImages sets series/parent image tags on an episode DTO so clients
// can display the series poster and backdrop in Continue Watching / Next Up.
func (m *mapper) applySeriesImages(dto *baseItemDTO, series seriesImageSet) {
if dto.SeriesID == "" {
return
}
if series.PosterURL != "" {
dto.SeriesPrimaryImageTag = m.imageTagSigner.Tag(
imageTagSeed(series.ContentID, "Primary", compatCardImageSize, series.PosterPath, series.PosterThumbhash, series.UpdatedAt),
series.PosterURL,
)
}
if series.BackdropURL != "" {
tag := m.imageTagSigner.Tag(
imageTagSeed(series.ContentID, "Backdrop", compatCardImageSize, series.BackdropPath, series.BackdropThumbhash, series.UpdatedAt),
series.BackdropURL,
)
dto.ParentBackdropImageTags = []string{tag}
dto.ParentBackdropItemID = dto.SeriesID
dto.ParentThumbImageTag = tag
dto.ParentThumbItemID = dto.SeriesID
}
}
func userDataDTO(itemID string, data *catalog.SeasonUserData, isFavorite bool, progress *upstreamProgress) *itemUserDataDTO {
dto := &itemUserDataDTO{IsFavorite: isFavorite, ItemID: itemID, Key: itemID}
if data != nil {
pos := clampResumeSeconds(data.PositionSeconds, data.DurationSeconds)
dto.PlaybackPositionTicks = secondsToTicks(pos)
dto.PlayedPercentage = playedPercentage(pos, data.DurationSeconds, data.Played)
dto.Played = data.Played
dto.UnplayedItemCount = data.UnplayedCount
if data.Played {
dto.PlayCount = 1
}
}
if progress != nil {
pos := clampResumeSeconds(progress.PositionSeconds, progress.DurationSeconds)
played := dto.Played || progress.Completed
dto.PlaybackPositionTicks = secondsToTicks(pos)
dto.PlayedPercentage = playedPercentage(pos, progress.DurationSeconds, played)
dto.Played = played
if played {
dto.PlayCount = 1
}
dto.LastPlayedDate = progress.UpdatedAt
}
return dto
}
// playedPercentage derives PlayedPercentage from the same clamped position
// used for PlaybackPositionTicks so the two fields can never disagree. A
// played item at rest (position 0, no resume point) reports 100 so clients
// rendering progress from the DTO show it fully watched; a rewatch in flight
// reports its live fraction.
func playedPercentage(clampedPos, duration float64, played bool) float64 {
if played && clampedPos == 0 {
return 100
}
if duration <= 0 {
return 0
}
return (clampedPos / duration) * 100
}
func jellyfinItemType(native string) string {
switch strings.ToLower(native) {
case "movie":
return "Movie"
case "series":
return "Series"
case "episode":
return "Episode"
case "season":
return "Season"
case "extra":
// Local extras have no dedicated BaseItemKind; plain Video is what
// Jellyfin uses for special features.
return "Video"
default:
if native == "" {
return ""
}
return strings.ToUpper(native[:1]) + strings.ToLower(native[1:])
}
}
func libraryCollectionType(native string) string {
switch native {
case "movies":
return "movies"
case "series":
return "tvshows"
default:
return native
}
}
func jellyfinMediaType(native string) string {
switch strings.ToLower(native) {
case "movie", "episode":
return "Video"
default:
return ""
}
}
func jellyfinIsFolder(native string) bool {
switch strings.ToLower(native) {
case "series", "season":
return true
default:
return false
}
}
func isPlayableItemType(native string) bool {
switch strings.ToLower(native) {
case "movie", "episode":
return true
default:
return false
}
}
func (m *mapper) genreItems(genres []string) []namePairDTO {
return m.namePairs(genres, EncodedIDGenre)
}
func (m *mapper) namePairs(values []string, kind EncodedIDType) []namePairDTO {
items := make([]namePairDTO, 0, len(values))
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
continue
}
items = append(items, namePairDTO{
Name: trimmed,
ID: m.codec.EncodeStringID(kind, trimmed),
})
}
return items
}
func primaryAspectRatio(native string) *float64 {
switch strings.ToLower(native) {
case "movie", "series":
value := 2.0 / 3.0 // portrait poster
return &value
case "episode":
value := 16.0 / 9.0 // landscape still
return &value
default:
return nil
}
}
func versionsHaveSubtitles(versions []catalog.FileVersion) bool {
for _, version := range versions {
if len(version.SubtitleTracks) > 0 {
return true
}
}
return false
}
func compatChapters(chapters []catalog.VersionChapter, addedAt time.Time) []map[string]any {
if len(chapters) == 0 {
return []map[string]any{}
}
imageDateModified := formatCompatTime(addedAt)
if imageDateModified == "" {
imageDateModified = formatCompatTime(time.Unix(0, 0))
}
items := make([]map[string]any, 0, len(chapters))
for _, chapter := range chapters {
item := map[string]any{
"StartPositionTicks": secondsToTicks(chapter.StartSeconds),
"EndPositionTicks": secondsToTicks(chapter.EndSeconds),
"Name": chapter.Title,
"ImageDateModified": imageDateModified,
}
if chapter.ThumbnailURL != "" {
item["ImagePath"] = chapter.ThumbnailURL
}
if chapter.ThumbnailThumbhash != "" {
item["ImageTag"] = chapter.ThumbnailThumbhash
}
items = append(items, item)
}
return items
}
// remoteTrailerDTOs maps remote provider videos of trailer kinds onto
// Jellyfin's RemoteTrailers MediaUrl shape ({Url, Name}). Non-trailer kinds
// (featurettes, clips, ...) have no Jellyfin remote surface and are omitted.
func remoteTrailerDTOs(videos []catalog.ItemVideoInfo) []map[string]any {
trailers := []map[string]any{}
for _, v := range videos {
if v.Kind != string(models.ExtraKindTrailer) && v.Kind != string(models.ExtraKindTeaser) {
continue
}
var url string
switch v.Site {
case "youtube":
url = "https://www.youtube.com/watch?v=" + v.SiteKey
case "vimeo":
url = "https://vimeo.com/" + v.SiteKey
default:
continue
}
trailers = append(trailers, map[string]any{
"Url": url,
"Name": v.Name,
})
}
return trailers
}
// countLocalExtras splits local extras into Jellyfin's LocalTrailerCount
// (trailer/teaser kinds, surfaced via /LocalTrailers) and SpecialFeatureCount
// (everything else, surfaced via /SpecialFeatures).
func countLocalExtras(extras []catalog.ItemExtraInfo) (localTrailers, specialFeatures int) {
for _, e := range extras {
if isLocalTrailerKind(e.Kind) {
localTrailers++
} else {
specialFeatures++
}
}
return localTrailers, specialFeatures
}
func isLocalTrailerKind(kind string) bool {
return kind == string(models.ExtraKindTrailer) || kind == string(models.ExtraKindTeaser)
}
func minutesToTicks(minutes int) int64 {
return int64(minutes) * 600_000_000
}
func secondsToTicks(seconds float64) int64 {
return int64(seconds * 10_000_000)
}
// runtimeTicks resolves RunTimeTicks probed-duration-first with the catalog
// runtime as fallback, matching the v1 API's contentDurationSeconds. Returns
// 0 when neither is known; RunTimeTicks is omitempty, so the field stays
// absent rather than being emitted as a bogus 0.
func runtimeTicks(durationSeconds, runtimeMinutes int) int64 {
if durationSeconds > 0 {
return secondsToTicks(float64(durationSeconds))
}
if runtimeMinutes > 0 {
return minutesToTicks(runtimeMinutes)
}
return 0
}
// clampResumeSeconds normalizes a stored resume position for client-facing
// user data. Completed rows store position 0, so fully watched items report 0
// (clients start fresh) while a rewatch in flight reports its live resume
// point alongside Played=true — matching real Jellyfin. The position is
// clamped to the item duration so a stale/overflowed stored position cannot
// drive the player past end-of-file (which stalls the HLS transcoder on
// resume).
func clampResumeSeconds(position, duration float64) float64 {
if duration > 0 && position > duration {
position = duration
}
if position < 0 {
position = 0
}
return position
}
func imageTagsWithSeed(signer *imageTagSigner, seed, imageURL string) map[string]string {
if imageURL == "" {
return nil
}
return map[string]string{"Primary": signer.Tag(seed, imageURL)}
}
func backdropTags(imageURL string) []string {
return backdropTagsWithSeed(nil, "", imageURL)
}
func backdropTagsWithSeed(signer *imageTagSigner, seed, imageURL string) []string {
if imageURL == "" {
return nil
}
return []string{signer.Tag(seed, imageURL)}
}
func listItemPrimaryImageSeedParts(item upstreamListItem) (string, string) {
if item.Type == "episode" && item.StillPath != "" {
return item.StillPath, item.StillThumbhash
}
return firstNonEmpty(item.PosterPath, item.StillPath), item.PosterThumbhash
}
func imageTagSeed(routeID, imageType, size, rawPath, thumbhash string, updatedAt time.Time) string {
rawPath = strings.TrimSpace(rawPath)
thumbhash = strings.TrimSpace(thumbhash)
if rawPath == "" && thumbhash == "" && updatedAt.IsZero() {
return ""
}
parts := []string{
strings.TrimSpace(routeID),
strings.ToLower(strings.TrimSpace(imageType)),
normalizeImageCacheSize(size),
rawPath,
thumbhash,
}
if !updatedAt.IsZero() {
parts = append(parts, updatedAt.UTC().Format(time.RFC3339Nano))
}
return strings.Join(parts, "\x00")
}
func tagValue(raw string) string {
if raw == "" {
return ""
}
sum := sha1.Sum([]byte(raw))
return hex.EncodeToString(sum[:8])
}
func itemEtag(item upstreamListItem) string {
return tagValue(strings.Join([]string{
item.ContentID,
item.Title,
strconv.Itoa(item.Year),
item.AirDate,
firstNonEmpty(item.PosterPath, item.PosterURL),
firstNonEmpty(item.BackdropPath, item.BackdropURL),
item.UpdatedAt.UTC().Format(time.RFC3339Nano),
}, ":"))
}
func derefString(value *string) string {
if value == nil {
return ""
}
return *value
}
func detailMediaSourceDTO(sourceID string, version catalog.FileVersion, streams []mediaStreamDTO) mediaSourceDTO {
return mediaSourceDTO{
Protocol: "File",
ID: sourceID,
Path: compatMediaPath(version),
Type: "Default",
Container: strings.ToLower(version.Container),
Size: version.FileSize,
Name: mediaSourceName(version),
IsRemote: false,
ETag: mediaSourceETag(version),
RunTimeTicks: secondsToTicks(float64(version.Duration)),
ReadAtNativeFramerate: false,
IgnoreDts: false,
IgnoreIndex: false,
GenPtsInput: false,
SupportsTranscoding: true,
SupportsDirectStream: true,
SupportsDirectPlay: true,
IsInfiniteStream: false,
UseMostCompatibleTranscodingProfile: false,
RequiresOpening: false,
RequiresClosing: false,
RequiresLooping: false,
SupportsProbing: true,
VideoType: "VideoFile",
HasSegments: false,
Formats: []string{strings.ToLower(version.Container)},
RequiredHTTPHeaders: map[string]string{},
MediaAttachments: []map[string]any{},
TranscodingSubProtocol: "hls",
Bitrate: version.Bitrate * 1000,
DefaultAudioStreamIndex: defaultAudioStreamIndex(version),
DefaultSubtitleStreamIndex: defaultSubtitleStreamIndex(version),
MediaStreams: streams,
}
}
func mediaSourceName(version catalog.FileVersion) string {
name := strings.TrimSpace(version.FileName)
if name == "" {
name = filepath.Base(version.FilePath)
}
if name == "" {
return ""
}
ext := filepath.Ext(name)
return strings.TrimSuffix(name, ext)
}
func compatMediaPath(version catalog.FileVersion) string {
if strings.TrimSpace(version.FilePath) != "" {
return version.FilePath
}
name := mediaSourceName(version)
if name == "" {
return ""
}
ext := strings.ToLower(strings.TrimSpace(version.Container))
if ext == "" {
return filepath.ToSlash(filepath.Join("/silo", name))
}
return filepath.ToSlash(filepath.Join("/silo", name+"."+ext))
}
func formatCompatTime(value time.Time) string {
if value.IsZero() {
return ""
}
return value.UTC().Format(time.RFC3339)
}
func syntheticPremiereDate(year int) string {
if year <= 0 {
return ""
}
return time.Date(year, time.January, 1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339)
}
func ensureRFC3339(value string) string {
if strings.Contains(value, "T") {
return value
}
t, err := time.Parse("2006-01-02", value)
if err != nil {
return value
}
return t.UTC().Format(time.RFC3339)
}
func nonNilStrings(s []string) []string {
if s == nil {
return []string{}
}
return s
}