{"subject":"fix(jellycompat): stabilize signed image tags across restarts","body":"- Sign library poster and episode parent series image tags from canonical paths/thumbhashes instead of presigned URLs so tags survive restarts\n- Accept signed canonical tags in the image handler without a session and fall back to legacy URL-derived cache tags\n- Always fetch series detail for episodes to build stable parent image tags"}
834 lines
27 KiB
Go
834 lines
27 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,
|
|
},
|
|
}
|
|
}
|
|
|
|
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" {
|
|
dto.LocationType = "FileSystem"
|
|
dto.VideoType = "VideoFile"
|
|
}
|
|
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 {
|
|
dto.ChildCount = *item.SeasonCount
|
|
dto.RecursiveItemCount = *item.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; standard Jellyfin clients refetch real MediaSources via
|
|
// /Items/{id}/PlaybackInfo on play, so this stub is never load-bearing.
|
|
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
|
|
// hydrator, which deliberately skips per-item GetItemDetail fanout; clients
|
|
// that need real MediaSources refetch them via /Items/{id}/PlaybackInfo on
|
|
// play.
|
|
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 {
|
|
dto.MediaSources = []mediaSourceDTO{stubDetailMediaSource}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
dto.LocationType = "FileSystem"
|
|
dto.VideoType = "VideoFile"
|
|
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)
|
|
}
|
|
}
|
|
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,
|
|
LocationType: "FileSystem",
|
|
VideoType: "VideoFile",
|
|
RunTimeTicks: minutesToTicks(ep.Runtime),
|
|
ImageTags: map[string]string{},
|
|
SeriesName: ep.SeriesTitle,
|
|
UserData: userDataDTO(m.codec.EncodeStringID(EncodedIDItem, ep.ContentID), ep.UserData, isFavorite, progress),
|
|
}
|
|
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
|
|
}
|