* fix(jellycompat): report fileless episodes as LocationType=Virtual
Provider-metadata-only episodes (unaired/missing entries pulled from
TVDB/TMDB that have no underlying media file) were mapped with
LocationType=FileSystem and an empty MediaSources list. Jellyfin's
contract is that such items report LocationType=Virtual.
Because they were not marked Virtual, clients that build playback queues
from the episode list (Wholphin, Infuse, Findroid, ...) treated them as
playable, queued them, and failed on advance with "no media sources".
Wholphin specifically filters LocationType=Virtual out of its
auto-advance playlist, so marking these Virtual lets next-episode /
skip-outro jump cleanly to the next real episode, and the episode list
greys them out as expected.
itemFromDetailWithFields now stamps LocationType=Virtual on playable
items (movie/episode) that have zero file versions.
* fix(jellycompat): mark fileless episodes Virtual on list paths too
The Virtual fix only covered itemFromDetailWithFields, which clients reach
only when requesting detail-level Fields (MediaSources, MediaStreams, ...).
itemFromList and episodeFromUpstream still stamped LocationType=FileSystem
unconditionally, so the same fileless episode reported Virtual or FileSystem
depending on the endpoint/Fields combination used.
Centralize the decision in applyPlayableLocation (which also clears VideoType
on virtual items, matching Jellyfin) and plumb a HasMediaFiles signal into the
list paths:
- episode targets query gains an EXISTS check against media_files
- the pool-less fallback uses a new EpisodeRepository.HasFilesByIDs
- the /Shows/{id}/Episodes non-detail path reuses the already-fetched
episode targets, so no extra query is needed
A nil signal preserves the historical FileSystem default for producers that
do not check file presence (movies, series-level lists).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
881 lines
29 KiB
Go
881 lines
29 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"
|
|
)
|
|
|
|
// 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 item.Runtime > 0 {
|
|
dto.RunTimeTicks = minutesToTicks(item.Runtime)
|
|
}
|
|
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),
|
|
})
|
|
}
|
|
}
|
|
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)
|
|
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: minutesToTicks(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 {
|
|
dto.PlaybackPositionTicks = resumePositionTicks(data.PositionSeconds, data.DurationSeconds, data.Played)
|
|
if data.DurationSeconds > 0 {
|
|
dto.PlayedPercentage = (data.PositionSeconds / data.DurationSeconds) * 100
|
|
}
|
|
dto.Played = data.Played
|
|
dto.UnplayedItemCount = data.UnplayedCount
|
|
if data.Played {
|
|
dto.PlayCount = 1
|
|
}
|
|
}
|
|
|
|
if progress != nil {
|
|
dto.PlaybackPositionTicks = resumePositionTicks(progress.PositionSeconds, progress.DurationSeconds, progress.Completed)
|
|
if progress.DurationSeconds > 0 {
|
|
dto.PlayedPercentage = (progress.PositionSeconds / progress.DurationSeconds) * 100
|
|
}
|
|
dto.Played = progress.Completed
|
|
if progress.Completed {
|
|
dto.PlayCount = 1
|
|
}
|
|
dto.LastPlayedDate = progress.UpdatedAt
|
|
}
|
|
|
|
return dto
|
|
}
|
|
|
|
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"
|
|
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
|
|
}
|
|
|
|
func minutesToTicks(minutes int) int64 {
|
|
return int64(minutes) * 600_000_000
|
|
}
|
|
|
|
func secondsToTicks(seconds float64) int64 {
|
|
return int64(seconds * 10_000_000)
|
|
}
|
|
|
|
// resumePositionTicks returns the PlaybackPositionTicks value for a UserData
|
|
// DTO. When the item is fully watched the position is reported as 0 so clients
|
|
// start fresh on the next play; otherwise 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 resumePositionTicks(position, duration float64, played bool) int64 {
|
|
if played {
|
|
return 0
|
|
}
|
|
if duration > 0 && position > duration {
|
|
position = duration
|
|
}
|
|
if position < 0 {
|
|
position = 0
|
|
}
|
|
return secondsToTicks(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
|
|
}
|