* 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>
968 lines
32 KiB
Go
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
|
|
}
|