Files
silo-server/internal/catalog/detail.go
T
Silo Server Migration 04a7e8c82d Add online marker support for recap and preview segments
- Wire introdb marker fetching into playback and Jellyfin compat
- Persist and expose recap/preview markers alongside intro and credits
- Add new playback/profile settings for recap and preview behavior
2026-05-23 23:48:25 -04:00

2215 lines
74 KiB
Go

package catalog
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/Silo-Server/silo-server/internal/access"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/overlays"
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// FileVersionFetcher retrieves media files linked to a content ID.
type FileVersionFetcher interface {
GetByContentID(ctx context.Context, contentID string) ([]*models.MediaFile, error)
GetByEpisodeID(ctx context.Context, episodeID string) ([]*models.MediaFile, error)
}
type PlaybackProbeEnsurer interface {
Ensure(ctx context.Context, file *models.MediaFile) (*models.MediaFile, error)
}
type ChapterThumbnailQueuer interface {
QueueFileIDs(ctx context.Context, fileIDs []int)
QueuePriorityFileIDs(ctx context.Context, fileIDs []int)
QueuePriorityFileAtPosition(ctx context.Context, fileID int, targetSeconds float64)
}
// ImageResolver resolves image paths (potentially plugin-prefixed) to usable URLs.
type ImageResolver interface {
// ResolveImageURL resolves a single image path. Plugin-prefixed paths (e.g.,
// "metadb://images/abc/original.jpg") are resolved via the owning plugin's RPC.
// HTTP(S) URLs pass through unchanged. Empty paths return "".
// The variant parameter is a semantic size hint: "card", "featured", "full", "original".
ResolveImageURL(ctx context.Context, path string, variant string) string
// ResolveImageURLs resolves multiple image paths in a single call. Returns a
// map from input path to resolved URL. The variant parameter applies to all paths.
ResolveImageURLs(ctx context.Context, paths []string, variant string) map[string]string
}
// ResolvedImageURL carries a resolved URL with optional validity metadata.
// A nil ExpiresAt means the URL has no known expiry and must not be stored in
// presign/plugin resolver caches that assume bounded validity.
type ResolvedImageURL struct {
URL string
ExpiresAt *time.Time
}
type expiringImageResolver interface {
ResolveImageURLWithExpiry(ctx context.Context, path string, variant string) ResolvedImageURL
ResolveImageURLsWithExpiry(ctx context.Context, paths []string, variant string) map[string]ResolvedImageURL
}
// ItemDetail is the full detail response for a single media item, including
// metadata, file versions, subtitles, intro/credits markers, and presigned image URLs.
type ItemDetail struct {
ContentID string `json:"content_id"`
Type string `json:"type"`
// Metadata (served inline from Postgres).
Title string `json:"title"`
SortTitle string `json:"sort_title,omitempty"`
OriginalTitle string `json:"original_title,omitempty"`
Year int `json:"year,omitempty"`
Overview string `json:"overview,omitempty"`
Tagline string `json:"tagline,omitempty"`
Runtime int `json:"runtime,omitempty"`
ContentRating string `json:"content_rating,omitempty"`
Genres []string `json:"genres"`
RatingIMDB *float64 `json:"rating_imdb,omitempty"`
RatingTMDB *float64 `json:"rating_tmdb,omitempty"`
RatingRTCritic *int `json:"rating_rt_critic,omitempty"`
RatingRTAudience *int `json:"rating_rt_audience,omitempty"`
ImdbID string `json:"imdb_id,omitempty"`
TmdbID string `json:"tmdb_id,omitempty"`
TvdbID string `json:"tvdb_id,omitempty"`
Cast []CastCredit `json:"cast"`
Crew []CrewCredit `json:"crew"`
Studios []string `json:"studios"`
Networks []string `json:"networks"`
Countries []string `json:"countries,omitempty"`
LockedFields []int `json:"locked_fields,omitempty"`
FirstAirDate *string `json:"first_air_date,omitempty"`
LastAirDate *string `json:"last_air_date,omitempty"`
ReleaseDate *string `json:"release_date,omitempty"`
AirTime *string `json:"air_time,omitempty"`
ShowStatus string `json:"show_status,omitempty"`
// Presigned image URLs.
PosterURL string `json:"poster_url,omitempty"`
PosterThumbhash string `json:"poster_thumbhash,omitempty"`
BackdropURL string `json:"backdrop_url,omitempty"`
BackdropThumbhash string `json:"backdrop_thumbhash,omitempty"`
LogoURL string `json:"logo_url,omitempty"`
// Series-specific.
SeasonCount *int `json:"season_count,omitempty"`
// Season-specific.
SeriesID string `json:"series_id,omitempty"`
SeriesTitle string `json:"series_title,omitempty"`
SeasonNumber *int `json:"season_number,omitempty"`
EpisodeNumber *int `json:"episode_number,omitempty"`
EpisodeCount *int `json:"episode_count,omitempty"`
AirDate *string `json:"air_date,omitempty"`
IsSpecials bool `json:"is_specials,omitempty"`
SeasonUserData *SeasonUserData `json:"user_data,omitempty"`
UserState *ItemUserState `json:"user_state,omitempty"`
UserRating *int `json:"user_rating,omitempty"`
// File versions available for playback.
Versions []FileVersion `json:"versions"`
PlaybackVariants []PlaybackVariant `json:"playback_variants,omitempty"`
// Root folder paths for series items (admin-only).
FolderPaths []string `json:"folder_paths,omitempty"`
// Compact overlay badges derived from the best available file.
OverlaySummary *models.OverlaySummary `json:"overlay_summary,omitempty"`
// Aggregated subtitles across all versions.
Subtitles []SubtitleInfo `json:"subtitles"`
// Intro/credits/recap/preview markers (from first file that has them).
Intro *Marker `json:"intro,omitempty"`
Credits *Marker `json:"credits,omitempty"`
Recap *Marker `json:"recap,omitempty"`
Preview *Marker `json:"preview,omitempty"`
// Effective subtitle defaults for episode playback derived from
// profile, library, and series-level preferences.
EffectiveSubtitleLanguage string `json:"-"`
HasEffectiveSubtitleLang bool `json:"-"`
EffectiveSubtitleMode string `json:"-"`
HasEffectiveSubtitleMode bool `json:"-"`
EffectiveShowForcedSubtitles bool `json:"-"`
HasEffectiveShowForcedSubtitles bool `json:"-"`
EffectiveSubtitleTrackSignature *userstore.SubtitleTrackSignature `json:"effective_subtitle_track_signature,omitempty"`
// Effective version defaults for episode playback derived from
// series-level sticky preferences.
EffectiveVersionResolution *string `json:"effective_version_resolution,omitempty"`
EffectiveVersionHDR *bool `json:"effective_version_hdr,omitempty"`
EffectiveVersionCodecVideo *string `json:"effective_version_codec_video,omitempty"`
EffectiveVersionEditionKey *string `json:"effective_version_edition_key,omitempty"`
}
// ItemUserState is per-profile viewer state included in item detail responses.
type ItemUserState struct {
Played bool `json:"played"`
IsFavorite bool `json:"is_favorite"`
InWatchlist bool `json:"in_watchlist"`
}
// CastCredit is the item-detail API shape for a cast member.
type CastCredit struct {
Name string `json:"name"`
Character string `json:"character"`
Order int `json:"order"`
PersonID string `json:"person_id,omitempty"`
TmdbID string `json:"tmdb_id,omitempty"`
TvdbID string `json:"tvdb_id,omitempty"`
ImdbID string `json:"imdb_id,omitempty"`
PlexGUID string `json:"plex_guid,omitempty"`
PhotoURL string `json:"photo_url,omitempty"`
PhotoThumbhash string `json:"photo_thumbhash,omitempty"`
}
// CrewCredit is the item-detail API shape for a crew member.
type CrewCredit struct {
Name string `json:"name"`
Job string `json:"job"`
PersonID string `json:"person_id,omitempty"`
TmdbID string `json:"tmdb_id,omitempty"`
TvdbID string `json:"tvdb_id,omitempty"`
ImdbID string `json:"imdb_id,omitempty"`
PlexGUID string `json:"plex_guid,omitempty"`
PhotoURL string `json:"photo_url,omitempty"`
PhotoThumbhash string `json:"photo_thumbhash,omitempty"`
}
// PersonCredit represents a person's credit on a media item for API responses.
type PersonCredit struct {
PersonID int64 `json:"person_id"`
Name string `json:"name"`
Kind models.PersonKind `json:"kind"`
Character string `json:"character,omitempty"`
SortOrder int `json:"order"`
TmdbID string `json:"tmdb_id,omitempty"`
ImdbID string `json:"imdb_id,omitempty"`
TvdbID string `json:"tvdb_id,omitempty"`
PlexGUID string `json:"plex_guid,omitempty"`
PhotoURL string `json:"photo_url,omitempty"`
PhotoThumbhash string `json:"photo_thumbhash,omitempty"`
}
// FileVersion represents a single file version available for playback.
type FileVersion struct {
FileID int `json:"file_id"`
FileName string `json:"file_name,omitempty"`
FilePath string `json:"file_path,omitempty"`
Resolution string `json:"resolution"`
CodecVideo string `json:"codec_video"`
CodecAudio string `json:"codec_audio"`
HDR bool `json:"hdr"`
Container string `json:"container"`
FileSize int64 `json:"file_size"`
Duration int `json:"duration"`
Bitrate int `json:"bitrate"`
AddedAt time.Time `json:"added_at"`
EditionRaw string `json:"edition_raw,omitempty"`
EditionKey string `json:"edition_key,omitempty"`
PresentationKind string `json:"presentation_kind,omitempty"`
PresentationGroupKey string `json:"presentation_group_key,omitempty"`
PresentationPartIndex int `json:"presentation_part_index,omitempty"`
MultiEpisodeStart int `json:"multi_episode_start,omitempty"`
MultiEpisodeEnd int `json:"multi_episode_end,omitempty"`
EffectiveAudioTrackIndex *int `json:"effective_audio_track_index,omitempty"`
EffectiveAudioLanguage string `json:"effective_audio_language,omitempty"`
VideoTracks []models.VideoTrack `json:"video_tracks,omitempty"`
AudioTracks []models.AudioTrack `json:"audio_tracks,omitempty"`
SubtitleTracks []VersionSubtitleTrack `json:"subtitle_tracks,omitempty"`
Chapters []VersionChapter `json:"chapters,omitempty"`
Intro *Marker `json:"intro,omitempty"`
Credits *Marker `json:"credits,omitempty"`
Recap *Marker `json:"recap,omitempty"`
Preview *Marker `json:"preview,omitempty"`
}
// PlaybackVariant is one logical watch choice, optionally spanning multiple ordered parts.
type PlaybackVariant struct {
VariantID string `json:"variant_id"`
EditionRaw string `json:"edition_raw,omitempty"`
EditionKey string `json:"edition_key,omitempty"`
PresentationKind string `json:"presentation_kind,omitempty"`
PresentationGroupKey string `json:"presentation_group_key,omitempty"`
PartCount int `json:"part_count"`
TotalDuration int `json:"total_duration,omitempty"`
DefaultFileID int `json:"default_file_id,omitempty"`
Parts []PlaybackVariantPart `json:"parts"`
}
// PlaybackVariantPart contains the interchangeable versions for one ordered part.
type PlaybackVariantPart struct {
PartIndex int `json:"part_index"`
DefaultFileID int `json:"default_file_id,omitempty"`
TotalDuration int `json:"total_duration,omitempty"`
Versions []FileVersion `json:"versions"`
}
// VersionChapter represents one chapter on a playable file version.
type VersionChapter struct {
Index int `json:"index"`
Title string `json:"title"`
StartSeconds float64 `json:"start_seconds"`
EndSeconds float64 `json:"end_seconds"`
Source string `json:"source"`
ThumbnailURL string `json:"thumbnail_url,omitempty"`
ThumbnailThumbhash string `json:"thumbnail_thumbhash,omitempty"`
}
// VersionSubtitleTrack represents one embedded or external subtitle track in a file version.
type VersionSubtitleTrack struct {
Index int `json:"index,omitempty"`
Language string `json:"language,omitempty"`
Codec string `json:"codec,omitempty"`
Title string `json:"title,omitempty"`
EmbeddedTitle string `json:"embedded_title,omitempty"`
Resolution string `json:"resolution,omitempty"`
Forced bool `json:"forced"`
Default bool `json:"default"`
HearingImpaired bool `json:"hearing_impaired"`
External bool `json:"external"`
FileName string `json:"file_name,omitempty"`
}
// SubtitleInfo represents a subtitle track available for a media item.
type SubtitleInfo struct {
Source string `json:"source"` // embedded, external
Language string `json:"language"`
Codec string `json:"codec,omitempty"`
Forced bool `json:"forced"`
HearingImpaired bool `json:"hearing_impaired"`
Title string `json:"title,omitempty"`
}
// Marker represents a time range (intro or credits).
type Marker struct {
Start float64 `json:"start"`
End float64 `json:"end"`
}
// SeasonUserData is the profile-scoped progress payload shared across item,
// season, series, and episode surfaces. Aggregate fields are used for season
// and series responses; leaf fields are used for movies and episodes.
type SeasonUserData struct {
PositionSeconds float64 `json:"position_seconds,omitempty"`
DurationSeconds float64 `json:"duration_seconds,omitempty"`
IsInProgress bool `json:"is_in_progress,omitempty"`
WatchedCount int `json:"watched_count"`
UnplayedCount int `json:"unplayed_count"`
InProgressCount int `json:"in_progress_count"`
Played bool `json:"played"`
LastFileID *int `json:"last_file_id,omitempty"`
LastResolution *string `json:"last_resolution,omitempty"`
LastHDR *bool `json:"last_hdr,omitempty"`
LastCodecVideo *string `json:"last_codec_video,omitempty"`
LastEditionKey *string `json:"last_edition_key,omitempty"`
}
// WatchDetail is the playback-oriented payload for /watch/{id}.
type WatchDetail struct {
ContentID string `json:"content_id"`
Type string `json:"type"`
Title string `json:"title"`
Year int `json:"year,omitempty"`
Overview string `json:"overview,omitempty"`
EffectiveSubtitleLanguage string `json:"-"`
HasEffectiveSubtitleLang bool `json:"-"`
EffectiveSubtitleMode string `json:"-"`
HasEffectiveSubtitleMode bool `json:"-"`
EffectiveShowForcedSubtitles bool `json:"-"`
HasEffectiveShowForcedSubtitles bool `json:"-"`
Versions []FileVersion `json:"versions"`
PlaybackVariants []PlaybackVariant `json:"playback_variants,omitempty"`
Subtitles []SubtitleInfo `json:"subtitles"`
Intro *Marker `json:"intro,omitempty"`
Credits *Marker `json:"credits,omitempty"`
Recap *Marker `json:"recap,omitempty"`
Preview *Marker `json:"preview,omitempty"`
UserData *SeasonUserData `json:"user_data,omitempty"`
SeriesID string `json:"series_id,omitempty"`
SeriesTitle string `json:"series_title,omitempty"`
SeasonNumber int `json:"season_number,omitempty"`
EpisodeNumber int `json:"episode_number,omitempty"`
EffectiveSubtitleTrackSignature *userstore.SubtitleTrackSignature `json:"effective_subtitle_track_signature,omitempty"`
EffectiveVersionResolution *string `json:"effective_version_resolution,omitempty"`
EffectiveVersionHDR *bool `json:"effective_version_hdr,omitempty"`
EffectiveVersionCodecVideo *string `json:"effective_version_codec_video,omitempty"`
EffectiveVersionEditionKey *string `json:"effective_version_edition_key,omitempty"`
}
func (d ItemDetail) MarshalJSON() ([]byte, error) {
type itemDetailAlias ItemDetail
payload := struct {
itemDetailAlias
EffectiveSubtitleLanguage *string `json:"effective_subtitle_language,omitempty"`
EffectiveSubtitleMode *string `json:"effective_subtitle_mode,omitempty"`
EffectiveShowForcedSubtitles *bool `json:"effective_show_forced_subtitles,omitempty"`
}{
itemDetailAlias: itemDetailAlias(d),
}
if d.HasEffectiveSubtitleLang {
payload.EffectiveSubtitleLanguage = &d.EffectiveSubtitleLanguage
}
if d.HasEffectiveSubtitleMode {
payload.EffectiveSubtitleMode = &d.EffectiveSubtitleMode
}
if d.HasEffectiveShowForcedSubtitles {
payload.EffectiveShowForcedSubtitles = &d.EffectiveShowForcedSubtitles
}
return json.Marshal(payload)
}
func (d WatchDetail) MarshalJSON() ([]byte, error) {
type watchDetailAlias WatchDetail
payload := struct {
watchDetailAlias
EffectiveSubtitleLanguage *string `json:"effective_subtitle_language,omitempty"`
EffectiveSubtitleMode *string `json:"effective_subtitle_mode,omitempty"`
EffectiveShowForcedSubtitles *bool `json:"effective_show_forced_subtitles,omitempty"`
}{
watchDetailAlias: watchDetailAlias(d),
}
if d.HasEffectiveSubtitleLang {
payload.EffectiveSubtitleLanguage = &d.EffectiveSubtitleLanguage
}
if d.HasEffectiveSubtitleMode {
payload.EffectiveSubtitleMode = &d.EffectiveSubtitleMode
}
if d.HasEffectiveShowForcedSubtitles {
payload.EffectiveShowForcedSubtitles = &d.EffectiveShowForcedSubtitles
}
return json.Marshal(payload)
}
type subtitleDefaults struct {
Language string
HasLanguage bool
Mode string
HasMode bool
ShowForced bool
HasShowForced bool
TrackSignature *userstore.SubtitleTrackSignature
}
type versionDefaults struct {
Resolution string
HDR bool
CodecVideo string
HasAny bool
}
var ErrWatchTargetNotPlayable = errors.New("watch target is not directly playable")
// IsWatchTargetNotPlayable reports whether the error means the client sent a
// valid content ID that is not directly playable.
func IsWatchTargetNotPlayable(err error) bool {
return errors.Is(err, ErrWatchTargetNotPlayable)
}
// DetailService builds full item detail responses with presigned URLs.
type DetailService struct {
itemRepo *ItemRepository
episodeRepo *EpisodeRepository
seasonRepo *SeasonRepository
personRepo *PersonRepository
itemLocRepo *MediaItemLocalizationRepository
seasonLocRepo *SeasonLocalizationRepository
episodeLocRepo *EpisodeLocalizationRepository
folderRepo interface {
GetByID(ctx context.Context, id int) (*models.MediaFolder, error)
}
fileFetcher FileVersionFetcher
rootClaimRepo *RootClaimRepository
groupClaimRepo *GroupClaimRepository
imageResolver ImageResolver
userStoreProvider userstore.UserStoreProvider
originalLangFn func(context.Context, string) string
probeEnsurer PlaybackProbeEnsurer
chapterThumbs ChapterThumbnailQueuer
}
// NewDetailService creates a new DetailService.
func NewDetailService(
itemRepo *ItemRepository,
episodeRepo *EpisodeRepository,
seasonRepo *SeasonRepository,
personRepo *PersonRepository,
fileFetcher FileVersionFetcher,
) *DetailService {
return &DetailService{
itemRepo: itemRepo,
episodeRepo: episodeRepo,
seasonRepo: seasonRepo,
personRepo: personRepo,
itemLocRepo: NewMediaItemLocalizationRepository(itemRepo.pool),
seasonLocRepo: NewSeasonLocalizationRepository(itemRepo.pool),
episodeLocRepo: NewEpisodeLocalizationRepository(itemRepo.pool),
fileFetcher: fileFetcher,
}
}
// SetImageResolver sets the plugin-based image resolver for resolving
// plugin-prefixed image paths (e.g., "metadb://images/abc/original.jpg").
func (s *DetailService) SetImageResolver(resolver ImageResolver) {
s.imageResolver = resolver
}
// SetUserStoreProvider wires in optional per-user preference lookups.
func (s *DetailService) SetUserStoreProvider(provider userstore.UserStoreProvider) {
s.userStoreProvider = provider
}
func (s *DetailService) SetProbeEnsurer(ensurer PlaybackProbeEnsurer) {
s.probeEnsurer = ensurer
}
func (s *DetailService) SetChapterThumbnailQueuer(queuer ChapterThumbnailQueuer) {
s.chapterThumbs = queuer
}
func (s *DetailService) SetFolderRepository(repo interface {
GetByID(ctx context.Context, id int) (*models.MediaFolder, error)
}) {
s.folderRepo = repo
}
// SetRootClaimRepository wires in the root claim repo for series folder path lookups.
func (s *DetailService) SetRootClaimRepository(repo *RootClaimRepository) {
s.rootClaimRepo = repo
}
func (s *DetailService) SetGroupClaimRepository(repo *GroupClaimRepository) {
s.groupClaimRepo = repo
}
func cloneMediaItem(item *models.MediaItem) *models.MediaItem {
if item == nil {
return nil
}
cp := *item
return &cp
}
func cloneSeason(season *models.Season) *models.Season {
if season == nil {
return nil
}
cp := *season
return &cp
}
func cloneEpisode(ep *models.Episode) *models.Episode {
if ep == nil {
return nil
}
cp := *ep
return &cp
}
func (s *DetailService) resolvePresentationLanguage(ctx context.Context, filter AccessFilter) (string, error) {
if strings.TrimSpace(filter.PresentationLanguage) != "" {
return strings.TrimSpace(filter.PresentationLanguage), nil
}
if filter.PresentationLibraryID == nil || s.folderRepo == nil {
return "", nil
}
folder, err := s.folderRepo.GetByID(ctx, *filter.PresentationLibraryID)
if err != nil {
if errors.Is(err, ErrFolderNotFound) {
return "", ErrItemNotFound
}
return "", err
}
return strings.TrimSpace(folder.MetadataLanguage), nil
}
func (s *DetailService) validatePresentationItemAccess(ctx context.Context, filter AccessFilter, contentID string) error {
if filter.PresentationLibraryID == nil {
return nil
}
membership, err := s.itemRepo.GetItemsInLibrary(ctx, []string{contentID}, *filter.PresentationLibraryID)
if err != nil {
return err
}
if !membership[contentID] {
return ErrItemNotFound
}
return nil
}
func sameMetadataLanguage(a, b string) bool {
return strings.EqualFold(strings.TrimSpace(a), strings.TrimSpace(b))
}
func (s *DetailService) LocalizeItemModel(ctx context.Context, item *models.MediaItem, filter AccessFilter) (*models.MediaItem, error) {
if item == nil {
return nil, nil
}
language, err := s.resolvePresentationLanguage(ctx, filter)
if err != nil || language == "" || sameMetadataLanguage(item.DefaultMetadataLanguage, language) || s.itemLocRepo == nil {
return cloneMediaItem(item), err
}
loc, err := s.itemLocRepo.Get(ctx, item.ContentID, language)
if err != nil || loc == nil {
return cloneMediaItem(item), err
}
localized := cloneMediaItem(item)
localized.Title = loc.Title
localized.SortTitle = loc.SortTitle
localized.Overview = loc.Overview
localized.Tagline = loc.Tagline
localized.PosterPath = loc.PosterPath
localized.PosterThumbhash = loc.PosterThumbhash
localized.BackdropPath = loc.BackdropPath
localized.BackdropThumbhash = loc.BackdropThumbhash
localized.LogoPath = loc.LogoPath
return localized, nil
}
func (s *DetailService) LocalizeSeasonModel(ctx context.Context, season *models.Season, filter AccessFilter) (*models.Season, error) {
if season == nil {
return nil, nil
}
language, err := s.resolvePresentationLanguage(ctx, filter)
if err != nil || language == "" || sameMetadataLanguage(season.DefaultMetadataLanguage, language) || s.seasonLocRepo == nil {
return cloneSeason(season), err
}
loc, err := s.seasonLocRepo.Get(ctx, season.ContentID, language)
if err != nil || loc == nil {
return cloneSeason(season), err
}
localized := cloneSeason(season)
localized.Title = loc.Title
localized.Overview = loc.Overview
localized.PosterPath = loc.PosterPath
localized.PosterThumbhash = loc.PosterThumbhash
return localized, nil
}
func (s *DetailService) LocalizeEpisodeModel(ctx context.Context, episode *models.Episode, filter AccessFilter) (*models.Episode, error) {
if episode == nil {
return nil, nil
}
language, err := s.resolvePresentationLanguage(ctx, filter)
if err != nil || language == "" || sameMetadataLanguage(episode.DefaultMetadataLanguage, language) || s.episodeLocRepo == nil {
return cloneEpisode(episode), err
}
loc, err := s.episodeLocRepo.Get(ctx, episode.ContentID, language)
if err != nil || loc == nil {
return cloneEpisode(episode), err
}
localized := cloneEpisode(episode)
localized.Title = loc.Title
localized.Overview = loc.Overview
return localized, nil
}
// GetItemDetail retrieves a full item detail with presigned URLs and file versions.
func (s *DetailService) GetItemDetail(ctx context.Context, contentID string, filter AccessFilter) (*ItemDetail, error) {
item, err := s.itemRepo.GetByID(ctx, contentID)
switch {
case err == nil:
if err := s.itemRepo.EnsureAccessible(ctx, contentID, filter); err != nil {
return nil, err
}
if err := s.validatePresentationItemAccess(ctx, filter, contentID); err != nil {
return nil, err
}
return s.buildMediaItemDetail(ctx, item, contentID, filter)
case !errors.Is(err, ErrItemNotFound):
return nil, err
}
if s.seasonRepo == nil {
return nil, ErrItemNotFound
}
season, err := s.seasonRepo.GetByID(ctx, contentID)
if err == nil {
if err := s.itemRepo.EnsureAccessible(ctx, season.SeriesID, filter); err != nil {
return nil, err
}
if err := s.validatePresentationItemAccess(ctx, filter, season.SeriesID); err != nil {
return nil, err
}
return s.buildSeasonDetail(ctx, season, filter)
} else if !errors.Is(err, ErrSeasonNotFound) {
return nil, err
}
if s.episodeRepo == nil {
return nil, ErrItemNotFound
}
episode, err := s.episodeRepo.GetByID(ctx, contentID)
if err != nil {
if errors.Is(err, ErrEpisodeNotFound) {
return nil, ErrItemNotFound
}
return nil, err
}
if err := s.itemRepo.EnsureAccessible(ctx, episode.SeriesID, filter); err != nil {
return nil, err
}
if err := s.validatePresentationItemAccess(ctx, filter, episode.ContentID); err != nil {
return nil, err
}
seriesCtx, err := s.buildSeriesDetailContext(ctx, episode.SeriesID, filter)
if err != nil {
return nil, err
}
return s.buildEpisodeDetail(ctx, episode, seriesCtx, filter)
}
// seriesDetailContext caches series-level lookups so a batched episode-detail
// call doesn't redo them per episode.
type seriesDetailContext struct {
series *models.MediaItem
castCredits []CastCredit
crewCredits []CrewCredit
versionPref versionDefaults
backdropURL string
}
// buildSeriesDetailContext loads the parent series row, localizes it, fetches
// its credits, computes the user's series-level version preference, and
// presigns the backdrop URL — the work that buildEpisodeDetail used to do
// inline on every call. Hoisted into a helper so the batch path
// (GetEpisodeDetailsForSeries) can reuse one context across many episodes.
func (s *DetailService) buildSeriesDetailContext(ctx context.Context, seriesID string, filter AccessFilter) (*seriesDetailContext, error) {
series, err := s.itemRepo.GetByID(ctx, seriesID)
if err != nil {
return nil, fmt.Errorf("loading parent series: %w", err)
}
series, err = s.LocalizeItemModel(ctx, series, filter)
if err != nil {
return nil, fmt.Errorf("localizing episode series detail: %w", err)
}
castCredits, crewCredits := s.fetchCredits(ctx, seriesID)
return &seriesDetailContext{
series: series,
castCredits: castCredits,
crewCredits: crewCredits,
versionPref: s.effectiveVersionDefaults(ctx, filter, seriesID),
backdropURL: s.PresignImageURL(ctx, series.BackdropPath, "backdrop", ""),
}, nil
}
// GetEpisodeDetailsForSeries returns ItemDetails for the requested episodes,
// hoisting series-level lookups so they run once per series instead of per
// episode. Used by the jellycompat /Shows/{id}/Episodes endpoint when clients
// request detail-level fields (Fields=MediaSources,MediaStreams,Chapters,
// People). Episodes that fail per-episode access checks are skipped silently.
func (s *DetailService) GetEpisodeDetailsForSeries(
ctx context.Context,
seriesID string,
episodeContentIDs []string,
filter AccessFilter,
) (map[string]*ItemDetail, error) {
result := make(map[string]*ItemDetail, len(episodeContentIDs))
if len(episodeContentIDs) == 0 || s.episodeRepo == nil {
return result, nil
}
if err := s.itemRepo.EnsureAccessible(ctx, seriesID, filter); err != nil {
return nil, err
}
seriesCtx, err := s.buildSeriesDetailContext(ctx, seriesID, filter)
if err != nil {
return nil, err
}
for _, contentID := range episodeContentIDs {
episode, err := s.episodeRepo.GetByID(ctx, contentID)
if err != nil {
if errors.Is(err, ErrEpisodeNotFound) {
continue
}
return nil, err
}
if err := s.validatePresentationItemAccess(ctx, filter, contentID); err != nil {
if errors.Is(err, ErrItemNotFound) {
continue
}
return nil, err
}
detail, err := s.buildEpisodeDetail(ctx, episode, seriesCtx, filter)
if err != nil {
// Skip this episode rather than failing the whole batch — the
// caller falls back to list-mapping for any contentID missing
// from the result map, matching the prior per-episode loop's
// behaviour where one bad detail didn't break the series page.
continue
}
result[contentID] = detail
}
return result, nil
}
// fetchCredits returns cast and crew credits for the given content ID.
func (s *DetailService) fetchCredits(ctx context.Context, contentID string) ([]CastCredit, []CrewCredit) {
if s.personRepo == nil {
return []CastCredit{}, []CrewCredit{}
}
people, err := s.personRepo.ListForItem(ctx, contentID)
if err != nil {
people = nil
}
credits := s.personCredits(ctx, people)
return splitCastCrew(credits)
}
func (s *DetailService) buildMediaItemDetail(ctx context.Context, item *models.MediaItem, contentID string, filter AccessFilter) (*ItemDetail, error) {
localizedItem, err := s.LocalizeItemModel(ctx, item, filter)
if err != nil {
return nil, fmt.Errorf("localizing item detail: %w", err)
}
item = localizedItem
castCredits, crewCredits := s.fetchCredits(ctx, contentID)
detail := &ItemDetail{
ContentID: item.ContentID,
Type: item.Type,
Title: item.Title,
SortTitle: item.SortTitle,
OriginalTitle: item.OriginalTitle,
Year: item.Year,
Overview: item.Overview,
Tagline: item.Tagline,
Runtime: item.Runtime,
ContentRating: item.ContentRating,
Genres: item.Genres,
RatingIMDB: item.RatingIMDB,
RatingTMDB: item.RatingTMDB,
RatingRTCritic: item.RatingRTCritic,
RatingRTAudience: item.RatingRTAudience,
ImdbID: item.ImdbID,
TmdbID: item.TmdbID,
TvdbID: item.TvdbID,
Cast: castCredits,
Crew: crewCredits,
Studios: item.Studios,
Networks: item.Networks,
Countries: item.Countries,
LockedFields: item.LockedFields,
FirstAirDate: item.FirstAirDate,
LastAirDate: item.LastAirDate,
ReleaseDate: item.ReleaseDate,
AirTime: item.AirTime,
ShowStatus: item.ShowStatus,
PosterThumbhash: item.PosterThumbhash,
BackdropThumbhash: item.BackdropThumbhash,
SeasonCount: item.SeasonCount,
Versions: []FileVersion{},
PlaybackVariants: []PlaybackVariant{},
Subtitles: []SubtitleInfo{},
}
// Resolve image URLs: full URLs (TVDB/TMDB) pass through; S3 cached base paths get
// variant-resolved and presigned.
detail.PosterURL = s.PresignImageURL(ctx, item.PosterPath, "poster", "")
detail.BackdropURL = s.PresignImageURL(ctx, item.BackdropPath, "backdrop", "")
detail.LogoURL = s.PresignImageURL(ctx, item.LogoPath, "logo", "")
// File versions and subtitle aggregation only apply to movies.
// For series, each episode file shares the series content_id, so
// GetByContentID would return every episode — not alternate encodings.
if item.Type != "series" {
files, err := s.fileFetcher.GetByContentID(ctx, contentID)
if err != nil {
return nil, fmt.Errorf("fetching file versions: %w", err)
}
files = FilterMediaFilesByAccess(files, filter)
files = s.preparePlaybackFiles(ctx, files)
detail.Versions, detail.PlaybackVariants, detail.Subtitles, detail.Intro, detail.Credits, detail.Recap, detail.Preview = s.buildPlaybackInfo(
ctx,
files,
filter,
item.ContentID,
)
detail.OverlaySummary = overlays.BuildSummary(files)
}
// Series folder paths from confirmed claims when available, otherwise from
// the file links that currently belong to the item. This keeps provisional
// root-scoped series visible for manual match/admin flows without implying
// confirmed cross-root ownership.
if item.Type == "series" {
if item.Status == "matched" {
if s.groupClaimRepo != nil {
paths, err := s.groupClaimRepo.ListObservedRootsByContentID(ctx, contentID)
if err != nil {
slog.WarnContext(ctx, "failed to fetch series group locations", "content_id", contentID, "error", err)
} else if len(paths) > 0 {
detail.FolderPaths = paths
}
}
if len(detail.FolderPaths) == 0 && s.rootClaimRepo != nil {
roots, err := s.rootClaimRepo.ListByContentID(ctx, contentID)
if err != nil {
slog.WarnContext(ctx, "failed to fetch series root claims", "content_id", contentID, "error", err)
} else if len(roots) > 0 {
paths := make([]string, len(roots))
for i, root := range roots {
paths[i] = root.CanonicalRootPath
}
detail.FolderPaths = paths
}
}
}
if len(detail.FolderPaths) == 0 && s.fileFetcher != nil {
files, err := s.fileFetcher.GetByContentID(ctx, contentID)
if err != nil {
slog.WarnContext(ctx, "failed to fetch series files for folder paths", "content_id", contentID, "error", err)
} else {
detail.FolderPaths = seriesFolderPathsFromFiles(files)
}
}
}
return detail, nil
}
// personCredits converts ItemPerson slice to PersonCredit slice with presigned URLs.
func (s *DetailService) personCredits(ctx context.Context, people []models.ItemPerson) []PersonCredit {
credits := make([]PersonCredit, 0, len(people))
for _, p := range people {
pc := PersonCredit{
PersonID: p.ID,
Name: p.Name,
Kind: p.Kind,
Character: p.Character,
SortOrder: p.SortOrder,
TmdbID: p.TmdbID,
ImdbID: p.ImdbID,
TvdbID: p.TvdbID,
PlexGUID: p.PlexGUID,
}
if p.PhotoPath != "" && p.PhotoPath != "-" {
pc.PhotoURL = s.PresignURL(ctx, p.PhotoPath, "featured")
}
if p.PhotoThumbhash != "" && p.PhotoThumbhash != "-" {
pc.PhotoThumbhash = p.PhotoThumbhash
}
credits = append(credits, pc)
}
return credits
}
// splitCastCrew splits PersonCredits into CastCredit and CrewCredit slices
// for backward-compatible API responses.
func splitCastCrew(credits []PersonCredit) ([]CastCredit, []CrewCredit) {
var cast []CastCredit
var crew []CrewCredit
for _, pc := range credits {
switch pc.Kind {
case models.PersonKindActor, models.PersonKindGuestStar:
cast = append(cast, CastCredit{
Name: pc.Name,
Character: pc.Character,
Order: pc.SortOrder,
PersonID: strconv.FormatInt(pc.PersonID, 10),
TmdbID: pc.TmdbID,
ImdbID: pc.ImdbID,
TvdbID: pc.TvdbID,
PlexGUID: pc.PlexGUID,
PhotoURL: pc.PhotoURL,
PhotoThumbhash: pc.PhotoThumbhash,
})
default:
crew = append(crew, CrewCredit{
Name: pc.Name,
Job: pc.Kind.String(),
PersonID: strconv.FormatInt(pc.PersonID, 10),
TmdbID: pc.TmdbID,
ImdbID: pc.ImdbID,
TvdbID: pc.TvdbID,
PlexGUID: pc.PlexGUID,
PhotoURL: pc.PhotoURL,
PhotoThumbhash: pc.PhotoThumbhash,
})
}
}
if cast == nil {
cast = []CastCredit{}
}
if crew == nil {
crew = []CrewCredit{}
}
return cast, crew
}
func seriesFolderPathsFromFiles(files []*models.MediaFile) []string {
if len(files) == 0 {
return nil
}
seen := make(map[string]struct{}, len(files))
out := make([]string, 0, len(files))
for _, file := range files {
if file == nil {
continue
}
path := strings.TrimSpace(file.ObservedRootPath)
if path == "" {
path = strings.TrimSpace(file.CanonicalRootPath)
}
if path == "" && strings.TrimSpace(file.FilePath) != "" {
path = filepath.Dir(file.FilePath)
}
if path == "" {
continue
}
path = filepath.Clean(path)
if _, ok := seen[path]; ok {
continue
}
seen[path] = struct{}{}
out = append(out, path)
}
sort.Strings(out)
return out
}
// clearSentinel returns "" for the no-photo sentinel, passing through real values.
func clearSentinel(s string) string {
if s == "-" {
return ""
}
return s
}
func (s *DetailService) buildSeasonDetail(ctx context.Context, season *models.Season, filter AccessFilter) (*ItemDetail, error) {
localizedSeason, err := s.LocalizeSeasonModel(ctx, season, filter)
if err != nil {
return nil, fmt.Errorf("localizing season detail: %w", err)
}
season = localizedSeason
series, err := s.itemRepo.GetByID(ctx, season.SeriesID)
if err != nil {
return nil, fmt.Errorf("loading parent series: %w", err)
}
series, err = s.LocalizeItemModel(ctx, series, filter)
if err != nil {
return nil, fmt.Errorf("localizing season series detail: %w", err)
}
episodes := []*models.Episode{}
if s.episodeRepo != nil {
episodes, err = s.episodeRepo.ListBySeasonID(ctx, season.ContentID)
if err != nil {
return nil, fmt.Errorf("listing season episodes: %w", err)
}
}
title := season.Title
if title == "" {
if season.SeasonNumber == 0 {
title = "Specials"
} else {
title = fmt.Sprintf("Season %d", season.SeasonNumber)
}
}
episodeCount := len(episodes)
seasonNumber := season.SeasonNumber
castCredits, crewCredits := s.fetchCredits(ctx, season.SeriesID)
detail := &ItemDetail{
ContentID: season.ContentID,
Type: "season",
Title: title,
Overview: season.Overview,
PosterThumbhash: season.PosterThumbhash,
BackdropThumbhash: series.BackdropThumbhash,
SeriesID: season.SeriesID,
SeriesTitle: series.Title,
SeasonNumber: &seasonNumber,
EpisodeCount: &episodeCount,
IsSpecials: season.SeasonNumber == 0,
Cast: castCredits,
Crew: crewCredits,
Versions: []FileVersion{},
PlaybackVariants: []PlaybackVariant{},
Subtitles: []SubtitleInfo{},
}
if season.AirDate != nil {
airDate := season.AirDate.Format("2006-01-02")
detail.AirDate = &airDate
}
detail.PosterURL = s.PresignImageURL(ctx, season.PosterPath, "poster", "")
detail.BackdropURL = s.PresignImageURL(ctx, series.BackdropPath, "backdrop", "")
return detail, nil
}
func (s *DetailService) buildEpisodeDetail(ctx context.Context, episode *models.Episode, seriesCtx *seriesDetailContext, filter AccessFilter) (*ItemDetail, error) {
localizedEpisode, err := s.LocalizeEpisodeModel(ctx, episode, filter)
if err != nil {
return nil, fmt.Errorf("localizing episode detail: %w", err)
}
episode = localizedEpisode
series := seriesCtx.series
seasonNumber := episode.SeasonNumber
episodeNumber := episode.EpisodeNumber
detail := &ItemDetail{
ContentID: episode.ContentID,
Type: "episode",
Title: episode.Title,
Overview: episode.Overview,
Runtime: episode.Runtime,
RatingIMDB: episode.RatingIMDB,
RatingTMDB: episode.RatingTMDB,
ImdbID: episode.ImdbID,
TmdbID: episode.TmdbID,
TvdbID: episode.TvdbID,
PosterThumbhash: episode.StillThumbhash,
BackdropThumbhash: series.BackdropThumbhash,
SeriesID: episode.SeriesID,
SeriesTitle: series.Title,
SeasonNumber: &seasonNumber,
EpisodeNumber: &episodeNumber,
Cast: seriesCtx.castCredits,
Crew: seriesCtx.crewCredits,
Versions: []FileVersion{},
PlaybackVariants: []PlaybackVariant{},
Subtitles: []SubtitleInfo{},
}
if episode.AirDate != nil {
airDate := episode.AirDate.Format("2006-01-02")
detail.AirDate = &airDate
}
if episode.Title == "" {
detail.Title = fmt.Sprintf("Episode %d", episode.EpisodeNumber)
}
detail.PosterURL = s.PresignImageURL(ctx, episode.StillPath, "still", "")
detail.BackdropURL = seriesCtx.backdropURL
files, err := s.fileFetcher.GetByEpisodeID(ctx, episode.ContentID)
if err != nil {
return nil, fmt.Errorf("fetching file versions: %w", err)
}
files = FilterMediaFilesByAccess(files, filter)
files = s.preparePlaybackFiles(ctx, files)
detail.Versions, detail.PlaybackVariants, detail.Subtitles, detail.Intro, detail.Credits, detail.Recap, detail.Preview = s.buildPlaybackInfo(
ctx,
files,
filter,
episode.SeriesID,
)
detail.OverlaySummary = overlays.BuildSummary(files)
defaults := s.effectiveSubtitleDefaults(ctx, filter, episode.SeriesID, files)
detail.EffectiveSubtitleLanguage = defaults.Language
detail.HasEffectiveSubtitleLang = defaults.HasLanguage
detail.EffectiveSubtitleMode = defaults.Mode
detail.HasEffectiveSubtitleMode = defaults.HasMode
detail.EffectiveShowForcedSubtitles = defaults.ShowForced
detail.HasEffectiveShowForcedSubtitles = defaults.HasShowForced
detail.EffectiveSubtitleTrackSignature = defaults.TrackSignature
if seriesCtx.versionPref.HasAny {
if seriesCtx.versionPref.Resolution != "" {
detail.EffectiveVersionResolution = stringPtr(seriesCtx.versionPref.Resolution)
}
detail.EffectiveVersionHDR = boolPtr(seriesCtx.versionPref.HDR)
if seriesCtx.versionPref.CodecVideo != "" {
detail.EffectiveVersionCodecVideo = stringPtr(seriesCtx.versionPref.CodecVideo)
}
}
return detail, nil
}
// GetWatchDetail resolves a directly playable content ID into a normalized
// playback payload. Movies resolve by media item content ID; episodes resolve
// by episode content ID via their linked series and episode file records.
func (s *DetailService) GetWatchDetail(ctx context.Context, contentID string, filter AccessFilter) (*WatchDetail, error) {
item, err := s.itemRepo.GetByID(ctx, contentID)
switch {
case err == nil:
if err := s.itemRepo.EnsureAccessible(ctx, contentID, filter); err != nil {
return nil, err
}
if err := s.validatePresentationItemAccess(ctx, filter, contentID); err != nil {
return nil, err
}
item, err = s.LocalizeItemModel(ctx, item, filter)
if err != nil {
return nil, fmt.Errorf("localizing watch item: %w", err)
}
if item.Type == "series" {
return nil, ErrWatchTargetNotPlayable
}
files, err := s.fileFetcher.GetByContentID(ctx, contentID)
if err != nil {
return nil, fmt.Errorf("fetching watch file versions: %w", err)
}
files = FilterMediaFilesByAccess(files, filter)
files = s.preparePlaybackFiles(ctx, files)
s.queueWatchPlaybackFiles(ctx, item.ContentID, item.Type, files)
detail := s.newWatchDetail(
ctx,
item.ContentID,
item.Type,
item.Title,
item.Overview,
files,
filter,
item.ContentID,
)
detail.Year = item.Year
defaults := s.effectiveSubtitleDefaults(ctx, filter, item.ContentID, files)
detail.EffectiveSubtitleLanguage = defaults.Language
detail.HasEffectiveSubtitleLang = defaults.HasLanguage
detail.EffectiveSubtitleMode = defaults.Mode
detail.HasEffectiveSubtitleMode = defaults.HasMode
detail.EffectiveShowForcedSubtitles = defaults.ShowForced
detail.HasEffectiveShowForcedSubtitles = defaults.HasShowForced
detail.EffectiveSubtitleTrackSignature = defaults.TrackSignature
return detail, nil
case !errors.Is(err, ErrItemNotFound):
return nil, err
}
if s.episodeRepo == nil {
return nil, ErrItemNotFound
}
episode, err := s.episodeRepo.GetByID(ctx, contentID)
if err != nil {
return nil, err
}
if err := s.itemRepo.EnsureAccessible(ctx, episode.SeriesID, filter); err != nil {
return nil, err
}
if err := s.validatePresentationItemAccess(ctx, filter, episode.ContentID); err != nil {
return nil, err
}
episode, err = s.LocalizeEpisodeModel(ctx, episode, filter)
if err != nil {
return nil, fmt.Errorf("localizing episode watch detail: %w", err)
}
files, err := s.fileFetcher.GetByEpisodeID(ctx, episode.ContentID)
if err != nil {
return nil, fmt.Errorf("fetching episode watch file versions: %w", err)
}
files = FilterMediaFilesByAccess(files, filter)
files = s.preparePlaybackFiles(ctx, files)
s.queueWatchPlaybackFiles(ctx, episode.ContentID, "episode", files)
detail := s.newWatchDetail(
ctx,
episode.ContentID,
"episode",
episode.Title,
episode.Overview,
files,
filter,
episode.SeriesID,
)
detail.SeasonNumber = episode.SeasonNumber
detail.EpisodeNumber = episode.EpisodeNumber
detail.SeriesID = episode.SeriesID
defaults := s.effectiveSubtitleDefaults(ctx, filter, episode.SeriesID, files)
detail.EffectiveSubtitleLanguage = defaults.Language
detail.HasEffectiveSubtitleLang = defaults.HasLanguage
detail.EffectiveSubtitleMode = defaults.Mode
detail.HasEffectiveSubtitleMode = defaults.HasMode
detail.EffectiveShowForcedSubtitles = defaults.ShowForced
detail.HasEffectiveShowForcedSubtitles = defaults.HasShowForced
detail.EffectiveSubtitleTrackSignature = defaults.TrackSignature
if versionPref := s.effectiveVersionDefaults(ctx, filter, episode.SeriesID); versionPref.HasAny {
if versionPref.Resolution != "" {
detail.EffectiveVersionResolution = stringPtr(versionPref.Resolution)
}
detail.EffectiveVersionHDR = boolPtr(versionPref.HDR)
if versionPref.CodecVideo != "" {
detail.EffectiveVersionCodecVideo = stringPtr(versionPref.CodecVideo)
}
}
if series, err := s.itemRepo.GetByID(ctx, episode.SeriesID); err == nil {
series, err = s.LocalizeItemModel(ctx, series, filter)
if err != nil {
return nil, fmt.Errorf("localizing series watch detail: %w", err)
}
detail.SeriesTitle = series.Title
detail.Year = series.Year
}
return detail, nil
}
func (s *DetailService) newWatchDetail(
ctx context.Context,
contentID,
contentType,
title,
overview string,
files []*models.MediaFile,
filter AccessFilter,
audioPreferenceContentID string,
) *WatchDetail {
versions, playbackVariants, subtitles, intro, credits, recap, preview := s.buildPlaybackInfo(
ctx,
files,
filter,
audioPreferenceContentID,
)
return &WatchDetail{
ContentID: contentID,
Type: contentType,
Title: title,
Overview: overview,
Versions: versions,
PlaybackVariants: playbackVariants,
Subtitles: subtitles,
Intro: intro,
Credits: credits,
Recap: recap,
Preview: preview,
}
}
func (s *DetailService) effectiveSubtitleDefaults(
ctx context.Context,
filter AccessFilter,
seriesID string,
files []*models.MediaFile,
) subtitleDefaults {
defaults := subtitleDefaults{}
if s.userStoreProvider == nil || filter.UserID == 0 || filter.ProfileID == "" {
return defaults
}
store, err := s.userStoreProvider.ForUser(ctx, filter.UserID)
if err != nil || store == nil {
return defaults
}
if profile, err := store.GetProfile(ctx, filter.ProfileID); err == nil && profile != nil {
defaults.Language = profile.SubtitleLanguage
defaults.Mode = profile.SubtitleMode
defaults.ShowForced = profile.ShowForcedSubtitles
defaults.HasLanguage = true
defaults.HasMode = true
defaults.HasShowForced = true
}
if libraryID := preferredPlayableLibraryID(files, filter.SelectedFileID); libraryID > 0 {
if pref, err := store.GetLibraryPlaybackPreference(ctx, filter.ProfileID, libraryID); err == nil && pref != nil {
if pref.HasSubtitleLanguage {
defaults.Language = pref.SubtitleLanguage
defaults.HasLanguage = true
}
if pref.HasSubtitleMode {
defaults.Mode = pref.SubtitleMode
defaults.HasMode = true
}
if pref.HasShowForcedSubtitles {
defaults.ShowForced = pref.ShowForcedSubtitles
defaults.HasShowForced = true
}
}
}
if seriesID != "" {
if pref, err := store.GetSubtitlePreference(ctx, filter.ProfileID, seriesID); err == nil && pref != nil {
defaults.Language = pref.SubtitleLanguage
defaults.HasLanguage = true
if pref.SubtitleMode != "" {
defaults.Mode = pref.SubtitleMode
defaults.HasMode = true
}
if pref.HasShowForcedSubtitles {
defaults.ShowForced = pref.ShowForcedSubtitles
defaults.HasShowForced = true
}
if pref.TrackSignature != nil && !pref.TrackSignature.IsZero() {
defaults.TrackSignature = pref.TrackSignature
}
}
}
return defaults
}
func (s *DetailService) effectiveVersionDefaults(
ctx context.Context,
filter AccessFilter,
seriesID string,
) versionDefaults {
defaults := versionDefaults{}
if seriesID == "" || s.userStoreProvider == nil || filter.UserID == 0 || filter.ProfileID == "" {
return defaults
}
store, err := s.userStoreProvider.ForUser(ctx, filter.UserID)
if err != nil || store == nil {
return defaults
}
pref, err := store.GetSeriesPlaybackPreference(ctx, filter.ProfileID, seriesID)
if err != nil || pref == nil {
return defaults
}
defaults.Resolution = pref.Resolution
defaults.HDR = pref.HDR
defaults.CodecVideo = pref.CodecVideo
defaults.HasAny = pref.Resolution != "" || pref.CodecVideo != "" || pref.HDR
return defaults
}
func (s *DetailService) resolveOriginalLanguage(ctx context.Context, contentID string) string {
if s != nil && s.originalLangFn != nil {
return strings.TrimSpace(s.originalLangFn(ctx, contentID))
}
if s == nil || s.itemRepo == nil || strings.TrimSpace(contentID) == "" {
return ""
}
lang, err := s.itemRepo.GetOriginalLanguage(ctx, contentID)
if err != nil {
return ""
}
return strings.TrimSpace(lang)
}
type effectiveAudioSelection struct {
Index int
Language string
}
func resolveSelectedAudioLanguage(
file *models.MediaFile,
index int,
originalLanguage string,
useOriginalFallback bool,
) string {
if file != nil && index >= 0 && index < len(file.AudioTracks) {
if language := strings.TrimSpace(file.AudioTracks[index].Language); language != "" {
return language
}
}
if useOriginalFallback {
return strings.TrimSpace(originalLanguage)
}
return ""
}
func (s *DetailService) effectiveAudioSelection(
ctx context.Context,
filter AccessFilter,
audioPreferenceContentID string,
file *models.MediaFile,
) effectiveAudioSelection {
if file == nil || len(file.AudioTracks) == 0 {
return effectiveAudioSelection{}
}
if s.userStoreProvider == nil || filter.UserID == 0 || filter.ProfileID == "" {
index := playback.SelectAudioTrack(file.AudioTracks, "", nil)
return effectiveAudioSelection{
Index: index,
Language: resolveSelectedAudioLanguage(file, index, "", false),
}
}
store, err := s.userStoreProvider.ForUser(ctx, filter.UserID)
if err != nil || store == nil {
index := playback.SelectAudioTrack(file.AudioTracks, "", nil)
return effectiveAudioSelection{
Index: index,
Language: resolveSelectedAudioLanguage(file, index, "", false),
}
}
var seriesPref *playback.AudioTrackPreference
if strings.TrimSpace(audioPreferenceContentID) != "" {
if pref, prefErr := store.GetAudioPreference(ctx, filter.ProfileID, audioPreferenceContentID); prefErr == nil && pref != nil {
seriesPref = &playback.AudioTrackPreference{
AudioTrackIndex: pref.AudioTrackIndex,
AudioLanguage: pref.AudioLanguage,
TrackSignature: pref.TrackSignature,
}
}
}
preferredLang := ""
if profile, profileErr := store.GetProfile(ctx, filter.ProfileID); profileErr == nil && profile != nil {
preferredLang = strings.TrimSpace(profile.Language)
}
libraryAudioLang := ""
if seriesPref == nil {
if pref, prefErr := store.GetLibraryPlaybackPreference(ctx, filter.ProfileID, file.MediaFolderID); prefErr == nil && pref != nil {
libraryAudioLang = strings.TrimSpace(pref.AudioLanguage)
}
}
originalLanguage := ""
resolveOriginalLanguage := func() string {
if originalLanguage == "" {
originalLanguage = s.resolveOriginalLanguage(ctx, audioPreferenceContentID)
}
return originalLanguage
}
seriesUsesOriginal := seriesPref != nil && seriesPref.AudioLanguage == playback.OriginalLanguageSentinel
profileUsesOriginal := preferredLang == playback.OriginalLanguageSentinel
libraryUsesOriginal := libraryAudioLang == playback.OriginalLanguageSentinel
if seriesUsesOriginal {
seriesPref.AudioLanguage = resolveOriginalLanguage()
}
if profileUsesOriginal {
preferredLang = resolveOriginalLanguage()
}
if libraryUsesOriginal {
libraryAudioLang = resolveOriginalLanguage()
}
if libraryAudioLang != "" {
preferredLang = libraryAudioLang
}
useOriginalFallback := seriesUsesOriginal ||
(libraryUsesOriginal && libraryAudioLang != "") ||
(profileUsesOriginal && libraryAudioLang == "")
index := playback.SelectAudioTrack(file.AudioTracks, preferredLang, seriesPref)
return effectiveAudioSelection{
Index: index,
Language: resolveSelectedAudioLanguage(file, index, originalLanguage, useOriginalFallback),
}
}
func (s *DetailService) effectiveAudioTrackIndex(
ctx context.Context,
filter AccessFilter,
audioPreferenceContentID string,
file *models.MediaFile,
) int {
return s.effectiveAudioSelection(ctx, filter, audioPreferenceContentID, file).Index
}
func stringPtr(value string) *string {
return &value
}
func boolPtr(value bool) *bool {
return &value
}
func intPtr(value int) *int {
return &value
}
func preferredPlayableLibraryID(files []*models.MediaFile, selectedFileID int) int {
if selectedFileID > 0 {
for _, file := range files {
if file != nil && file.ID == selectedFileID {
return file.MediaFolderID
}
}
}
best := bestPlayableFile(files)
if best == nil {
return 0
}
return best.MediaFolderID
}
func bestPlayableFile(files []*models.MediaFile) *models.MediaFile {
var best *models.MediaFile
for _, file := range files {
if file == nil {
continue
}
if best == nil {
best = file
continue
}
switch access.CompareQuality(file.Resolution, best.Resolution) {
case 1:
best = file
case 0:
if file.FileSize < best.FileSize {
best = file
}
}
}
return best
}
// markerFromRange converts a (start, end) pair into a *Marker, returning nil
// when either bound is missing. Used to lift the four per-file segment kinds
// (intro/credits/recap/preview) into the api response shape.
func markerFromRange(start, end *float64) *Marker {
if start == nil || end == nil {
return nil
}
return &Marker{Start: *start, End: *end}
}
func (s *DetailService) buildPlaybackInfo(
ctx context.Context,
files []*models.MediaFile,
filter AccessFilter,
audioPreferenceContentID string,
) ([]FileVersion, []PlaybackVariant, []SubtitleInfo, *Marker, *Marker, *Marker, *Marker) {
versions := make([]FileVersion, 0, len(files))
subtitleSet := make(map[string]SubtitleInfo)
var firstIntro, firstCredits, firstRecap, firstPreview *Marker
for _, f := range files {
if f == nil {
continue
}
effectiveAudioSelection := s.effectiveAudioSelection(
ctx,
filter,
audioPreferenceContentID,
f,
)
versionIntro := markerFromRange(f.IntroStart, f.IntroEnd)
if versionIntro != nil && firstIntro == nil {
firstIntro = versionIntro
}
versionCredits := markerFromRange(f.CreditsStart, f.CreditsEnd)
if versionCredits != nil && firstCredits == nil {
firstCredits = versionCredits
}
versionRecap := markerFromRange(f.RecapStart, f.RecapEnd)
if versionRecap != nil && firstRecap == nil {
firstRecap = versionRecap
}
versionPreview := markerFromRange(f.PreviewStart, f.PreviewEnd)
if versionPreview != nil && firstPreview == nil {
firstPreview = versionPreview
}
versions = append(versions, FileVersion{
FileID: f.ID,
FileName: filepath.Base(f.FilePath),
FilePath: f.FilePath,
Resolution: f.Resolution,
CodecVideo: f.CodecVideo,
CodecAudio: f.CodecAudio,
HDR: f.HDR,
Container: f.Container,
FileSize: f.FileSize,
Duration: f.Duration,
Bitrate: f.Bitrate,
AddedAt: f.CreatedAt,
EditionRaw: f.EditionRaw,
EditionKey: f.EditionKey,
PresentationKind: f.PresentationKind,
PresentationGroupKey: f.PresentationGroupKey,
PresentationPartIndex: f.PresentationPartIndex,
MultiEpisodeStart: f.MultiEpisodeStart,
MultiEpisodeEnd: f.MultiEpisodeEnd,
EffectiveAudioTrackIndex: intPtr(effectiveAudioSelection.Index),
EffectiveAudioLanguage: effectiveAudioSelection.Language,
VideoTracks: append([]models.VideoTrack(nil), f.VideoTracks...),
AudioTracks: append([]models.AudioTrack(nil), f.AudioTracks...),
SubtitleTracks: buildVersionSubtitleTracks(f),
Chapters: s.buildVersionChapters(ctx, f),
Intro: versionIntro,
Credits: versionCredits,
Recap: versionRecap,
Preview: versionPreview,
})
for _, sub := range f.SubtitleTracks {
key := fmt.Sprintf("embedded:%s:%s:%v:%v", sub.Language, sub.Codec, sub.Forced, sub.HearingImpaired)
if _, exists := subtitleSet[key]; !exists {
subtitleSet[key] = SubtitleInfo{
Source: "embedded",
Language: sub.Language,
Codec: sub.Codec,
Forced: sub.Forced,
HearingImpaired: sub.HearingImpaired,
Title: sub.Title,
}
}
}
for _, sub := range f.ExternalSubtitles {
key := fmt.Sprintf("external:%s:%s:%v:%v", sub.Language, sub.Format, sub.Forced, sub.HearingImpaired)
if _, exists := subtitleSet[key]; !exists {
subtitleSet[key] = SubtitleInfo{
Source: "external",
Language: sub.Language,
Codec: sub.Format,
Forced: sub.Forced,
HearingImpaired: sub.HearingImpaired,
}
}
}
}
subtitles := make([]SubtitleInfo, 0, len(subtitleSet))
for _, sub := range subtitleSet {
subtitles = append(subtitles, sub)
}
variants := buildPlaybackVariants(versions, filter.SelectedFileID)
selectedVersionExists := playbackVersionExists(versions, filter.SelectedFileID)
pick := func(field func(v FileVersion) *Marker, fallback *Marker) *Marker {
m := selectedPlaybackMarker(versions, variants, filter.SelectedFileID, field)
if m == nil && !selectedVersionExists {
return fallback
}
return m
}
intro := pick(func(v FileVersion) *Marker { return v.Intro }, firstIntro)
credits := pick(func(v FileVersion) *Marker { return v.Credits }, firstCredits)
recap := pick(func(v FileVersion) *Marker { return v.Recap }, firstRecap)
preview := pick(func(v FileVersion) *Marker { return v.Preview }, firstPreview)
return versions, variants, subtitles, intro, credits, recap, preview
}
func playbackVersionExists(versions []FileVersion, selectedFileID int) bool {
if selectedFileID <= 0 {
return false
}
for _, version := range versions {
if version.FileID == selectedFileID {
return true
}
}
return false
}
func selectedPlaybackMarker(
versions []FileVersion,
variants []PlaybackVariant,
selectedFileID int,
marker func(FileVersion) *Marker,
) *Marker {
if marker == nil {
return nil
}
if selectedFileID > 0 {
for _, version := range versions {
if version.FileID == selectedFileID {
return marker(version)
}
}
}
for _, variant := range variants {
if variant.DefaultFileID <= 0 {
continue
}
for _, version := range versions {
if version.FileID == variant.DefaultFileID {
if matched := marker(version); matched != nil {
return matched
}
break
}
}
}
return nil
}
func buildPlaybackVariants(versions []FileVersion, selectedFileID int) []PlaybackVariant {
if len(versions) == 0 {
return []PlaybackVariant{}
}
type partBucket struct {
PartIndex int
Versions []FileVersion
}
type variantBucket struct {
EditionRaw string
EditionKey string
PresentationKind string
PresentationGroupKey string
Parts map[int]*partBucket
}
byVariant := make(map[string]*variantBucket)
order := make([]string, 0)
for _, version := range versions {
variantID := playbackVariantID(version)
bucket, ok := byVariant[variantID]
if !ok {
bucket = &variantBucket{
EditionRaw: version.EditionRaw,
EditionKey: version.EditionKey,
PresentationKind: version.PresentationKind,
PresentationGroupKey: version.PresentationGroupKey,
Parts: map[int]*partBucket{},
}
byVariant[variantID] = bucket
order = append(order, variantID)
}
partIndex := version.PresentationPartIndex
if partIndex <= 0 {
partIndex = 1
}
part, ok := bucket.Parts[partIndex]
if !ok {
part = &partBucket{PartIndex: partIndex}
bucket.Parts[partIndex] = part
}
part.Versions = append(part.Versions, version)
}
variants := make([]PlaybackVariant, 0, len(order))
for _, variantID := range order {
bucket := byVariant[variantID]
partIndexes := make([]int, 0, len(bucket.Parts))
for partIndex := range bucket.Parts {
partIndexes = append(partIndexes, partIndex)
}
sort.Ints(partIndexes)
parts := make([]PlaybackVariantPart, 0, len(partIndexes))
totalDuration := 0
defaultFileID := 0
for _, partIndex := range partIndexes {
part := bucket.Parts[partIndex]
sortFileVersions(part.Versions)
defaultVersion := chooseDefaultVariantVersion(part.Versions, selectedFileID)
if defaultVersion != nil {
if defaultFileID == 0 {
defaultFileID = defaultVersion.FileID
}
}
partDuration := 0
for _, version := range part.Versions {
if version.Duration > partDuration {
partDuration = version.Duration
}
}
totalDuration += partDuration
parts = append(parts, PlaybackVariantPart{
PartIndex: partIndex,
DefaultFileID: fileIDOrZero(defaultVersion),
TotalDuration: partDuration,
Versions: part.Versions,
})
}
variants = append(variants, PlaybackVariant{
VariantID: variantID,
EditionRaw: bucket.EditionRaw,
EditionKey: bucket.EditionKey,
PresentationKind: bucket.PresentationKind,
PresentationGroupKey: bucket.PresentationGroupKey,
PartCount: len(parts),
TotalDuration: totalDuration,
DefaultFileID: defaultFileID,
Parts: parts,
})
}
return variants
}
func playbackVariantID(version FileVersion) string {
editionKey := strings.TrimSpace(version.EditionKey)
presentationKind := strings.TrimSpace(version.PresentationKind)
groupKey := strings.TrimSpace(version.PresentationGroupKey)
if editionKey == "" && presentationKind == "" && groupKey == "" {
return "default"
}
return strings.Join([]string{editionKey, presentationKind, groupKey}, "|")
}
func sortFileVersions(versions []FileVersion) {
sort.SliceStable(versions, func(i, j int) bool {
a, b := versions[i], versions[j]
switch access.CompareQuality(a.Resolution, b.Resolution) {
case 1:
return true
case -1:
return false
}
if a.HDR != b.HDR {
return a.HDR
}
return a.FileSize > b.FileSize
})
}
func chooseDefaultVariantVersion(versions []FileVersion, selectedFileID int) *FileVersion {
if selectedFileID > 0 {
for i := range versions {
if versions[i].FileID == selectedFileID {
return &versions[i]
}
}
}
if len(versions) == 0 {
return nil
}
return &versions[0]
}
func fileIDOrZero(version *FileVersion) int {
if version == nil {
return 0
}
return version.FileID
}
func (s *DetailService) preparePlaybackFiles(ctx context.Context, files []*models.MediaFile) []*models.MediaFile {
if len(files) == 0 {
return files
}
prepared := make([]*models.MediaFile, 0, len(files))
for _, file := range files {
if file == nil {
continue
}
if s.probeEnsurer != nil {
ensured, err := s.probeEnsurer.Ensure(ctx, file)
if err == nil && ensured != nil {
file = ensured
}
}
prepared = append(prepared, file)
}
return prepared
}
func (s *DetailService) queueWatchPlaybackFiles(
ctx context.Context,
contentID string,
contentType string,
files []*models.MediaFile,
) {
if s == nil || s.chapterThumbs == nil || len(files) == 0 {
return
}
fileIDs := make([]int, 0, len(files))
for _, file := range files {
if file == nil || file.ID <= 0 {
continue
}
fileIDs = append(fileIDs, file.ID)
}
if len(fileIDs) == 0 {
return
}
slog.Info(
"queueing chapter thumbnails",
"source",
"watch_detail",
"content_id",
contentID,
"content_type",
contentType,
"file_count",
len(fileIDs),
)
s.chapterThumbs.QueueFileIDs(ctx, fileIDs)
}
func (s *DetailService) buildVersionChapters(ctx context.Context, file *models.MediaFile) []VersionChapter {
if file == nil || len(file.Chapters) == 0 {
return []VersionChapter{}
}
chapters := make([]VersionChapter, 0, len(file.Chapters))
for _, chapter := range file.Chapters {
ch := VersionChapter{
Index: chapter.Index,
Title: chapter.Title,
StartSeconds: chapter.StartSeconds,
EndSeconds: chapter.EndSeconds,
Source: chapter.Source,
ThumbnailThumbhash: chapter.ThumbnailThumbhash,
}
if chapter.ThumbnailPath != "" {
ch.ThumbnailURL = s.PresignURL(ctx, strings.Replace(chapter.ThumbnailPath, "/original.", "/w300.", 1), "card")
}
chapters = append(chapters, ch)
}
return chapters
}
func buildVersionSubtitleTracks(file *models.MediaFile) []VersionSubtitleTrack {
tracks := make([]VersionSubtitleTrack, 0, len(file.SubtitleTracks)+len(file.ExternalSubtitles))
for _, sub := range file.SubtitleTracks {
tracks = append(tracks, VersionSubtitleTrack{
Index: sub.Index,
Language: sub.Language,
Codec: sub.Codec,
Title: sub.Title,
EmbeddedTitle: sub.EmbeddedTitle,
Resolution: sub.Resolution,
Forced: sub.Forced,
Default: sub.Default,
HearingImpaired: sub.HearingImpaired,
External: sub.External,
FileName: sub.FileName,
})
}
for _, sub := range file.ExternalSubtitles {
tracks = append(tracks, VersionSubtitleTrack{
Language: sub.Language,
Codec: sub.Format,
Title: firstNonEmpty(sub.Title, filepath.Base(sub.Path)),
EmbeddedTitle: sub.EmbeddedTitle,
Resolution: sub.Resolution,
Forced: sub.Forced,
Default: sub.Default,
HearingImpaired: sub.HearingImpaired,
External: true,
FileName: filepath.Base(sub.Path),
})
}
return tracks
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
// PresignURL resolves an image path to a usable URL:
// - Empty/"-" → ""
// - http:// or https:// → returned as-is (external provider URLs)
// - {plugin_id}:// prefix → resolved via plugin's ResolveImageURL RPC
// - Bare path (legacy) → logs warning and returns "" (no longer resolvable)
//
// The variant parameter is a semantic size hint forwarded to plugin resolvers:
// "card", "featured", "full", "original".
func (s *DetailService) PresignURL(ctx context.Context, path string, variant string) string {
return s.PresignURLWithExpiry(ctx, path, variant).URL
}
// PresignURLWithExpiry resolves an image path and returns expiry metadata when
// the underlying resolver can state the resolved URL validity window.
func (s *DetailService) PresignURLWithExpiry(ctx context.Context, path string, variant string) ResolvedImageURL {
if path == "" || path == "-" {
return ResolvedImageURL{}
}
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
return ResolvedImageURL{URL: path}
}
if s.imageResolver != nil {
if resolver, ok := s.imageResolver.(expiringImageResolver); ok {
return resolver.ResolveImageURLWithExpiry(ctx, path, variant)
}
return ResolvedImageURL{URL: s.imageResolver.ResolveImageURL(ctx, path, variant)}
}
slog.Warn("image path could not be resolved", "path", path)
return ResolvedImageURL{}
}
// PresignImageURLs resolves multiple image paths to usable URLs with variant
// support while preserving the original input paths as lookup keys.
func (s *DetailService) PresignImageURLs(ctx context.Context, paths []string, imageType, size string) map[string]string {
resolvedWithExpiry := s.PresignImageURLsWithExpiry(ctx, paths, imageType, size)
resolved := make(map[string]string, len(resolvedWithExpiry))
for path, value := range resolvedWithExpiry {
resolved[path] = value.URL
}
return resolved
}
// PresignImageURLsWithExpiry resolves multiple image paths with image-type size
// normalization while preserving original input paths as lookup keys.
func (s *DetailService) PresignImageURLsWithExpiry(ctx context.Context, paths []string, imageType, size string) map[string]ResolvedImageURL {
if len(paths) == 0 {
return map[string]ResolvedImageURL{}
}
variant := sizeToVariant(size)
normalizedPaths := make([]string, 0, len(paths))
originalsByNormalized := make(map[string][]string, len(paths))
for _, path := range paths {
if path == "" || path == "-" {
continue
}
normalized := path
if !strings.HasPrefix(path, "http://") &&
!strings.HasPrefix(path, "https://") &&
!strings.Contains(path, "://") {
normalized = cachedImageVariantPath(path, imageType, size)
}
if _, ok := originalsByNormalized[normalized]; !ok {
normalizedPaths = append(normalizedPaths, normalized)
}
originalsByNormalized[normalized] = append(originalsByNormalized[normalized], path)
}
if len(normalizedPaths) == 0 {
return map[string]ResolvedImageURL{}
}
resolvedByNormalized := s.PresignURLsWithExpiry(ctx, normalizedPaths, variant)
resolved := make(map[string]ResolvedImageURL, len(paths))
for _, normalized := range normalizedPaths {
value, ok := resolvedByNormalized[normalized]
if !ok || value.URL == "" {
value = s.PresignURLWithExpiry(ctx, normalized, variant)
}
for _, original := range originalsByNormalized[normalized] {
resolved[original] = value
}
}
return resolved
}
// PresignURLsWithExpiry resolves already-normalized image paths in a single
// batch, preserving the input path as the lookup key.
func (s *DetailService) PresignURLsWithExpiry(ctx context.Context, paths []string, variant string) map[string]ResolvedImageURL {
if len(paths) == 0 {
return map[string]ResolvedImageURL{}
}
resolved := make(map[string]ResolvedImageURL, len(paths))
resolverPaths := make([]string, 0, len(paths))
for _, path := range paths {
if path == "" || path == "-" {
continue
}
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
resolved[path] = ResolvedImageURL{URL: path}
continue
}
resolverPaths = append(resolverPaths, path)
}
if s.imageResolver != nil {
if resolver, ok := s.imageResolver.(expiringImageResolver); ok {
for path, value := range resolver.ResolveImageURLsWithExpiry(ctx, resolverPaths, variant) {
resolved[path] = value
}
} else {
for path, url := range s.imageResolver.ResolveImageURLs(ctx, resolverPaths, variant) {
resolved[path] = ResolvedImageURL{URL: url}
}
}
}
for _, path := range resolverPaths {
if _, ok := resolved[path]; ok {
continue
}
if s.imageResolver == nil {
resolved[path] = s.PresignURLWithExpiry(ctx, path, variant)
}
}
return resolved
}
// sizeToVariant maps the existing S3 size hints used by the frontend to
// semantic variant names understood by plugins.
func sizeToVariant(size string) string {
switch size {
case "small":
return "card"
case "medium":
return "featured"
case "original":
return "original"
default: // "" (the default in most call sites)
return "featured"
}
}
// PresignImageURL resolves an image path to a usable URL with variant support.
// For cached base paths (bare S3 keys without a scheme), appends the appropriate
// variant filename and presigns the result. For http(s) URLs and plugin-prefixed
// paths, delegates to PresignURL with a mapped semantic variant.
func (s *DetailService) PresignImageURL(ctx context.Context, path, imageType, size string) string {
return s.PresignImageURLWithExpiry(ctx, path, imageType, size).URL
}
// PresignImageURLWithExpiry resolves one image path using the same image type
// and size normalization as PresignImageURL, retaining URL expiry metadata.
func (s *DetailService) PresignImageURLWithExpiry(ctx context.Context, path, imageType, size string) ResolvedImageURL {
if path == "" || path == "-" {
return ResolvedImageURL{}
}
// Plugin-prefixed paths resolve via plugin with semantic variant.
if strings.Contains(path, "://") &&
!strings.HasPrefix(path, "http://") &&
!strings.HasPrefix(path, "https://") {
return s.PresignURLWithExpiry(ctx, path, sizeToVariant(size))
}
// HTTP URLs pass through.
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
return ResolvedImageURL{URL: path}
}
return s.PresignURLWithExpiry(ctx, cachedImageVariantPath(path, imageType, size), sizeToVariant(size))
}
func cachedImageVariantPath(path, imageType, size string) string {
if strings.Contains(path, "://") {
return path
}
variant := cachedImageVariantKey(imageType, size)
if variant == "" {
return path
}
if strings.Contains(path, "/original.") {
return strings.Replace(path, "/original.", "/"+variant+".", 1)
}
return path
}
func cachedImageVariantKey(imageType, size string) string {
if size == "original" {
return "original"
}
switch imageType {
case "backdrop":
if size == "small" {
return "w300"
}
return "w1920"
case "logo":
return "w500"
case "poster", "still":
if size == "small" {
return "w300"
}
return "w500"
default:
if size == "small" {
return "w300"
}
return "w500"
}
}