- 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
2215 lines
74 KiB
Go
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"
|
|
}
|
|
}
|