Files
silo-server/internal/jellycompat/mapping.go
T
Quick 578100f5ef fix(jellycompat): stabilize signed image tags across restarts
{"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"}
2026-05-26 22:29:49 -04:00

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
}