* feat(metadata): add original-language preferences * fix(settings): show metadata exceptions immediately * docs(settings): add metadata language screenshot * fix(settings): make language exceptions responsive * fix(settings): standardize language display names
4080 lines
140 KiB
Go
4080 lines
140 KiB
Go
package catalog
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/access"
|
|
"github.com/Silo-Server/silo-server/internal/artworkkey"
|
|
"github.com/Silo-Server/silo-server/internal/lang"
|
|
"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/settingscontract"
|
|
"github.com/Silo-Server/silo-server/internal/settingskeys"
|
|
"github.com/Silo-Server/silo-server/internal/settingsresolve"
|
|
"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)
|
|
}
|
|
|
|
// extraFileFetcher is the optional FileVersionFetcher extension used to
|
|
// resolve files backing local extras (media_extras rows). The concrete
|
|
// *scanner.FileRepository implements it; test fakes may omit it.
|
|
type extraFileFetcher interface {
|
|
GetByExtraID(ctx context.Context, extraID string) ([]*models.MediaFile, error)
|
|
}
|
|
|
|
type batchDurationFetcher interface {
|
|
FirstDurationsByContentIDs(ctx context.Context, ids []string) (map[string]int, error)
|
|
FirstDurationsByEpisodeIDs(ctx context.Context, ids []string) (map[string]int, 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
|
|
}
|
|
|
|
type WorkSummaryProvider interface {
|
|
GetSummaryForContentID(ctx context.Context, contentID string, filter AccessFilter) (*WorkSummary, error)
|
|
}
|
|
|
|
type WorkSummaryBatchProvider interface {
|
|
ListSummariesForContentIDs(ctx context.Context, contentIDs []string, filter AccessFilter) (map[string]*WorkSummary, error)
|
|
}
|
|
|
|
type WorkSummary struct {
|
|
WorkID string
|
|
Title string
|
|
Formats []WorkFormatSummary
|
|
}
|
|
|
|
type WorkFormatSummary struct {
|
|
Type string `json:"type"`
|
|
ContentID string `json:"content_id"`
|
|
LibraryID int `json:"library_id,omitempty"`
|
|
}
|
|
|
|
// ProbedDurationsByContentIDs resolves first-file probed durations when the
|
|
// configured file fetcher supports the optional batch extension.
|
|
func (s *DetailService) ProbedDurationsByContentIDs(ctx context.Context, ids []string) map[string]int {
|
|
if s == nil || s.fileFetcher == nil || len(ids) == 0 {
|
|
return nil
|
|
}
|
|
fetcher, ok := s.fileFetcher.(batchDurationFetcher)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
durations, err := fetcher.FirstDurationsByContentIDs(ctx, ids)
|
|
if err != nil {
|
|
slog.Warn("failed to fetch probed content durations", "error", err, "id_count", len(ids))
|
|
return nil
|
|
}
|
|
return durations
|
|
}
|
|
|
|
// ProbedDurationsByEpisodeIDs resolves first-file probed durations when the
|
|
// configured file fetcher supports the optional batch extension.
|
|
func (s *DetailService) ProbedDurationsByEpisodeIDs(ctx context.Context, ids []string) map[string]int {
|
|
if s == nil || s.fileFetcher == nil || len(ids) == 0 {
|
|
return nil
|
|
}
|
|
fetcher, ok := s.fileFetcher.(batchDurationFetcher)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
durations, err := fetcher.FirstDurationsByEpisodeIDs(ctx, ids)
|
|
if err != nil {
|
|
slog.Warn("failed to fetch probed episode durations", "error", err, "id_count", len(ids))
|
|
return nil
|
|
}
|
|
return durations
|
|
}
|
|
|
|
// 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"`
|
|
// PendingTranslationLanguage, when set, is the viewer's presentation
|
|
// language that the description is missing — the on-view AI translation
|
|
// affordance keys off it.
|
|
PendingTranslationLanguage string `json:"pending_translation_language,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"`
|
|
AirTimezone *string `json:"air_timezone,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"`
|
|
|
|
// Literary work linkage. Present for linked ebook/audiobook items.
|
|
WorkID string `json:"work_id,omitempty"`
|
|
WorkTitle string `json:"work_title,omitempty"`
|
|
WorkFormats []WorkFormatSummary `json:"work_formats,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"`
|
|
|
|
// Remote provider videos (YouTube trailers, teasers, ...) for
|
|
// movies/series, ordered for display (trailers first, official first).
|
|
Videos []ItemVideoInfo `json:"videos,omitempty"`
|
|
|
|
// Local extras (scanner-discovered trailers, featurettes, deleted
|
|
// scenes, ...) playable via their own content_id through /watch.
|
|
Extras []ItemExtraInfo `json:"extras,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"`
|
|
|
|
// Audiobook-specific detail. Present only when Type == "audiobook".
|
|
Audiobook *AudiobookDetailExtension `json:"audiobook,omitempty"`
|
|
|
|
// Ebook-specific detail. Present only when Type == "ebook".
|
|
Ebook *EbookDetailExtension `json:"ebook,omitempty"`
|
|
|
|
// Manga-specific detail. Present only when Type == "manga".
|
|
Manga *MangaDetailExtension `json:"manga,omitempty"`
|
|
}
|
|
|
|
// ItemVideoInfo is the API shape of a remote provider video. It exposes the
|
|
// site reference (site + site_key) rather than internal row identity so
|
|
// clients can build embed/watch URLs without further lookups.
|
|
type ItemVideoInfo struct {
|
|
Kind string `json:"kind"`
|
|
Site string `json:"site"`
|
|
SiteKey string `json:"site_key"`
|
|
Name string `json:"name,omitempty"`
|
|
Language string `json:"language,omitempty"`
|
|
IsOfficial bool `json:"is_official"`
|
|
}
|
|
|
|
// ItemExtraInfo is the API shape of a local extra. ContentID is a playable
|
|
// watch target (same /watch flow as any item); FileID backs download/direct
|
|
// stream affordances.
|
|
type ItemExtraInfo struct {
|
|
ContentID string `json:"content_id"`
|
|
Kind string `json:"kind"`
|
|
Title string `json:"title,omitempty"`
|
|
DurationSeconds int `json:"duration_seconds,omitempty"`
|
|
FileID int `json:"file_id,omitempty"`
|
|
}
|
|
|
|
type AudiobookDetailExtension struct {
|
|
Authors []AudiobookPerson `json:"authors"`
|
|
Narrators []AudiobookPerson `json:"narrators"`
|
|
Publisher string `json:"publisher,omitempty"`
|
|
TotalDurationSeconds int `json:"total_duration_seconds"`
|
|
Series *AudiobookSeriesGroup `json:"series,omitempty"`
|
|
OtherNarrations []AudiobookNarration `json:"other_narrations"`
|
|
Related AudiobookRelatedContent `json:"related"`
|
|
}
|
|
|
|
type AudiobookPerson struct {
|
|
PersonID string `json:"person_id,omitempty"`
|
|
Name string `json:"name"`
|
|
PhotoURL string `json:"photo_url,omitempty"`
|
|
PhotoThumbhash string `json:"photo_thumbhash,omitempty"`
|
|
}
|
|
|
|
type AudiobookRelatedContent struct {
|
|
AlsoByAuthor []AudiobookRelatedItem `json:"also_by_author"`
|
|
Similar []AudiobookRelatedItem `json:"similar"`
|
|
}
|
|
|
|
type AudiobookRelatedItem struct {
|
|
ContentID string `json:"content_id"`
|
|
Title string `json:"title"`
|
|
Year int `json:"year,omitempty"`
|
|
PosterURL string `json:"poster_url,omitempty"`
|
|
SeriesIndex *int `json:"series_index,omitempty"`
|
|
}
|
|
|
|
type AudiobookSeriesGroup struct {
|
|
Name string `json:"name,omitempty"`
|
|
Entries []AudiobookRelatedItem `json:"entries"`
|
|
}
|
|
|
|
type AudiobookNarration struct {
|
|
ContentID string `json:"content_id"`
|
|
Title string `json:"title"`
|
|
Year int `json:"year,omitempty"`
|
|
Narrators []string `json:"narrators"`
|
|
}
|
|
|
|
type EbookDetailExtension struct {
|
|
Authors []AudiobookPerson `json:"authors"`
|
|
Publisher string `json:"publisher,omitempty"`
|
|
Series *AudiobookSeriesGroup `json:"series,omitempty"`
|
|
Related AudiobookRelatedContent `json:"related"`
|
|
}
|
|
|
|
// MangaDetailExtension is the manga-series detail payload. A manga series item
|
|
// (media_items.type='manga') owns a set of readable chapter items
|
|
// (media_items.type='ebook') linked via the manga_chapters table.
|
|
type MangaDetailExtension struct {
|
|
Chapters []MangaChapter `json:"chapters"`
|
|
}
|
|
|
|
// MangaChapter is one chapter of a manga series, ordered by chapter index.
|
|
type MangaChapter struct {
|
|
ContentID string `json:"content_id"`
|
|
Title string `json:"title"`
|
|
ChapterIndex *float64 `json:"chapter_index,omitempty"`
|
|
Volume string `json:"volume,omitempty"`
|
|
// Read is true when the current viewer has finished this chapter, mirroring
|
|
// ebook read state: ebook_reader_progress.progress >= the finished threshold.
|
|
Read bool `json:"read"`
|
|
// Progress is the viewer's reading position as a 0..1 fraction, present
|
|
// only when a progress row exists. The row progress bar uses it.
|
|
Progress *float64 `json:"progress,omitempty"`
|
|
// PosterURL is the chapter's extracted cover (presigned), for row thumbnails.
|
|
PosterURL string `json:"poster_url,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"`
|
|
PresentationPartTotal int `json:"presentation_part_total,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
|
|
}
|
|
|
|
func (d subtitleDefaults) applyToItemDetail(detail *ItemDetail) {
|
|
detail.EffectiveSubtitleLanguage = d.Language
|
|
detail.HasEffectiveSubtitleLang = d.HasLanguage
|
|
detail.EffectiveSubtitleMode = d.Mode
|
|
detail.HasEffectiveSubtitleMode = d.HasMode
|
|
detail.EffectiveShowForcedSubtitles = d.ShowForced
|
|
detail.HasEffectiveShowForcedSubtitles = d.HasShowForced
|
|
detail.EffectiveSubtitleTrackSignature = d.TrackSignature
|
|
}
|
|
|
|
func (d subtitleDefaults) applyToWatchDetail(detail *WatchDetail) {
|
|
detail.EffectiveSubtitleLanguage = d.Language
|
|
detail.HasEffectiveSubtitleLang = d.HasLanguage
|
|
detail.EffectiveSubtitleMode = d.Mode
|
|
detail.HasEffectiveSubtitleMode = d.HasMode
|
|
detail.EffectiveShowForcedSubtitles = d.ShowForced
|
|
detail.HasEffectiveShowForcedSubtitles = d.HasShowForced
|
|
detail.EffectiveSubtitleTrackSignature = d.TrackSignature
|
|
}
|
|
|
|
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
|
|
videoRepo *VideoRepository
|
|
extraRepo *ExtraRepository
|
|
rootClaimRepo *RootClaimRepository
|
|
groupClaimRepo *GroupClaimRepository
|
|
imageResolver ImageResolver
|
|
userStoreProvider userstore.UserStoreProvider
|
|
workSummary WorkSummaryProvider
|
|
originalLangFn func(context.Context, string) string
|
|
probeEnsurer PlaybackProbeEnsurer
|
|
chapterThumbs ChapterThumbnailQueuer
|
|
|
|
// resolver is built once on first use; see settingsResolver.
|
|
resolverOnce sync.Once
|
|
resolver *settingsresolve.Resolver
|
|
}
|
|
|
|
// 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),
|
|
videoRepo: NewVideoRepository(itemRepo.pool),
|
|
extraRepo: NewExtraRepository(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) SetWorkSummaryProvider(provider WorkSummaryProvider) {
|
|
if s != nil {
|
|
s.workSummary = 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
|
|
}
|
|
|
|
type presentationLanguageBase struct {
|
|
target string
|
|
libraryFallback string
|
|
explicit bool
|
|
}
|
|
|
|
// resolvePresentationLanguageBase picks the request-wide fallback once. The
|
|
// item-specific original-language rule is applied separately, because one
|
|
// result page can contain several source languages and therefore several
|
|
// localization targets.
|
|
func (s *DetailService) resolvePresentationLanguageBase(ctx context.Context, filter AccessFilter) (presentationLanguageBase, error) {
|
|
base := presentationLanguageBase{}
|
|
if explicit := strings.TrimSpace(filter.PresentationLanguage); explicit != "" {
|
|
base.target = explicit
|
|
base.explicit = true
|
|
} else {
|
|
base.target = strings.TrimSpace(filter.ProfilePreferredLanguage)
|
|
}
|
|
|
|
// A concrete profile/explicit target never needs the library fallback. The
|
|
// original-language sentinel does: items with no known original language
|
|
// should still inherit the library rather than lose localization entirely.
|
|
if base.target != "" && !sameMetadataLanguage(base.target, access.OriginalMetadataLanguage) {
|
|
return base, nil
|
|
}
|
|
if filter.PresentationLibraryID == nil || s.folderRepo == nil {
|
|
return base, nil
|
|
}
|
|
folder, err := s.folderRepo.GetByID(ctx, *filter.PresentationLibraryID)
|
|
if err != nil {
|
|
if errors.Is(err, ErrFolderNotFound) {
|
|
return presentationLanguageBase{}, ErrItemNotFound
|
|
}
|
|
return presentationLanguageBase{}, err
|
|
}
|
|
base.libraryFallback = strings.TrimSpace(folder.MetadataLanguage)
|
|
if base.target == "" {
|
|
base.target = base.libraryFallback
|
|
}
|
|
return base, nil
|
|
}
|
|
|
|
// presentationLanguageForOriginal applies a profile's per-source exception,
|
|
// then resolves the original-language sentinel to the item's concrete catalog
|
|
// language. An explicit request language remains authoritative and bypasses
|
|
// profile exceptions.
|
|
func presentationLanguageForOriginal(base presentationLanguageBase, originalLanguage string, filter AccessFilter) string {
|
|
original := lang.Canonical(originalLanguage)
|
|
target := base.target
|
|
if !base.explicit && original != "" {
|
|
if override := strings.TrimSpace(filter.MetadataLanguageOverrides[original]); override != "" {
|
|
target = override
|
|
}
|
|
}
|
|
if sameMetadataLanguage(target, access.OriginalMetadataLanguage) {
|
|
if original != "" {
|
|
return original
|
|
}
|
|
return base.libraryFallback
|
|
}
|
|
return strings.TrimSpace(target)
|
|
}
|
|
|
|
func (s *DetailService) resolvePresentationLanguage(ctx context.Context, filter AccessFilter, originalLanguage string) (string, error) {
|
|
base, err := s.resolvePresentationLanguageBase(ctx, filter)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return presentationLanguageForOriginal(base, originalLanguage, filter), nil
|
|
}
|
|
|
|
func metadataLanguageMayUseOriginal(filter AccessFilter) bool {
|
|
if explicit := strings.TrimSpace(filter.PresentationLanguage); explicit != "" {
|
|
return sameMetadataLanguage(explicit, access.OriginalMetadataLanguage)
|
|
}
|
|
return sameMetadataLanguage(filter.ProfilePreferredLanguage, access.OriginalMetadataLanguage) ||
|
|
len(filter.MetadataLanguageOverrides) > 0
|
|
}
|
|
|
|
// seriesOriginalLanguage supplies the source language for season and episode
|
|
// rows, which deliberately inherit original_language from their parent series
|
|
// instead of duplicating it in each table.
|
|
func (s *DetailService) seriesOriginalLanguage(ctx context.Context, seriesID string, filter AccessFilter) string {
|
|
if original := strings.TrimSpace(filter.PresentationOriginalLanguage); original != "" {
|
|
return original
|
|
}
|
|
if !metadataLanguageMayUseOriginal(filter) || s.itemRepo == nil || strings.TrimSpace(seriesID) == "" {
|
|
return ""
|
|
}
|
|
series, err := s.itemRepo.GetByID(ctx, seriesID)
|
|
if err != nil || series == nil {
|
|
return ""
|
|
}
|
|
return series.OriginalLanguage
|
|
}
|
|
|
|
// seriesOriginalLanguages is the batch equivalent of seriesOriginalLanguage.
|
|
// A season or episode page usually contains many children of one series; load
|
|
// each distinct parent once so original-language preferences do not introduce
|
|
// an N+1 query on those pages.
|
|
func (s *DetailService) seriesOriginalLanguages(
|
|
ctx context.Context,
|
|
seriesIDs []string,
|
|
filter AccessFilter,
|
|
) (map[string]string, error) {
|
|
originalBySeries := make(map[string]string)
|
|
seen := make(map[string]struct{}, len(seriesIDs))
|
|
distinct := make([]string, 0, len(seriesIDs))
|
|
for _, seriesID := range seriesIDs {
|
|
seriesID = strings.TrimSpace(seriesID)
|
|
if seriesID == "" {
|
|
continue
|
|
}
|
|
if _, exists := seen[seriesID]; exists {
|
|
continue
|
|
}
|
|
seen[seriesID] = struct{}{}
|
|
distinct = append(distinct, seriesID)
|
|
}
|
|
|
|
if supplied := strings.TrimSpace(filter.PresentationOriginalLanguage); supplied != "" {
|
|
for _, seriesID := range distinct {
|
|
originalBySeries[seriesID] = supplied
|
|
}
|
|
return originalBySeries, nil
|
|
}
|
|
if len(distinct) == 0 || !metadataLanguageMayUseOriginal(filter) || s.itemRepo == nil {
|
|
return originalBySeries, nil
|
|
}
|
|
|
|
series, err := s.itemRepo.GetByIDs(ctx, distinct)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, item := range series {
|
|
if item != nil {
|
|
originalBySeries[item.ContentID] = item.OriginalLanguage
|
|
}
|
|
}
|
|
return originalBySeries, nil
|
|
}
|
|
|
|
// PendingTranslationLanguage reports the presentation language the item's
|
|
// description is missing: non-empty when the resolved language differs from
|
|
// the item's base metadata language, the base overview has text, and no
|
|
// localized overview exists yet. Clients use it to offer (or auto-run)
|
|
// on-view AI translation; it is pure data, independent of whether the AI
|
|
// feature is enabled.
|
|
func (s *DetailService) PendingTranslationLanguage(ctx context.Context, item *models.MediaItem, filter AccessFilter) string {
|
|
if item == nil || strings.TrimSpace(item.Overview) == "" || s.itemLocRepo == nil {
|
|
return ""
|
|
}
|
|
language, err := s.resolvePresentationLanguage(ctx, filter, item.OriginalLanguage)
|
|
if err != nil || language == "" || sameMetadataLanguage(item.DefaultMetadataLanguage, language) {
|
|
return ""
|
|
}
|
|
loc, err := s.itemLocRepo.Get(ctx, item.ContentID, language)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return pendingTranslationLanguageWith(item, language, loc)
|
|
}
|
|
|
|
// pendingTranslationLanguageWith is the shared core of PendingTranslationLanguage:
|
|
// it decides the pending-translation language for an item given a pre-resolved
|
|
// presentation language and the item's localization row (nil when none exists).
|
|
// The batch detail path reuses it after a single bulk localization lookup so it
|
|
// yields an identical result to the per-item method.
|
|
func pendingTranslationLanguageWith(item *models.MediaItem, language string, loc *models.MediaItemLocalization) string {
|
|
if item == nil || strings.TrimSpace(item.Overview) == "" || language == "" || sameMetadataLanguage(item.DefaultMetadataLanguage, language) {
|
|
return ""
|
|
}
|
|
if loc != nil && loc.Overview != "" {
|
|
return ""
|
|
}
|
|
return language
|
|
}
|
|
|
|
// PendingSeasonTranslationLanguage is the season-row equivalent of
|
|
// PendingTranslationLanguage.
|
|
func (s *DetailService) PendingSeasonTranslationLanguage(ctx context.Context, season *models.Season, filter AccessFilter) string {
|
|
if season == nil || strings.TrimSpace(season.Overview) == "" || s.seasonLocRepo == nil {
|
|
return ""
|
|
}
|
|
language, err := s.resolvePresentationLanguage(ctx, filter, s.seriesOriginalLanguage(ctx, season.SeriesID, filter))
|
|
if err != nil || language == "" || sameMetadataLanguage(season.DefaultMetadataLanguage, language) {
|
|
return ""
|
|
}
|
|
loc, err := s.seasonLocRepo.Get(ctx, season.ContentID, language)
|
|
if err != nil || (loc != nil && loc.Overview != "") {
|
|
return ""
|
|
}
|
|
return language
|
|
}
|
|
|
|
// PendingEpisodeTranslationLanguage is the episode-row equivalent of
|
|
// PendingTranslationLanguage.
|
|
func (s *DetailService) PendingEpisodeTranslationLanguage(ctx context.Context, episode *models.Episode, filter AccessFilter) string {
|
|
if episode == nil || strings.TrimSpace(episode.Overview) == "" || s.episodeLocRepo == nil {
|
|
return ""
|
|
}
|
|
language, err := s.resolvePresentationLanguage(ctx, filter, s.seriesOriginalLanguage(ctx, episode.SeriesID, filter))
|
|
if err != nil || language == "" || sameMetadataLanguage(episode.DefaultMetadataLanguage, language) {
|
|
return ""
|
|
}
|
|
loc, err := s.episodeLocRepo.Get(ctx, episode.ContentID, language)
|
|
if err != nil || (loc != nil && loc.Overview != "") {
|
|
return ""
|
|
}
|
|
return language
|
|
}
|
|
|
|
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, item.OriginalLanguage)
|
|
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 {
|
|
return cloneMediaItem(item), err
|
|
}
|
|
return s.localizeItemModelWith(item, language, loc), nil
|
|
}
|
|
|
|
// loadItemLocalizations resolves each item's target and groups repository reads
|
|
// by that language. The former single-language lookup was correct only while a
|
|
// profile had one global target; source-language exceptions make the grouping
|
|
// necessary to preserve batching without serving one item's localization to
|
|
// another language group.
|
|
func (s *DetailService) loadItemLocalizations(
|
|
ctx context.Context,
|
|
items []*models.MediaItem,
|
|
filter AccessFilter,
|
|
) (map[string]string, map[string]*models.MediaItemLocalization, error) {
|
|
base, err := s.resolvePresentationLanguageBase(ctx, filter)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
targets := make(map[string]string, len(items))
|
|
if s.itemLocRepo == nil {
|
|
return targets, nil, nil
|
|
}
|
|
|
|
idsByLanguage := make(map[string][]string)
|
|
seenByLanguage := make(map[string]map[string]struct{})
|
|
for _, item := range items {
|
|
if item == nil || item.ContentID == "" {
|
|
continue
|
|
}
|
|
target := presentationLanguageForOriginal(base, item.OriginalLanguage, filter)
|
|
targets[item.ContentID] = target
|
|
if target == "" || sameMetadataLanguage(item.DefaultMetadataLanguage, target) {
|
|
continue
|
|
}
|
|
if seenByLanguage[target] == nil {
|
|
seenByLanguage[target] = make(map[string]struct{})
|
|
}
|
|
if _, seen := seenByLanguage[target][item.ContentID]; seen {
|
|
continue
|
|
}
|
|
seenByLanguage[target][item.ContentID] = struct{}{}
|
|
idsByLanguage[target] = append(idsByLanguage[target], item.ContentID)
|
|
}
|
|
|
|
languages := make([]string, 0, len(idsByLanguage))
|
|
for language := range idsByLanguage {
|
|
languages = append(languages, language)
|
|
}
|
|
sort.Strings(languages)
|
|
localizations := make(map[string]*models.MediaItemLocalization)
|
|
for _, language := range languages {
|
|
rows, err := s.itemLocRepo.GetByContentIDs(ctx, idsByLanguage[language], language)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
for contentID, localization := range rows {
|
|
localizations[contentID] = localization
|
|
}
|
|
}
|
|
return targets, localizations, nil
|
|
}
|
|
|
|
// localizeItemModelWith applies a pre-resolved localization to item, returning a
|
|
// clone when there is nothing to localize (no language, base language already
|
|
// matches, no localization repo, or no localization row). Shared core of
|
|
// LocalizeItemModel so the batch detail path produces identical models after a
|
|
// single bulk localization lookup.
|
|
func (s *DetailService) localizeItemModelWith(item *models.MediaItem, language string, loc *models.MediaItemLocalization) *models.MediaItem {
|
|
if language == "" || sameMetadataLanguage(item.DefaultMetadataLanguage, language) || s.itemLocRepo == nil || loc == nil {
|
|
return cloneMediaItem(item)
|
|
}
|
|
return applyItemLocalization(item, loc)
|
|
}
|
|
|
|
// LocalizeItemModels applies presentation-language localization to a batch of
|
|
// media items with a single lookup. Items without a localization row are
|
|
// returned unchanged; the result preserves input order and length.
|
|
func (s *DetailService) LocalizeItemModels(ctx context.Context, items []*models.MediaItem, filter AccessFilter) ([]*models.MediaItem, error) {
|
|
if len(items) == 0 {
|
|
return items, nil
|
|
}
|
|
localized := make([]*models.MediaItem, len(items))
|
|
for i, item := range items {
|
|
localized[i] = cloneMediaItem(item)
|
|
}
|
|
targets, locs, err := s.loadItemLocalizations(ctx, items, filter)
|
|
if err != nil || s.itemLocRepo == nil {
|
|
return localized, err
|
|
}
|
|
for i, item := range items {
|
|
if item == nil {
|
|
continue
|
|
}
|
|
if loc := locs[item.ContentID]; loc != nil {
|
|
localized[i] = s.localizeItemModelWith(item, targets[item.ContentID], loc)
|
|
}
|
|
}
|
|
return localized, nil
|
|
}
|
|
|
|
func (s *DetailService) LocalizeSeasonModel(ctx context.Context, season *models.Season, filter AccessFilter) (*models.Season, error) {
|
|
if season == nil {
|
|
return nil, nil
|
|
}
|
|
original := s.seriesOriginalLanguage(ctx, season.SeriesID, filter)
|
|
language, err := s.resolvePresentationLanguage(ctx, filter, original)
|
|
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
|
|
}
|
|
return applySeasonLocalization(season, loc), nil
|
|
}
|
|
|
|
// LocalizeSeasonModels applies presentation-language localization to a batch
|
|
// of seasons. Parent series and localization rows are fetched in batches, so
|
|
// original-language preferences do not add one query per season. The result
|
|
// preserves input order and length.
|
|
func (s *DetailService) LocalizeSeasonModels(ctx context.Context, seasons []*models.Season, filter AccessFilter) ([]*models.Season, error) {
|
|
if len(seasons) == 0 {
|
|
return seasons, nil
|
|
}
|
|
localized := make([]*models.Season, len(seasons))
|
|
seriesIDs := make([]string, 0, len(seasons))
|
|
for i, season := range seasons {
|
|
localized[i] = cloneSeason(season)
|
|
if season != nil {
|
|
seriesIDs = append(seriesIDs, season.SeriesID)
|
|
}
|
|
}
|
|
|
|
base, err := s.resolvePresentationLanguageBase(ctx, filter)
|
|
if err != nil || s.seasonLocRepo == nil {
|
|
return localized, err
|
|
}
|
|
originalBySeries, err := s.seriesOriginalLanguages(ctx, seriesIDs, filter)
|
|
if err != nil {
|
|
return localized, err
|
|
}
|
|
|
|
targets := make(map[string]string, len(seasons))
|
|
idsByLanguage := make(map[string][]string)
|
|
seenByLanguage := make(map[string]map[string]struct{})
|
|
for _, season := range seasons {
|
|
if season == nil || season.ContentID == "" {
|
|
continue
|
|
}
|
|
target := presentationLanguageForOriginal(base, originalBySeries[season.SeriesID], filter)
|
|
targets[season.ContentID] = target
|
|
if target == "" || sameMetadataLanguage(season.DefaultMetadataLanguage, target) {
|
|
continue
|
|
}
|
|
if seenByLanguage[target] == nil {
|
|
seenByLanguage[target] = make(map[string]struct{})
|
|
}
|
|
if _, seen := seenByLanguage[target][season.ContentID]; seen {
|
|
continue
|
|
}
|
|
seenByLanguage[target][season.ContentID] = struct{}{}
|
|
idsByLanguage[target] = append(idsByLanguage[target], season.ContentID)
|
|
}
|
|
|
|
languages := make([]string, 0, len(idsByLanguage))
|
|
for language := range idsByLanguage {
|
|
languages = append(languages, language)
|
|
}
|
|
sort.Strings(languages)
|
|
locs := make(map[string]*models.SeasonLocalization)
|
|
for _, language := range languages {
|
|
rows, err := s.seasonLocRepo.GetBySeasonIDs(ctx, idsByLanguage[language], language)
|
|
if err != nil {
|
|
return localized, err
|
|
}
|
|
for seasonID, localization := range rows {
|
|
locs[seasonID] = localization
|
|
}
|
|
}
|
|
for i, season := range seasons {
|
|
if season == nil {
|
|
continue
|
|
}
|
|
if loc := locs[season.ContentID]; loc != nil {
|
|
target := targets[season.ContentID]
|
|
if target != "" && !sameMetadataLanguage(season.DefaultMetadataLanguage, target) {
|
|
localized[i] = applySeasonLocalization(season, loc)
|
|
}
|
|
}
|
|
}
|
|
return localized, nil
|
|
}
|
|
|
|
func (s *DetailService) LocalizeEpisodeModel(ctx context.Context, episode *models.Episode, filter AccessFilter) (*models.Episode, error) {
|
|
if episode == nil {
|
|
return nil, nil
|
|
}
|
|
original := s.seriesOriginalLanguage(ctx, episode.SeriesID, filter)
|
|
language, err := s.resolvePresentationLanguage(ctx, filter, original)
|
|
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
|
|
}
|
|
return applyEpisodeLocalization(episode, loc), nil
|
|
}
|
|
|
|
// LocalizeEpisodeModels applies presentation-language localization to a batch
|
|
// of episodes with a single lookup. Episodes without a localization row are
|
|
// returned unchanged; the result preserves input order and length.
|
|
func (s *DetailService) LocalizeEpisodeModels(ctx context.Context, episodes []*models.Episode, filter AccessFilter) ([]*models.Episode, error) {
|
|
if len(episodes) == 0 {
|
|
return episodes, nil
|
|
}
|
|
base, err := s.resolvePresentationLanguageBase(ctx, filter)
|
|
if err != nil || s.episodeLocRepo == nil {
|
|
return episodes, err
|
|
}
|
|
|
|
seriesIDs := make([]string, 0, len(episodes))
|
|
for _, episode := range episodes {
|
|
if episode != nil {
|
|
seriesIDs = append(seriesIDs, episode.SeriesID)
|
|
}
|
|
}
|
|
originalBySeries, err := s.seriesOriginalLanguages(ctx, seriesIDs, filter)
|
|
if err != nil {
|
|
return episodes, err
|
|
}
|
|
|
|
targets := make(map[string]string, len(episodes))
|
|
idsByLanguage := make(map[string][]string)
|
|
for _, ep := range episodes {
|
|
if ep == nil || ep.ContentID == "" {
|
|
continue
|
|
}
|
|
target := presentationLanguageForOriginal(base, originalBySeries[ep.SeriesID], filter)
|
|
targets[ep.ContentID] = target
|
|
if target == "" || sameMetadataLanguage(ep.DefaultMetadataLanguage, target) {
|
|
continue
|
|
}
|
|
idsByLanguage[target] = append(idsByLanguage[target], ep.ContentID)
|
|
}
|
|
|
|
languages := make([]string, 0, len(idsByLanguage))
|
|
for language := range idsByLanguage {
|
|
languages = append(languages, language)
|
|
}
|
|
sort.Strings(languages)
|
|
locs := make(map[string]*models.EpisodeLocalization)
|
|
for _, language := range languages {
|
|
rows, err := s.episodeLocRepo.GetByEpisodeIDs(ctx, idsByLanguage[language], language)
|
|
if err != nil {
|
|
return episodes, err
|
|
}
|
|
for episodeID, localization := range rows {
|
|
locs[episodeID] = localization
|
|
}
|
|
}
|
|
localized := make([]*models.Episode, len(episodes))
|
|
for i, ep := range episodes {
|
|
localized[i] = ep
|
|
if ep == nil {
|
|
continue
|
|
}
|
|
if loc := locs[ep.ContentID]; loc != nil {
|
|
target := targets[ep.ContentID]
|
|
if target != "" && !sameMetadataLanguage(ep.DefaultMetadataLanguage, target) {
|
|
localized[i] = applyEpisodeLocalization(ep, loc)
|
|
}
|
|
}
|
|
}
|
|
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, nil)
|
|
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) {
|
|
// Fourth tier: a local extra. Serving it from GetItemDetail keeps
|
|
// per-item consumers that resolve arbitrary content ids
|
|
// (jellycompat PlaybackInfo in particular) playable without a
|
|
// separate lookup path.
|
|
return s.buildExtraItemDetail(ctx, contentID, filter)
|
|
}
|
|
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)
|
|
}
|
|
|
|
// buildExtraItemDetail resolves a local extra as a minimal ItemDetail:
|
|
// title/kind plus the ordinary playback surface (versions, subtitles) built
|
|
// from its backing files. Access control is the parent item's.
|
|
func (s *DetailService) buildExtraItemDetail(ctx context.Context, contentID string, filter AccessFilter) (*ItemDetail, error) {
|
|
if s.extraRepo == nil {
|
|
return nil, ErrItemNotFound
|
|
}
|
|
extra, err := s.extraRepo.GetByID(ctx, contentID)
|
|
if err != nil {
|
|
if errors.Is(err, ErrExtraNotFound) {
|
|
return nil, ErrItemNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
if err := s.itemRepo.EnsureAccessible(ctx, extra.ParentID, filter); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.validatePresentationItemAccess(ctx, filter, extra.ParentID); err != nil {
|
|
return nil, err
|
|
}
|
|
fetcher, ok := s.fileFetcher.(extraFileFetcher)
|
|
if !ok {
|
|
return nil, ErrItemNotFound
|
|
}
|
|
files, err := fetcher.GetByExtraID(ctx, extra.ContentID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetching extra files: %w", err)
|
|
}
|
|
files = FilterMediaFilesByAccess(files, filter)
|
|
files = s.preparePlaybackFiles(ctx, files)
|
|
|
|
detail := &ItemDetail{
|
|
ContentID: extra.ContentID,
|
|
Type: "extra",
|
|
Title: extra.Title,
|
|
Genres: []string{},
|
|
Cast: []CastCredit{},
|
|
Crew: []CrewCredit{},
|
|
Studios: []string{},
|
|
Networks: []string{},
|
|
}
|
|
detail.Versions, detail.PlaybackVariants, detail.Subtitles, detail.Intro, detail.Credits, detail.Recap, detail.Preview = s.buildPlaybackInfo(
|
|
ctx,
|
|
files,
|
|
filter,
|
|
extra.ContentID,
|
|
)
|
|
if parent, parentErr := s.itemRepo.GetByID(ctx, extra.ParentID); parentErr == nil {
|
|
if localized, locErr := s.LocalizeItemModel(ctx, parent, filter); locErr == nil {
|
|
parent = localized
|
|
}
|
|
if detail.Title == "" {
|
|
detail.Title = parent.Title
|
|
}
|
|
// Series fields only for series-owned extras: clients treat a
|
|
// populated series_id as episodic context (post-roll/next-episode
|
|
// flows), which is wrong for a movie's extras.
|
|
if parent.Type == "series" {
|
|
detail.SeriesID = extra.ParentID
|
|
detail.SeriesTitle = parent.Title
|
|
}
|
|
detail.Year = parent.Year
|
|
}
|
|
return detail, nil
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// fileVersionBatchFetcher is the optional batch form of FileVersionFetcher.
|
|
// When the configured fetcher implements it, GetItemDetailsByIDs loads every
|
|
// page item's media files in one query instead of one per item. Fetchers that
|
|
// don't implement it (e.g. some test fakes) transparently fall back to the
|
|
// per-item GetByContentID path inside buildMediaItemDetail.
|
|
type fileVersionBatchFetcher interface {
|
|
ListByContentIDs(ctx context.Context, contentIDs []string) (map[string][]*models.MediaFile, error)
|
|
}
|
|
|
|
// GetItemDetailsByIDs returns full item details for the given content IDs,
|
|
// batching the shared per-item lookups that GetItemDetail otherwise issues
|
|
// one-at-a-time: row load (GetByIDs), access checks (EnsureAccessibleIDs +
|
|
// presentation-library membership), localization, credits, media files, and
|
|
// work summaries. The detail produced for each id is byte-for-byte identical to
|
|
// calling GetItemDetail(id): the same builder (buildMediaItemDetail) assembles
|
|
// it, only fed pre-resolved inputs via itemDetailPrefetch. Any input building
|
|
// block missing a batch form falls back to its inline per-item lookup, so the
|
|
// output never drifts from the unbatched path.
|
|
//
|
|
// IDs that are not item-typed media (e.g. episodes or seasons — handled by
|
|
// GetEpisodeDetailsForSeries / GetItemDetail respectively), are access-filtered,
|
|
// or fail to build are simply absent from the result map, mirroring
|
|
// GetItemDetail returning an error so callers fall back to list-level rendering.
|
|
func (s *DetailService) GetItemDetailsByIDs(ctx context.Context, contentIDs []string, filter AccessFilter) (map[string]*ItemDetail, error) {
|
|
result := make(map[string]*ItemDetail, len(contentIDs))
|
|
if len(contentIDs) == 0 {
|
|
return result, nil
|
|
}
|
|
|
|
items, err := s.itemRepo.GetByIDs(ctx, contentIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(items) == 0 {
|
|
return result, nil
|
|
}
|
|
|
|
foundIDs := make([]string, 0, len(items))
|
|
for _, item := range items {
|
|
foundIDs = append(foundIDs, item.ContentID)
|
|
}
|
|
|
|
// Access checks, batched to match GetItemDetail's per-item EnsureAccessible
|
|
// and validatePresentationItemAccess exactly.
|
|
accessible, err := s.itemRepo.EnsureAccessibleIDs(ctx, foundIDs, filter)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
presentationOK := func(string) bool { return true }
|
|
if filter.PresentationLibraryID != nil {
|
|
membership, err := s.itemRepo.GetItemsInLibrary(ctx, foundIDs, *filter.PresentationLibraryID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
presentationOK = func(id string) bool { return membership[id] }
|
|
}
|
|
|
|
visible := make([]*models.MediaItem, 0, len(items))
|
|
visibleIDs := make([]string, 0, len(items))
|
|
nonSeriesIDs := make([]string, 0, len(items))
|
|
for _, item := range items {
|
|
if !accessible[item.ContentID] || !presentationOK(item.ContentID) {
|
|
continue
|
|
}
|
|
visible = append(visible, item)
|
|
visibleIDs = append(visibleIDs, item.ContentID)
|
|
if item.Type != "series" {
|
|
nonSeriesIDs = append(nonSeriesIDs, item.ContentID)
|
|
}
|
|
}
|
|
if len(visible) == 0 {
|
|
return result, nil
|
|
}
|
|
|
|
// Localization keeps one lookup per target language. Most profiles still
|
|
// produce one query; profiles with source-language exceptions produce one
|
|
// query for each target represented on this page.
|
|
targetByID, locByID, err := s.loadItemLocalizations(ctx, visible, filter)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("localizing item detail: %w", err)
|
|
}
|
|
|
|
// Credits for the whole page in one query.
|
|
var creditsByID map[string][]models.ItemPerson
|
|
if s.personRepo != nil {
|
|
creditsByID, err = s.personRepo.ListForItems(ctx, visibleIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// Media files (playable versions) for non-series items in one query, when the
|
|
// fetcher supports batching.
|
|
var filesByID map[string][]*models.MediaFile
|
|
batchFetcher, haveFileBatch := s.fileFetcher.(fileVersionBatchFetcher)
|
|
if haveFileBatch && len(nonSeriesIDs) > 0 {
|
|
filesByID, err = batchFetcher.ListByContentIDs(ctx, nonSeriesIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// Remote videos and local extras for movie/series items in two queries.
|
|
movieSeriesIDs := make([]string, 0, len(visible))
|
|
for _, item := range visible {
|
|
if item.Type == "movie" || item.Type == "series" {
|
|
movieSeriesIDs = append(movieSeriesIDs, item.ContentID)
|
|
}
|
|
}
|
|
var videosByID map[string][]models.ItemVideo
|
|
var extrasByID map[string][]ExtraWithFile
|
|
if len(movieSeriesIDs) > 0 {
|
|
if s.videoRepo != nil {
|
|
videosByID, err = s.videoRepo.ListByContentIDs(ctx, movieSeriesIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if s.extraRepo != nil {
|
|
extrasByID, err = s.extraRepo.ListWithFilesByParentIDs(ctx, movieSeriesIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
|
|
// Work summaries for the whole page in one query, when the provider supports
|
|
// batching (ListSummariesForContentIDs is the documented batch equivalent of
|
|
// GetSummaryForContentID).
|
|
var workSummaries map[string]*WorkSummary
|
|
batchSummary, haveWorkSummaryBatch := s.workSummary.(WorkSummaryBatchProvider)
|
|
if haveWorkSummaryBatch {
|
|
workSummaries, err = batchSummary.ListSummariesForContentIDs(ctx, visibleIDs, filter)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
for _, item := range visible {
|
|
id := item.ContentID
|
|
language := targetByID[id]
|
|
loc := locByID[id]
|
|
pending := ""
|
|
if s.itemLocRepo != nil {
|
|
pending = pendingTranslationLanguageWith(item, language, loc)
|
|
}
|
|
pf := &itemDetailPrefetch{
|
|
haveLocalization: true,
|
|
pendingTranslation: pending,
|
|
localizedItem: s.localizeItemModelWith(item, language, loc),
|
|
haveCredits: s.personRepo != nil,
|
|
}
|
|
if s.personRepo != nil {
|
|
pf.castCredits, pf.crewCredits = splitCastCrew(s.personCredits(ctx, creditsByID[id]))
|
|
}
|
|
if item.Type != "series" && haveFileBatch {
|
|
pf.haveFiles = true
|
|
pf.files = filesByID[id]
|
|
}
|
|
if haveWorkSummaryBatch {
|
|
pf.haveWorkSummary = true
|
|
pf.workSummary = workSummaries[id]
|
|
}
|
|
if item.Type == "movie" || item.Type == "series" {
|
|
if s.videoRepo != nil {
|
|
pf.haveVideos = true
|
|
pf.videos = videosByID[id]
|
|
}
|
|
if s.extraRepo != nil {
|
|
pf.haveExtras = true
|
|
pf.extras = extrasByID[id]
|
|
}
|
|
}
|
|
detail, err := s.buildMediaItemDetail(ctx, item, id, filter, pf)
|
|
if err != nil {
|
|
// Skip rather than fail the batch; the caller falls back to list
|
|
// rendering for any id missing from the result, matching the
|
|
// per-item path where one item's build error never breaks the page.
|
|
continue
|
|
}
|
|
result[id] = detail
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// fetchItemVideos returns the item's remote videos in API shape, honoring a
|
|
// batch prefetch when present. Lookup failures degrade to an empty section.
|
|
func (s *DetailService) fetchItemVideos(ctx context.Context, contentID string, pf *itemDetailPrefetch) []ItemVideoInfo {
|
|
var videos []models.ItemVideo
|
|
if pf != nil && pf.haveVideos {
|
|
videos = pf.videos
|
|
} else if s.videoRepo != nil {
|
|
fetched, err := s.videoRepo.GetByContentID(ctx, contentID)
|
|
if err != nil {
|
|
slog.WarnContext(ctx, "failed to fetch item videos", "content_id", contentID, "error", err)
|
|
return nil
|
|
}
|
|
videos = fetched
|
|
}
|
|
if len(videos) == 0 {
|
|
return nil
|
|
}
|
|
infos := make([]ItemVideoInfo, 0, len(videos))
|
|
for _, v := range videos {
|
|
infos = append(infos, ItemVideoInfo{
|
|
Kind: string(v.Kind),
|
|
Site: v.Site,
|
|
SiteKey: v.SiteKey,
|
|
Name: v.Name,
|
|
Language: v.Language,
|
|
IsOfficial: v.IsOfficial,
|
|
})
|
|
}
|
|
return infos
|
|
}
|
|
|
|
// fetchItemExtras returns the item's local extras in API shape, honoring a
|
|
// batch prefetch when present. Lookup failures degrade to an empty section.
|
|
func (s *DetailService) fetchItemExtras(ctx context.Context, contentID string, pf *itemDetailPrefetch) []ItemExtraInfo {
|
|
var extras []ExtraWithFile
|
|
if pf != nil && pf.haveExtras {
|
|
extras = pf.extras
|
|
} else if s.extraRepo != nil {
|
|
fetched, err := s.extraRepo.ListWithFilesByParentID(ctx, contentID)
|
|
if err != nil {
|
|
slog.WarnContext(ctx, "failed to fetch item extras", "content_id", contentID, "error", err)
|
|
return nil
|
|
}
|
|
extras = fetched
|
|
}
|
|
if len(extras) == 0 {
|
|
return nil
|
|
}
|
|
infos := make([]ItemExtraInfo, 0, len(extras))
|
|
for _, e := range extras {
|
|
infos = append(infos, ItemExtraInfo{
|
|
ContentID: e.ContentID,
|
|
Kind: string(e.Kind),
|
|
Title: e.Title,
|
|
DurationSeconds: e.Duration,
|
|
FileID: e.FileID,
|
|
})
|
|
}
|
|
return infos
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// itemDetailPrefetch carries per-item lookups that GetItemDetailsByIDs resolves
|
|
// once for a whole page so buildMediaItemDetail can skip the equivalent per-item
|
|
// queries. Each piece is opt-in via its "have" flag: when unset, the builder
|
|
// falls back to its normal inline lookup, so the result is byte-for-byte
|
|
// identical to the unbatched GetItemDetail path regardless of which pieces were
|
|
// prefetched. A nil prefetch reproduces the original per-item behavior exactly.
|
|
type itemDetailPrefetch struct {
|
|
haveLocalization bool
|
|
pendingTranslation string
|
|
localizedItem *models.MediaItem
|
|
haveCredits bool
|
|
castCredits []CastCredit
|
|
crewCredits []CrewCredit
|
|
haveFiles bool
|
|
files []*models.MediaFile
|
|
haveWorkSummary bool
|
|
workSummary *WorkSummary
|
|
haveVideos bool
|
|
videos []models.ItemVideo
|
|
haveExtras bool
|
|
extras []ExtraWithFile
|
|
}
|
|
|
|
func (s *DetailService) buildMediaItemDetail(ctx context.Context, item *models.MediaItem, contentID string, filter AccessFilter, pf *itemDetailPrefetch) (*ItemDetail, error) {
|
|
var pendingTranslation string
|
|
if pf != nil && pf.haveLocalization {
|
|
pendingTranslation = pf.pendingTranslation
|
|
item = pf.localizedItem
|
|
} else {
|
|
pendingTranslation = s.PendingTranslationLanguage(ctx, item, filter)
|
|
localizedItem, err := s.LocalizeItemModel(ctx, item, filter)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("localizing item detail: %w", err)
|
|
}
|
|
item = localizedItem
|
|
}
|
|
var castCredits []CastCredit
|
|
var crewCredits []CrewCredit
|
|
if pf != nil && pf.haveCredits {
|
|
castCredits, crewCredits = pf.castCredits, pf.crewCredits
|
|
} else {
|
|
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,
|
|
PendingTranslationLanguage: pendingTranslation,
|
|
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,
|
|
AirTimezone: item.AirTimezone,
|
|
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" {
|
|
var files []*models.MediaFile
|
|
if pf != nil && pf.haveFiles {
|
|
files = pf.files
|
|
} else {
|
|
fetched, err := s.fileFetcher.GetByContentID(ctx, contentID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetching file versions: %w", err)
|
|
}
|
|
files = fetched
|
|
}
|
|
|
|
files = FilterMediaFilesByAccess(files, filter)
|
|
if item.Type == "audiobook" {
|
|
sortAudiobookMediaFiles(files)
|
|
}
|
|
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)
|
|
// Movie pre-play selectors need the same effective subtitle defaults
|
|
// the watch payload resolves — including any per-item override saved
|
|
// from a previous play — so the item detail can't omit them. Scoped to
|
|
// movies: episodes resolve theirs in buildEpisodeDetail, and other
|
|
// types have no subtitle selector to feed.
|
|
if item.Type == "movie" {
|
|
s.effectiveSubtitleDefaults(ctx, filter, item.ContentID, files).applyToItemDetail(detail)
|
|
}
|
|
}
|
|
|
|
// Trailers/extras apply to movies and series only.
|
|
if item.Type == "movie" || item.Type == "series" {
|
|
detail.Videos = s.fetchItemVideos(ctx, contentID, pf)
|
|
detail.Extras = s.fetchItemExtras(ctx, contentID, pf)
|
|
}
|
|
|
|
if item.Type == "audiobook" {
|
|
detail.Audiobook = s.buildAudiobookExtension(ctx, item, detail.Versions, crewCredits, filter)
|
|
}
|
|
if item.Type == "ebook" {
|
|
detail.Ebook = s.buildEbookExtension(ctx, item, crewCredits, filter)
|
|
// A manga chapter is an ebook item linked to its series; exposing the
|
|
// linkage lets the reader navigate back/next within the series and
|
|
// continue-reading cards show the series instead of the chapter.
|
|
if seriesID, seriesTitle, ok := s.lookupMangaSeriesForChapter(ctx, item.ContentID); ok {
|
|
detail.SeriesID = seriesID
|
|
detail.SeriesTitle = seriesTitle
|
|
}
|
|
}
|
|
if item.Type == "manga" {
|
|
detail.Manga = s.buildMangaExtension(ctx, item, filter)
|
|
}
|
|
|
|
// 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", "component", "catalog", "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", "component", "catalog", "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", "component", "catalog", "content_id", contentID, "error", err)
|
|
} else {
|
|
detail.FolderPaths = seriesFolderPathsFromFiles(files)
|
|
}
|
|
}
|
|
}
|
|
|
|
if pf != nil && pf.haveWorkSummary {
|
|
applyWorkSummaryValue(detail, pf.workSummary)
|
|
} else {
|
|
applyWorkSummary(ctx, detail, s.workSummary, filter)
|
|
}
|
|
return detail, nil
|
|
}
|
|
|
|
func applyWorkSummary(ctx context.Context, detail *ItemDetail, provider WorkSummaryProvider, filter AccessFilter) {
|
|
if detail == nil || provider == nil || detail.ContentID == "" {
|
|
return
|
|
}
|
|
summary, err := provider.GetSummaryForContentID(ctx, detail.ContentID, filter)
|
|
if err != nil {
|
|
return
|
|
}
|
|
applyWorkSummaryValue(detail, summary)
|
|
}
|
|
|
|
// applyWorkSummaryValue copies a resolved work summary onto the detail. Shared by
|
|
// the per-item (applyWorkSummary) and batched (GetItemDetailsByIDs) paths so a
|
|
// summary fetched in bulk applies identically. A nil summary is a no-op.
|
|
func applyWorkSummaryValue(detail *ItemDetail, summary *WorkSummary) {
|
|
if detail == nil || summary == nil {
|
|
return
|
|
}
|
|
detail.WorkID = summary.WorkID
|
|
detail.WorkTitle = summary.Title
|
|
detail.WorkFormats = summary.Formats
|
|
}
|
|
|
|
// 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 (s *DetailService) buildAudiobookExtension(
|
|
ctx context.Context,
|
|
item *models.MediaItem,
|
|
versions []FileVersion,
|
|
crew []CrewCredit,
|
|
filter AccessFilter,
|
|
) *AudiobookDetailExtension {
|
|
if item == nil {
|
|
return nil
|
|
}
|
|
totalDuration := 0
|
|
for _, version := range versions {
|
|
if version.Duration > 0 {
|
|
totalDuration += version.Duration
|
|
}
|
|
}
|
|
|
|
// The four related-content lookups are independent read-only queries;
|
|
// run them concurrently so detail latency is the slowest one, not their sum.
|
|
var (
|
|
series *AudiobookSeriesGroup
|
|
otherNarr []AudiobookNarration
|
|
alsoByAuthor []AudiobookRelatedItem
|
|
similar []AudiobookRelatedItem
|
|
wg sync.WaitGroup
|
|
)
|
|
wg.Add(4)
|
|
go func() { defer wg.Done(); series = s.fetchAudiobookSeries(ctx, item.ContentID, filter) }()
|
|
go func() { defer wg.Done(); otherNarr = s.fetchAudiobookOtherNarrations(ctx, item.ContentID, filter) }()
|
|
go func() { defer wg.Done(); alsoByAuthor = s.fetchAudiobookAlsoByAuthor(ctx, item.ContentID, filter) }()
|
|
go func() { defer wg.Done(); similar = s.fetchAudiobookSimilarByGenres(ctx, item.ContentID, filter) }()
|
|
wg.Wait()
|
|
|
|
return &AudiobookDetailExtension{
|
|
Authors: audiobookPeopleFromCrew(crew, models.PersonKindAuthor.String()),
|
|
Narrators: audiobookPeopleFromCrew(crew, models.PersonKindNarrator.String()),
|
|
Publisher: firstNonEmptyString(item.Studios),
|
|
TotalDurationSeconds: totalDuration,
|
|
Series: series,
|
|
OtherNarrations: otherNarr,
|
|
Related: AudiobookRelatedContent{
|
|
AlsoByAuthor: alsoByAuthor,
|
|
Similar: similar,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (s *DetailService) buildEbookExtension(
|
|
ctx context.Context,
|
|
item *models.MediaItem,
|
|
crew []CrewCredit,
|
|
filter AccessFilter,
|
|
) *EbookDetailExtension {
|
|
if item == nil {
|
|
return nil
|
|
}
|
|
// The three related-content lookups are independent read-only queries; run
|
|
// them concurrently so detail latency is the slowest one, not their sum
|
|
// (mirrors buildAudiobookExtension).
|
|
var (
|
|
series *AudiobookSeriesGroup
|
|
alsoByAuthor []AudiobookRelatedItem
|
|
similar []AudiobookRelatedItem
|
|
wg sync.WaitGroup
|
|
)
|
|
wg.Add(3)
|
|
go func() { defer wg.Done(); series = s.fetchEbookSeries(ctx, item.ContentID, filter) }()
|
|
go func() { defer wg.Done(); alsoByAuthor = s.fetchEbookAlsoByAuthor(ctx, item.ContentID, filter) }()
|
|
go func() { defer wg.Done(); similar = s.fetchEbookSimilarByGenres(ctx, item.ContentID, filter) }()
|
|
wg.Wait()
|
|
|
|
return &EbookDetailExtension{
|
|
Authors: audiobookPeopleFromCrew(crew, models.PersonKindAuthor.String()),
|
|
Publisher: firstNonEmptyString(item.Studios),
|
|
Series: series,
|
|
Related: AudiobookRelatedContent{
|
|
AlsoByAuthor: alsoByAuthor,
|
|
Similar: similar,
|
|
},
|
|
}
|
|
}
|
|
|
|
// buildMangaExtension assembles the manga-series detail payload by listing the
|
|
// series' chapters (ebook items linked via manga_chapters).
|
|
func (s *DetailService) buildMangaExtension(ctx context.Context, item *models.MediaItem, filter AccessFilter) *MangaDetailExtension {
|
|
if item == nil {
|
|
return nil
|
|
}
|
|
return &MangaDetailExtension{
|
|
Chapters: s.fetchMangaChapters(ctx, item.ContentID, filter),
|
|
}
|
|
}
|
|
|
|
// mangaChaptersQuery is the SQL listing a manga series' chapters in reading
|
|
// order. Chapters with a parsed index sort first (ascending); those without
|
|
// fall back to sort_title. Kept as a package var so the ordering contract can
|
|
// be asserted without a database.
|
|
//
|
|
// Manga chapters are ebook items, so per-chapter read state mirrors the ebook
|
|
// surfaces: a chapter is read when the current viewer's ebook_reader_progress
|
|
// row has progress >= the finished threshold. The LEFT JOIN is scoped by the
|
|
// viewer's user_id + profile_id ($2/$3) and yields false when no row exists.
|
|
var mangaChaptersQuery = fmt.Sprintf(`
|
|
SELECT m.content_id, m.title, mc.chapter_index, mc.volume,
|
|
COALESCE(erp.progress >= %s, false) AS read,
|
|
erp.progress::double precision,
|
|
COALESCE(m.poster_path, '') AS poster_path
|
|
FROM manga_chapters mc
|
|
JOIN media_items m ON m.content_id = mc.chapter_content_id
|
|
LEFT JOIN ebook_reader_progress erp
|
|
ON erp.content_id = mc.chapter_content_id
|
|
AND erp.user_id = $2
|
|
AND erp.profile_id = $3
|
|
WHERE mc.series_content_id = $1
|
|
ORDER BY mc.chapter_index NULLS LAST, m.sort_title
|
|
`, EbookFinishedProgressThresholdSQL)
|
|
|
|
// fetchMangaChapters returns the ordered chapters for a manga series. It never
|
|
// returns nil so the JSON payload always carries a (possibly empty) array. The
|
|
// access filter supplies the viewer (user_id/profile_id) used to resolve each
|
|
// chapter's per-viewer read state.
|
|
func (s *DetailService) fetchMangaChapters(ctx context.Context, seriesContentID string, filter AccessFilter) []MangaChapter {
|
|
chapters := make([]MangaChapter, 0, 16)
|
|
if s == nil || s.itemRepo == nil || s.itemRepo.pool == nil {
|
|
return chapters
|
|
}
|
|
rows, err := s.itemRepo.pool.Query(ctx, mangaChaptersQuery, seriesContentID, filter.UserID, filter.ProfileID)
|
|
if err != nil {
|
|
return chapters
|
|
}
|
|
defer rows.Close()
|
|
|
|
posterPaths := make([]string, 0, 16)
|
|
for rows.Next() {
|
|
var (
|
|
ch MangaChapter
|
|
index *float64
|
|
volume *string
|
|
progress *float64
|
|
posterPath string
|
|
)
|
|
if err := rows.Scan(&ch.ContentID, &ch.Title, &index, &volume, &ch.Read, &progress, &posterPath); err != nil {
|
|
return chapters
|
|
}
|
|
ch.ChapterIndex = index
|
|
if volume != nil {
|
|
ch.Volume = *volume
|
|
}
|
|
ch.Progress = progress
|
|
ch.PosterURL = posterPath // raw path; resolved in one batch below
|
|
if posterPath != "" {
|
|
posterPaths = append(posterPaths, posterPath)
|
|
}
|
|
chapters = append(chapters, ch)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
slog.WarnContext(ctx, "manga chapters: row iteration error", "component", "catalog", "series", seriesContentID, "error", err)
|
|
}
|
|
// Presign every chapter poster in one batch rather than per chapter — a
|
|
// long-running series has hundreds of chapters.
|
|
resolved := s.PresignImageURLs(ctx, posterPaths, "poster", "")
|
|
for i := range chapters {
|
|
chapters[i].PosterURL = resolved[chapters[i].PosterURL]
|
|
}
|
|
return chapters
|
|
}
|
|
|
|
func audiobookPeopleFromCrew(crew []CrewCredit, job string) []AudiobookPerson {
|
|
out := make([]AudiobookPerson, 0)
|
|
for _, credit := range crew {
|
|
if credit.Job != job || strings.TrimSpace(credit.Name) == "" {
|
|
continue
|
|
}
|
|
out = append(out, AudiobookPerson{
|
|
PersonID: credit.PersonID,
|
|
Name: credit.Name,
|
|
PhotoURL: credit.PhotoURL,
|
|
PhotoThumbhash: credit.PhotoThumbhash,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func firstNonEmptyString(values []string) string {
|
|
for _, value := range values {
|
|
if strings.TrimSpace(value) != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (s *DetailService) presignAudiobookPosterURL(ctx context.Context, posterPath string) string {
|
|
return s.PresignImageURL(ctx, posterPath, "poster", "")
|
|
}
|
|
|
|
func appendAudiobookItemAccessConditions(
|
|
alias string,
|
|
filter AccessFilter,
|
|
conditions *[]string,
|
|
args *[]any,
|
|
argIdx *int,
|
|
) bool {
|
|
if filter.AllowedLibraryIDs != nil {
|
|
if len(filter.AllowedLibraryIDs) == 0 {
|
|
return false
|
|
}
|
|
*conditions = append(*conditions, fmt.Sprintf(`
|
|
EXISTS (
|
|
SELECT 1 FROM media_item_libraries mil_allowed
|
|
WHERE mil_allowed.content_id = %s.content_id
|
|
AND mil_allowed.media_folder_id = ANY($%d)
|
|
)`, alias, *argIdx))
|
|
*args = append(*args, filter.AllowedLibraryIDs)
|
|
*argIdx = *argIdx + 1
|
|
}
|
|
if len(filter.DisabledLibraryIDs) > 0 {
|
|
if filter.AllowedLibraryIDs == nil {
|
|
*conditions = append(*conditions, fmt.Sprintf(`
|
|
EXISTS (
|
|
SELECT 1 FROM media_item_libraries mil_visible
|
|
WHERE mil_visible.content_id = %s.content_id
|
|
)`, alias))
|
|
}
|
|
*conditions = append(*conditions, fmt.Sprintf(`
|
|
NOT EXISTS (
|
|
SELECT 1 FROM media_item_libraries mil_disabled
|
|
WHERE mil_disabled.content_id = %s.content_id
|
|
AND mil_disabled.media_folder_id = ANY($%d)
|
|
)`, alias, *argIdx))
|
|
*args = append(*args, filter.DisabledLibraryIDs)
|
|
*argIdx = *argIdx + 1
|
|
}
|
|
ApplySectionAccessFilter(alias, AccessFilter{MaxContentRating: filter.MaxContentRating}, conditions, args, argIdx)
|
|
return true
|
|
}
|
|
|
|
func (s *DetailService) fetchAudiobookAlsoByAuthor(ctx context.Context, contentID string, filter AccessFilter) []AudiobookRelatedItem {
|
|
return s.fetchBookAlsoByAuthor(ctx, contentID, "audiobook", filter)
|
|
}
|
|
|
|
func (s *DetailService) fetchEbookAlsoByAuthor(ctx context.Context, contentID string, filter AccessFilter) []AudiobookRelatedItem {
|
|
return s.fetchBookAlsoByAuthor(ctx, contentID, "ebook", filter)
|
|
}
|
|
|
|
func (s *DetailService) fetchBookAlsoByAuthor(ctx context.Context, contentID string, mediaType string, filter AccessFilter) []AudiobookRelatedItem {
|
|
if s == nil || s.itemRepo == nil || s.itemRepo.pool == nil {
|
|
return []AudiobookRelatedItem{}
|
|
}
|
|
args := []any{contentID, models.PersonKindAuthor, mediaType}
|
|
argIdx := 4
|
|
conditions := []string{
|
|
"ip1.content_id = $1",
|
|
"ip1.kind = $2",
|
|
"m2.type = $3",
|
|
}
|
|
if !appendAudiobookItemAccessConditions("m2", filter, &conditions, &args, &argIdx) {
|
|
return []AudiobookRelatedItem{}
|
|
}
|
|
query := fmt.Sprintf(`
|
|
SELECT m2.content_id, m2.title, COALESCE(m2.year, 0), COALESCE(m2.poster_path, '')
|
|
FROM item_people ip1
|
|
JOIN item_people ip2
|
|
ON ip2.person_id = ip1.person_id
|
|
AND ip2.kind = ip1.kind
|
|
AND ip2.content_id <> ip1.content_id
|
|
JOIN media_items m2 ON m2.content_id = ip2.content_id
|
|
WHERE %s
|
|
ORDER BY m2.year DESC NULLS LAST, LOWER(m2.sort_title)
|
|
LIMIT 12
|
|
`, strings.Join(conditions, " AND "))
|
|
rows, err := s.itemRepo.pool.Query(ctx, query, args...)
|
|
if err != nil {
|
|
return []AudiobookRelatedItem{}
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := make([]AudiobookRelatedItem, 0, 12)
|
|
seen := make(map[string]struct{})
|
|
for rows.Next() {
|
|
var item AudiobookRelatedItem
|
|
var posterPath string
|
|
if err := rows.Scan(&item.ContentID, &item.Title, &item.Year, &posterPath); err != nil {
|
|
return []AudiobookRelatedItem{}
|
|
}
|
|
if _, ok := seen[item.ContentID]; ok {
|
|
continue
|
|
}
|
|
seen[item.ContentID] = struct{}{}
|
|
item.PosterURL = s.presignAudiobookPosterURL(ctx, posterPath)
|
|
out = append(out, item)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *DetailService) fetchAudiobookSimilarByGenres(ctx context.Context, contentID string, filter AccessFilter) []AudiobookRelatedItem {
|
|
return s.fetchBookSimilarByGenres(ctx, contentID, "audiobook", filter)
|
|
}
|
|
|
|
func (s *DetailService) fetchEbookSimilarByGenres(ctx context.Context, contentID string, filter AccessFilter) []AudiobookRelatedItem {
|
|
return s.fetchBookSimilarByGenres(ctx, contentID, "ebook", filter)
|
|
}
|
|
|
|
func (s *DetailService) fetchBookSimilarByGenres(ctx context.Context, contentID string, mediaType string, filter AccessFilter) []AudiobookRelatedItem {
|
|
if s == nil || s.itemRepo == nil || s.itemRepo.pool == nil {
|
|
return []AudiobookRelatedItem{}
|
|
}
|
|
args := []any{contentID, models.PersonKindAuthor, mediaType}
|
|
argIdx := 4
|
|
conditions := []string{
|
|
"m.type = $3",
|
|
"m.content_id <> $1",
|
|
"m.genres && (SELECT array_agg(g) FROM this_genres)",
|
|
`NOT EXISTS (
|
|
SELECT 1 FROM item_people ip
|
|
WHERE ip.content_id = m.content_id
|
|
AND ip.kind = $2
|
|
AND ip.person_id IN (SELECT person_id FROM this_author)
|
|
)`,
|
|
}
|
|
if !appendAudiobookItemAccessConditions("m", filter, &conditions, &args, &argIdx) {
|
|
return []AudiobookRelatedItem{}
|
|
}
|
|
query := fmt.Sprintf(`
|
|
WITH this_genres AS (
|
|
SELECT unnest(genres) AS g FROM media_items WHERE content_id = $1
|
|
),
|
|
this_author AS (
|
|
SELECT person_id FROM item_people WHERE content_id = $1 AND kind = $2
|
|
)
|
|
SELECT m.content_id, m.title, COALESCE(m.year, 0), COALESCE(m.poster_path, '')
|
|
FROM media_items m
|
|
WHERE %s
|
|
ORDER BY
|
|
(
|
|
SELECT COUNT(*)
|
|
FROM unnest(m.genres) AS genre(value)
|
|
WHERE genre.value IN (SELECT g FROM this_genres)
|
|
) DESC,
|
|
COALESCE(m.year, 0) DESC,
|
|
LOWER(m.sort_title)
|
|
LIMIT 12
|
|
`, strings.Join(conditions, " AND "))
|
|
rows, err := s.itemRepo.pool.Query(ctx, query, args...)
|
|
if err != nil {
|
|
return []AudiobookRelatedItem{}
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := make([]AudiobookRelatedItem, 0, 12)
|
|
for rows.Next() {
|
|
var item AudiobookRelatedItem
|
|
var posterPath string
|
|
if err := rows.Scan(&item.ContentID, &item.Title, &item.Year, &posterPath); err != nil {
|
|
return []AudiobookRelatedItem{}
|
|
}
|
|
item.PosterURL = s.presignAudiobookPosterURL(ctx, posterPath)
|
|
out = append(out, item)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *DetailService) fetchAudiobookSeries(ctx context.Context, contentID string, filter AccessFilter) *AudiobookSeriesGroup {
|
|
return s.fetchBookSeries(ctx, contentID, "audiobook", "audiobook_series", filter)
|
|
}
|
|
|
|
func (s *DetailService) fetchEbookSeries(ctx context.Context, contentID string, filter AccessFilter) *AudiobookSeriesGroup {
|
|
return s.fetchBookSeries(ctx, contentID, "ebook", "ebook_series", filter)
|
|
}
|
|
|
|
func (s *DetailService) fetchBookSeries(ctx context.Context, contentID string, mediaType string, tableName string, filter AccessFilter) *AudiobookSeriesGroup {
|
|
if s == nil || s.itemRepo == nil || s.itemRepo.pool == nil {
|
|
return nil
|
|
}
|
|
switch tableName {
|
|
case "audiobook_series", "ebook_series":
|
|
default:
|
|
return nil
|
|
}
|
|
args := []any{contentID, mediaType}
|
|
argIdx := 3
|
|
conditions := []string{
|
|
"root.content_id = $1",
|
|
"m.type = $2",
|
|
}
|
|
if !appendAudiobookItemAccessConditions("m", filter, &conditions, &args, &argIdx) {
|
|
return nil
|
|
}
|
|
query := fmt.Sprintf(`
|
|
SELECT
|
|
m.content_id,
|
|
m.title,
|
|
COALESCE(m.year, 0),
|
|
COALESCE(m.poster_path, ''),
|
|
s.series_name,
|
|
s.series_index
|
|
FROM %s root
|
|
JOIN %s s ON LOWER(s.series_name) = LOWER(root.series_name)
|
|
JOIN media_items m ON m.content_id = s.content_id
|
|
WHERE %s
|
|
ORDER BY s.series_index NULLS LAST, LOWER(m.sort_title)
|
|
LIMIT 30
|
|
`, tableName, tableName, strings.Join(conditions, " AND "))
|
|
rows, err := s.itemRepo.pool.Query(ctx, query, args...)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defer rows.Close()
|
|
|
|
var seriesName string
|
|
entries := make([]AudiobookRelatedItem, 0, 16)
|
|
for rows.Next() {
|
|
var (
|
|
item AudiobookRelatedItem
|
|
poster string
|
|
name string
|
|
index *float64
|
|
)
|
|
if err := rows.Scan(&item.ContentID, &item.Title, &item.Year, &poster, &name, &index); err != nil {
|
|
return nil
|
|
}
|
|
if seriesName == "" {
|
|
seriesName = name
|
|
}
|
|
if index != nil {
|
|
n := int(*index)
|
|
if float64(n) == *index {
|
|
item.SeriesIndex = &n
|
|
}
|
|
}
|
|
item.PosterURL = s.presignAudiobookPosterURL(ctx, poster)
|
|
entries = append(entries, item)
|
|
}
|
|
if len(entries) < 2 {
|
|
return nil
|
|
}
|
|
return &AudiobookSeriesGroup{Name: seriesName, Entries: entries}
|
|
}
|
|
|
|
func (s *DetailService) fetchAudiobookOtherNarrations(ctx context.Context, contentID string, filter AccessFilter) []AudiobookNarration {
|
|
if s == nil || s.itemRepo == nil || s.itemRepo.pool == nil {
|
|
return []AudiobookNarration{}
|
|
}
|
|
const stripRE = `\s*\(?\s*[-:,]?\s*(UK Version:?|US Version:?)?\s*[Rr]ead [Bb]y [^()]+\)?\s*$`
|
|
args := []any{stripRE, contentID, models.PersonKindAuthor, models.PersonKindNarrator}
|
|
argIdx := 5
|
|
conditions := []string{
|
|
"m.type = 'audiobook'",
|
|
"m.content_id <> $2",
|
|
"trim(regexp_replace(m.title, $1, '', 'g')) = (SELECT norm_title FROM this_book)",
|
|
`EXISTS (
|
|
SELECT 1 FROM item_people ip
|
|
WHERE ip.content_id = m.content_id
|
|
AND ip.kind = $3
|
|
AND ip.person_id IN (SELECT person_id FROM this_author)
|
|
)`,
|
|
}
|
|
if !appendAudiobookItemAccessConditions("m", filter, &conditions, &args, &argIdx) {
|
|
return []AudiobookNarration{}
|
|
}
|
|
query := fmt.Sprintf(`
|
|
WITH this_book AS (
|
|
SELECT trim(regexp_replace(title, $1, '', 'g')) AS norm_title
|
|
FROM media_items WHERE content_id = $2
|
|
),
|
|
this_author AS (
|
|
SELECT person_id FROM item_people WHERE content_id = $2 AND kind = $3
|
|
)
|
|
SELECT
|
|
m.content_id,
|
|
m.title,
|
|
COALESCE(m.year, 0),
|
|
COALESCE(string_agg(DISTINCT NULLIF(BTRIM(p.name), ''), '|||'), '') AS narrators
|
|
FROM media_items m
|
|
LEFT JOIN item_people ipn ON ipn.content_id = m.content_id AND ipn.kind = $4
|
|
LEFT JOIN people p ON p.id = ipn.person_id
|
|
WHERE %s
|
|
GROUP BY m.content_id, m.title, m.year
|
|
ORDER BY m.year DESC NULLS LAST, m.title
|
|
LIMIT 8
|
|
`, strings.Join(conditions, " AND "))
|
|
rows, err := s.itemRepo.pool.Query(ctx, query, args...)
|
|
if err != nil {
|
|
return []AudiobookNarration{}
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := make([]AudiobookNarration, 0, 4)
|
|
for rows.Next() {
|
|
var item AudiobookNarration
|
|
var narrators string
|
|
if err := rows.Scan(&item.ContentID, &item.Title, &item.Year, &narrators); err != nil {
|
|
return []AudiobookNarration{}
|
|
}
|
|
item.Narrators = splitDelimitedNames(narrators)
|
|
out = append(out, item)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func splitDelimitedNames(raw string) []string {
|
|
if strings.TrimSpace(raw) == "" {
|
|
return []string{}
|
|
}
|
|
parts := strings.Split(raw, "|||")
|
|
out := make([]string, 0, len(parts))
|
|
for _, part := range parts {
|
|
if value := strings.TrimSpace(part); value != "" {
|
|
out = append(out, value)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
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) {
|
|
series, err := s.itemRepo.GetByID(ctx, season.SeriesID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loading parent series: %w", err)
|
|
}
|
|
localizationFilter := filter
|
|
localizationFilter.PresentationOriginalLanguage = series.OriginalLanguage
|
|
pendingTranslation := s.PendingSeasonTranslationLanguage(ctx, season, localizationFilter)
|
|
localizedSeason, err := s.LocalizeSeasonModel(ctx, season, localizationFilter)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("localizing season detail: %w", err)
|
|
}
|
|
season = localizedSeason
|
|
series, err = s.LocalizeItemModel(ctx, series, localizationFilter)
|
|
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,
|
|
PendingTranslationLanguage: pendingTranslation,
|
|
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) {
|
|
localizationFilter := filter
|
|
localizationFilter.PresentationOriginalLanguage = seriesCtx.series.OriginalLanguage
|
|
pendingTranslation := s.PendingEpisodeTranslationLanguage(ctx, episode, localizationFilter)
|
|
localizedEpisode, err := s.LocalizeEpisodeModel(ctx, episode, localizationFilter)
|
|
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,
|
|
PendingTranslationLanguage: pendingTranslation,
|
|
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)
|
|
s.effectiveSubtitleDefaults(ctx, filter, episode.SeriesID, files).applyToItemDetail(detail)
|
|
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
|
|
s.effectiveSubtitleDefaults(ctx, filter, item.ContentID, files).applyToWatchDetail(detail)
|
|
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 {
|
|
// Third fallback tier: a local extra (media_extras). Access is the
|
|
// parent item's; playback reuses the ordinary file/versions pipeline.
|
|
if errors.Is(err, ErrEpisodeNotFound) {
|
|
return s.buildExtraWatchDetail(ctx, contentID, filter)
|
|
}
|
|
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
|
|
}
|
|
// Seasons and episodes inherit original_language from the parent series.
|
|
// Load it before localization and reuse the same row for SeriesTitle below,
|
|
// avoiding an extra lookup only for profiles that use language exceptions.
|
|
series, seriesErr := s.itemRepo.GetByID(ctx, episode.SeriesID)
|
|
localizationFilter := filter
|
|
if seriesErr == nil && series != nil {
|
|
localizationFilter.PresentationOriginalLanguage = series.OriginalLanguage
|
|
}
|
|
episode, err = s.LocalizeEpisodeModel(ctx, episode, localizationFilter)
|
|
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
|
|
s.effectiveSubtitleDefaults(ctx, filter, episode.SeriesID, files).applyToWatchDetail(detail)
|
|
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 seriesErr == nil && series != nil {
|
|
series, err = s.LocalizeItemModel(ctx, series, localizationFilter)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("localizing series watch detail: %w", err)
|
|
}
|
|
detail.SeriesTitle = series.Title
|
|
detail.Year = series.Year
|
|
}
|
|
|
|
return detail, nil
|
|
}
|
|
|
|
// buildExtraWatchDetail resolves a local extra (media_extras) as a watch
|
|
// target. Access control is the parent item's library membership, mirroring
|
|
// how episodes gate on their series.
|
|
func (s *DetailService) buildExtraWatchDetail(ctx context.Context, contentID string, filter AccessFilter) (*WatchDetail, error) {
|
|
if s.extraRepo == nil {
|
|
return nil, ErrItemNotFound
|
|
}
|
|
extra, err := s.extraRepo.GetByID(ctx, contentID)
|
|
if err != nil {
|
|
if errors.Is(err, ErrExtraNotFound) {
|
|
return nil, ErrItemNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
if err := s.itemRepo.EnsureAccessible(ctx, extra.ParentID, filter); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.validatePresentationItemAccess(ctx, filter, extra.ParentID); err != nil {
|
|
return nil, err
|
|
}
|
|
fetcher, ok := s.fileFetcher.(extraFileFetcher)
|
|
if !ok {
|
|
return nil, ErrItemNotFound
|
|
}
|
|
files, err := fetcher.GetByExtraID(ctx, extra.ContentID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetching extra watch files: %w", err)
|
|
}
|
|
files = FilterMediaFilesByAccess(files, filter)
|
|
files = s.preparePlaybackFiles(ctx, files)
|
|
s.queueWatchPlaybackFiles(ctx, extra.ContentID, "extra", files)
|
|
detail := s.newWatchDetail(
|
|
ctx,
|
|
extra.ContentID,
|
|
"extra",
|
|
extra.Title,
|
|
"",
|
|
files,
|
|
filter,
|
|
extra.ParentID,
|
|
)
|
|
if parent, parentErr := s.itemRepo.GetByID(ctx, extra.ParentID); parentErr == nil {
|
|
if localized, locErr := s.LocalizeItemModel(ctx, parent, filter); locErr == nil {
|
|
parent = localized
|
|
}
|
|
if detail.Title == "" {
|
|
detail.Title = parent.Title
|
|
}
|
|
// Series fields only for series-owned extras: players treat a
|
|
// populated SeriesID as episodic context (post-roll/next-episode
|
|
// flows), which is wrong for a movie's extras.
|
|
if parent.Type == "series" {
|
|
detail.SeriesID = extra.ParentID
|
|
detail.SeriesTitle = parent.Title
|
|
}
|
|
detail.Year = parent.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,
|
|
}
|
|
}
|
|
|
|
// effectiveSubtitleDefaults resolves the subtitle preferences that apply to one
|
|
// item, through the canonical resolver.
|
|
//
|
|
// This used to be four levels of hand-written precedence — profile columns,
|
|
// then a library preference row, then a series preference row, each partially
|
|
// overriding the last through Has* flags. The order lives in the manifest now
|
|
// (profile_series, profile_library, profile_device, profile, default), so this
|
|
// function and the contract cannot disagree about which override wins, and a
|
|
// new scope is a manifest change rather than another branch here.
|
|
//
|
|
// The track signature stays on its specialized table: it identifies a concrete
|
|
// track rather than expressing a preference, so it is not a setting.
|
|
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
|
|
}
|
|
|
|
rc := settingsresolve.Context{ProfileID: filter.ProfileID}
|
|
if libraryID := preferredPlayableLibraryID(files, filter.SelectedFileID); libraryID > 0 {
|
|
rc.LibraryIDs = []int{libraryID}
|
|
}
|
|
if seriesID != "" {
|
|
rc.SeriesIDs = []string{seriesID}
|
|
}
|
|
|
|
resolved, err := s.settingsResolver().Resolve(ctx, store, rc, []string{
|
|
settingskeys.PlaybackSubtitleLanguage,
|
|
settingskeys.PlaybackSubtitleMode,
|
|
settingskeys.PlaybackShowForcedSubtitles,
|
|
}, nil)
|
|
if err == nil {
|
|
for _, eff := range resolved {
|
|
// A value that resolved to the contract default is not a stored
|
|
// preference, and the callers distinguish the two through the Has*
|
|
// flags: an unset language must not read as "the user chose empty".
|
|
stored := eff.Source != settingscontract.ScopeDefault
|
|
switch eff.Key {
|
|
case settingskeys.PlaybackSubtitleLanguage:
|
|
var language string
|
|
if json.Unmarshal(eff.Value, &language) == nil && stored {
|
|
defaults.Language = language
|
|
defaults.HasLanguage = true
|
|
}
|
|
case settingskeys.PlaybackSubtitleMode:
|
|
var mode string
|
|
if json.Unmarshal(eff.Value, &mode) == nil && stored {
|
|
defaults.Mode = mode
|
|
defaults.HasMode = true
|
|
}
|
|
case settingskeys.PlaybackShowForcedSubtitles:
|
|
var forced bool
|
|
if json.Unmarshal(eff.Value, &forced) == nil && stored {
|
|
defaults.ShowForced = forced
|
|
defaults.HasShowForced = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if seriesID != "" {
|
|
if pref, err := store.GetSubtitlePreference(ctx, filter.ProfileID, seriesID); err == nil && pref != nil {
|
|
if pref.TrackSignature != nil && !pref.TrackSignature.IsZero() {
|
|
defaults.TrackSignature = pref.TrackSignature
|
|
}
|
|
}
|
|
}
|
|
|
|
return defaults
|
|
}
|
|
|
|
// settingsResolver lazily builds the resolver over the embedded contract.
|
|
//
|
|
// The contract is validated at startup, so a load failure here is unreachable;
|
|
// returning a resolver with no contract makes Resolve error rather than panic,
|
|
// which degrades this to "no stored preferences" instead of failing the detail
|
|
// request.
|
|
func (s *DetailService) settingsResolver() *settingsresolve.Resolver {
|
|
s.resolverOnce.Do(func() {
|
|
contract, err := settingscontract.Load()
|
|
if err != nil {
|
|
return
|
|
}
|
|
s.resolver = settingsresolve.New(contract)
|
|
})
|
|
return s.resolver
|
|
}
|
|
|
|
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 ""
|
|
}
|
|
|
|
// audioPrefResolver memoizes the user-store lookups behind
|
|
// effectiveAudioSelection that are invariant across the files of a single
|
|
// detail request — the user store, the profile, the item's audio preference,
|
|
// and the resolved original language — plus per-library playback preferences.
|
|
// A multi-track item (e.g. a several-hundred-file audiobook) thus issues each
|
|
// of those queries once per request instead of once per file.
|
|
type audioPrefResolver struct {
|
|
svc *DetailService
|
|
store userstore.UserStore
|
|
valid bool
|
|
profileID string
|
|
contentID string
|
|
|
|
profileDone bool
|
|
profileLang string
|
|
|
|
audioDone bool
|
|
audioPref *playback.AudioTrackPreference
|
|
|
|
originalDone bool
|
|
originalLang string
|
|
|
|
resolvedLang map[int]string
|
|
}
|
|
|
|
func (r *audioPrefResolver) profileLanguage(ctx context.Context) string {
|
|
if !r.profileDone {
|
|
r.profileDone = true
|
|
r.profileLang = r.svc.resolvedAudioLanguage(ctx, r.store, settingsresolve.Context{
|
|
ProfileID: r.profileID,
|
|
})
|
|
}
|
|
return r.profileLang
|
|
}
|
|
|
|
// newAudioPrefResolver resolves the per-user store once and prepares the
|
|
// memoization caches. The returned resolver is not safe for concurrent use; it
|
|
// is intended to be threaded through a single sequential file loop.
|
|
func (s *DetailService) newAudioPrefResolver(ctx context.Context, filter AccessFilter, audioPreferenceContentID string) *audioPrefResolver {
|
|
r := &audioPrefResolver{
|
|
svc: s,
|
|
profileID: filter.ProfileID,
|
|
contentID: audioPreferenceContentID,
|
|
resolvedLang: map[int]string{},
|
|
}
|
|
if s.userStoreProvider == nil || filter.UserID == 0 || filter.ProfileID == "" {
|
|
return r
|
|
}
|
|
store, err := s.userStoreProvider.ForUser(ctx, filter.UserID)
|
|
if err != nil || store == nil {
|
|
return r
|
|
}
|
|
r.store = store
|
|
r.valid = true
|
|
return r
|
|
}
|
|
|
|
// audioPreference returns a fresh copy of the item's stored audio preference
|
|
// (or nil). A copy is returned each call so the caller can resolve the
|
|
// original-language sentinel in place without mutating the memoized value.
|
|
func (r *audioPrefResolver) audioPreference(ctx context.Context) *playback.AudioTrackPreference {
|
|
if !r.audioDone {
|
|
r.audioDone = true
|
|
if strings.TrimSpace(r.contentID) != "" {
|
|
if pref, prefErr := r.store.GetAudioPreference(ctx, r.profileID, r.contentID); prefErr == nil && pref != nil {
|
|
r.audioPref = &playback.AudioTrackPreference{
|
|
AudioTrackIndex: pref.AudioTrackIndex,
|
|
AudioLanguage: pref.AudioLanguage,
|
|
TrackSignature: pref.TrackSignature,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if r.audioPref == nil {
|
|
return nil
|
|
}
|
|
cp := *r.audioPref
|
|
return &cp
|
|
}
|
|
|
|
// audioLanguage resolves with every content identity in context. The language
|
|
// preference lives in the canonical table at profile_series/profile_library/
|
|
// profile scopes; the specialized audio row supplies only concrete track
|
|
// identity. Caching by library keeps a multi-file item at one canonical read
|
|
// per distinct folder rather than one read per file.
|
|
func (r *audioPrefResolver) audioLanguage(ctx context.Context, libraryID int) string {
|
|
if lang, ok := r.resolvedLang[libraryID]; ok {
|
|
return lang
|
|
}
|
|
rc := settingsresolve.Context{
|
|
ProfileID: r.profileID,
|
|
LibraryIDs: []int{libraryID},
|
|
}
|
|
if strings.TrimSpace(r.contentID) != "" {
|
|
rc.SeriesIDs = []string{r.contentID}
|
|
}
|
|
lang := r.svc.resolvedAudioLanguage(ctx, r.store, rc)
|
|
r.resolvedLang[libraryID] = lang
|
|
return lang
|
|
}
|
|
|
|
// resolvedAudioLanguage returns the effective playback.audio_language for one
|
|
// context, or "" when nothing is stored.
|
|
func (s *DetailService) resolvedAudioLanguage(
|
|
ctx context.Context, store userstore.UserStore, rc settingsresolve.Context,
|
|
) string {
|
|
resolved, err := s.settingsResolver().Resolve(ctx, store, rc,
|
|
[]string{settingskeys.PlaybackAudioLanguage}, nil)
|
|
if err != nil || len(resolved) == 0 {
|
|
return ""
|
|
}
|
|
// The contract default is null, which means "no preference" — the caller
|
|
// treats "" the same way, so an unset language needs no special case.
|
|
var language string
|
|
if json.Unmarshal(resolved[0].Value, &language) != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(language)
|
|
}
|
|
|
|
func (r *audioPrefResolver) originalLanguage(ctx context.Context) string {
|
|
if !r.originalDone {
|
|
r.originalDone = true
|
|
r.originalLang = r.svc.resolveOriginalLanguage(ctx, r.contentID)
|
|
}
|
|
return r.originalLang
|
|
}
|
|
|
|
func (s *DetailService) effectiveAudioSelection(
|
|
ctx context.Context,
|
|
filter AccessFilter,
|
|
audioPreferenceContentID string,
|
|
file *models.MediaFile,
|
|
) effectiveAudioSelection {
|
|
return s.effectiveAudioSelectionWith(ctx, s.newAudioPrefResolver(ctx, filter, audioPreferenceContentID), file)
|
|
}
|
|
|
|
// effectiveAudioSelectionWith computes the selection for one file using a
|
|
// resolver whose request-invariant lookups are shared across every file.
|
|
func (s *DetailService) effectiveAudioSelectionWith(
|
|
ctx context.Context,
|
|
r *audioPrefResolver,
|
|
file *models.MediaFile,
|
|
) effectiveAudioSelection {
|
|
if file == nil || len(file.AudioTracks) == 0 {
|
|
return effectiveAudioSelection{}
|
|
}
|
|
if r == nil || !r.valid {
|
|
index := playback.SelectAudioTrack(file.AudioTracks, "", nil)
|
|
return effectiveAudioSelection{
|
|
Index: index,
|
|
Language: resolveSelectedAudioLanguage(file, index, "", false),
|
|
}
|
|
}
|
|
|
|
seriesPref := r.audioPreference(ctx)
|
|
preferredLang := r.audioLanguage(ctx, file.MediaFolderID)
|
|
|
|
originalLanguage := ""
|
|
resolveOriginalLanguage := func() string {
|
|
if originalLanguage == "" {
|
|
originalLanguage = r.originalLanguage(ctx)
|
|
}
|
|
return originalLanguage
|
|
}
|
|
|
|
usesOriginal := preferredLang == playback.OriginalLanguageSentinel
|
|
if usesOriginal {
|
|
preferredLang = resolveOriginalLanguage()
|
|
if preferredLang == "" {
|
|
// "original" used to fall through to the roaming profile choice
|
|
// when the item's original language could not be resolved. Keep that
|
|
// failure behavior while moving the content-scoped read to canonical
|
|
// storage.
|
|
preferredLang = r.profileLanguage(ctx)
|
|
if preferredLang == playback.OriginalLanguageSentinel {
|
|
preferredLang = resolveOriginalLanguage()
|
|
}
|
|
}
|
|
}
|
|
if seriesPref != nil {
|
|
// The signature/index remain the concrete selection. Language comes
|
|
// from canonical resolution so a stale legacy language cannot outrank a
|
|
// profile_series write made through /settings/values.
|
|
seriesPref.AudioLanguage = preferredLang
|
|
}
|
|
|
|
index := playback.SelectAudioTrack(file.AudioTracks, preferredLang, seriesPref)
|
|
return effectiveAudioSelection{
|
|
Index: index,
|
|
Language: resolveSelectedAudioLanguage(file, index, originalLanguage, usesOriginal),
|
|
}
|
|
}
|
|
|
|
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 sortAudiobookMediaFiles(files []*models.MediaFile) {
|
|
sort.SliceStable(files, func(i, j int) bool {
|
|
a, b := files[i], files[j]
|
|
if a == nil || b == nil {
|
|
return a != nil
|
|
}
|
|
if a.PresentationPartIndex > 0 || b.PresentationPartIndex > 0 {
|
|
if a.PresentationPartIndex != b.PresentationPartIndex {
|
|
if a.PresentationPartIndex == 0 {
|
|
return false
|
|
}
|
|
if b.PresentationPartIndex == 0 {
|
|
return true
|
|
}
|
|
return a.PresentationPartIndex < b.PresentationPartIndex
|
|
}
|
|
}
|
|
if a.FilePath != b.FilePath {
|
|
return strings.Compare(a.FilePath, b.FilePath) < 0
|
|
}
|
|
return a.ID < b.ID
|
|
})
|
|
}
|
|
|
|
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
|
|
|
|
// Resolve the request-invariant audio preferences once; a multi-track item
|
|
// would otherwise re-query the profile/preference rows for every file.
|
|
audioResolver := s.newAudioPrefResolver(ctx, filter, audioPreferenceContentID)
|
|
|
|
for _, f := range files {
|
|
if f == nil {
|
|
continue
|
|
}
|
|
effectiveAudioSelection := s.effectiveAudioSelectionWith(ctx, audioResolver, 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,
|
|
PresentationPartTotal: f.PresentationPartTotal,
|
|
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.InfoContext(ctx,
|
|
"queueing chapter thumbnails", "component", "catalog",
|
|
"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))
|
|
nextExternalIndex := len(file.VideoTracks) + len(file.AudioTracks)
|
|
for i, 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,
|
|
})
|
|
|
|
index := sub.Index
|
|
if index <= 0 {
|
|
index = len(file.VideoTracks) + len(file.AudioTracks) + i
|
|
}
|
|
if index >= nextExternalIndex {
|
|
nextExternalIndex = index + 1
|
|
}
|
|
}
|
|
// Zero means "use positional fallback" to downstream consumers and is
|
|
// omitted from JSON, so external tracks always receive positive indexes.
|
|
if nextExternalIndex < 1 {
|
|
nextExternalIndex = 1
|
|
}
|
|
for i, sub := range file.ExternalSubtitles {
|
|
tracks = append(tracks, VersionSubtitleTrack{
|
|
Index: nextExternalIndex + i,
|
|
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.WarnContext(ctx, "image path could not be resolved", "component", "catalog", "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
|
|
}
|
|
return artworkkey.Variant(path, variant)
|
|
}
|
|
|
|
// imageTypeFromCachedPath returns the image type segment ("poster", "backdrop",
|
|
// "logo", "still") encoded in a cached S3 image path of the form
|
|
// ".../{imageType}/{variant}.{ext}". It returns "" for full URLs,
|
|
// plugin-prefixed paths, or paths with no directory segment.
|
|
func imageTypeFromCachedPath(path string) string {
|
|
if path == "" || strings.Contains(path, "://") {
|
|
return ""
|
|
}
|
|
lastSlash := strings.LastIndex(path, "/")
|
|
if lastSlash <= 0 {
|
|
return ""
|
|
}
|
|
dir := path[:lastSlash]
|
|
return dir[strings.LastIndex(dir, "/")+1:]
|
|
}
|
|
|
|
// BackdropVariantPath rewrites a cached "/original." image path to the
|
|
// requested backdrop variant (e.g. "w1280" or "w1920"). Episode "backdrops"
|
|
// are frequently the episode still, which the cache only generates at
|
|
// w500/w300 — so requesting a backdrop width 404s. For still/poster/logo
|
|
// paths this clamps to that type's largest cached variant instead. Full URLs,
|
|
// plugin-prefixed paths, and non-"/original." paths pass through unchanged.
|
|
func BackdropVariantPath(path, desiredVariant string) string {
|
|
if path == "" || strings.Contains(path, "://") || !strings.Contains(path, "/original.") {
|
|
return path
|
|
}
|
|
variant := desiredVariant
|
|
switch imageType := imageTypeFromCachedPath(path); imageType {
|
|
case "still", "poster", "logo":
|
|
variant = cachedImageVariantKey(imageType, "")
|
|
}
|
|
if variant == "" {
|
|
return path
|
|
}
|
|
return strings.Replace(path, "/original.", "/"+variant+".", 1)
|
|
}
|
|
|
|
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"
|
|
}
|
|
}
|