Files
2026-04-24 10:45:19 -07:00

338 lines
9.4 KiB
Go

package service
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"path/filepath"
"strings"
"time"
"github.com/TopherMayor/unified-media-manager/internal/db"
"github.com/jackc/pgx/v5"
)
// FullMediaDetail is the comprehensive detail response for the MediaDetail page.
type FullMediaDetail struct {
Media MediaDetail `json:"media"`
QualityProfile *QualityProfileInfo `json:"quality_profile,omitempty"`
FilesWithSubs []FileWithSubtitles `json:"files_with_subtitles"`
Episodes []EpisodeInfo `json:"episodes,omitempty"`
History []MediaHistoryItem `json:"history"`
}
// QualityProfileInfo contains the quality profile data for the detail response.
type QualityProfileInfo struct {
ID int64 `json:"id"`
Name string `json:"name"`
CutoffQuality json.RawMessage `json:"cutoff_quality"`
AllowedQualities json.RawMessage `json:"allowed_qualities"`
}
// FileWithSubtitles extends MediaFile with associated subtitle information.
type FileWithSubtitles struct {
MediaFile
Subtitles []SubtitleInfo `json:"subtitles,omitempty"`
}
// SubtitleInfo represents a subtitle file associated with a media file.
type SubtitleInfo struct {
FileName string `json:"file_name"`
Language string `json:"language"`
LanguageCode string `json:"language_code"`
HI bool `json:"hi"`
Forced bool `json:"forced"`
Source string `json:"source"`
}
// EpisodeInfo represents a single episode within a series.
type EpisodeInfo struct {
MediaID int64 `json:"media_id"`
Title string `json:"title"`
Season int `json:"season"`
Episode int `json:"episode"`
Status string `json:"status"`
Monitored bool `json:"monitored"`
AirDate *string `json:"air_date,omitempty"`
HasFile bool `json:"has_file"`
Quality json.RawMessage `json:"quality,omitempty"`
}
// MediaHistoryItem represents an activity event in the media detail history.
type MediaHistoryItem struct {
ID int64 `json:"id"`
EventType string `json:"event_type"`
Title string `json:"title"`
Description *string `json:"description,omitempty"`
Data json.RawMessage `json:"data"`
CreatedAt time.Time `json:"created_at"`
}
// MediaDetailService aggregates data for the media detail page using pgx.Batch.
type MediaDetailService struct {
db *db.DB
mediaSvc *MediaService
activitySvc *ActivityService
}
// NewMediaDetailService creates a new MediaDetailService.
func NewMediaDetailService(database *db.DB, mediaSvc *MediaService, activitySvc *ActivityService) *MediaDetailService {
return &MediaDetailService{db: database, mediaSvc: mediaSvc, activitySvc: activitySvc}
}
// GetFullDetail returns the complete media detail using pgx.Batch for parallel queries.
func (s *MediaDetailService) GetFullDetail(ctx context.Context, id int64, mediaType string) (*FullMediaDetail, error) {
// Step 1: Get base media detail via existing service
baseDetail, err := s.mediaSvc.GetByID(ctx, id, mediaType)
if err != nil {
return nil, fmt.Errorf("get media detail: %w", err)
}
result := &FullMediaDetail{
Media: *baseDetail,
}
// Step 2: Build a pgx.Batch with queries
batch := &pgx.Batch{}
hasQualityProfile := baseDetail.Media.QualityProfileID != nil
hasEpisodes := mediaType == "series"
// Query: Quality profile
if hasQualityProfile {
batch.Queue(
"SELECT id, name, cutoff_quality, allowed_qualities FROM quality_profiles WHERE id = $1",
*baseDetail.Media.QualityProfileID,
)
}
// Query: Activity history
batch.Queue(
"SELECT id, event_type, title, description, data, created_at FROM activity_events WHERE media_id = $1 ORDER BY created_at DESC LIMIT 100",
id,
)
// Query: Episode children (series only)
if hasEpisodes {
batch.Queue(
`SELECT m.id, m.title, mr.season, mr.position, m.status, m.monitored,
EXISTS(SELECT 1 FROM media_files mf WHERE mf.media_id = m.id AND mf.deleted_at IS NULL) as has_file,
mf.quality
FROM media m
JOIN media_relations mr ON mr.child_id = m.id
LEFT JOIN LATERAL (SELECT quality FROM media_files WHERE media_id = m.id AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1) mf ON true
WHERE mr.parent_id = $1 AND mr.relation = 'episode'
ORDER BY mr.season, mr.position`,
id,
)
}
// Step 3: Send batch
batchResults := s.db.Pool.SendBatch(ctx, batch)
defer batchResults.Close()
// Step 4: Read results in the same order they were queued
// Read quality profile
if hasQualityProfile {
row := batchResults.QueryRow()
var qp QualityProfileInfo
if err := row.Scan(&qp.ID, &qp.Name, &qp.CutoffQuality, &qp.AllowedQualities); err == nil {
result.QualityProfile = &qp
}
}
// Read activity history
historyRows, err := batchResults.Query()
if err != nil {
slog.Error("read activity history from batch", "error", err)
} else {
defer historyRows.Close()
for historyRows.Next() {
var item MediaHistoryItem
var description sql.NullString
var data []byte
if err := historyRows.Scan(&item.ID, &item.EventType, &item.Title, &description, &data, &item.CreatedAt); err != nil {
slog.Error("scan history item", "error", err)
continue
}
if description.Valid {
item.Description = &description.String
}
if data != nil {
item.Data = json.RawMessage(data)
}
result.History = append(result.History, item)
}
}
// Read episodes (series only)
if hasEpisodes {
epRows, err := batchResults.Query()
if err != nil {
slog.Error("read episodes from batch", "error", err)
} else {
defer epRows.Close()
for epRows.Next() {
var ep EpisodeInfo
var season, position sql.NullInt64
var airDate sql.NullString
var quality []byte
if err := epRows.Scan(&ep.MediaID, &ep.Title, &season, &position, &ep.Status, &ep.Monitored, &ep.HasFile, &quality); err != nil {
slog.Error("scan episode", "error", err)
continue
}
if season.Valid {
ep.Season = int(season.Int64)
}
if position.Valid {
ep.Episode = int(position.Int64)
}
if airDate.Valid {
ep.AirDate = &airDate.String
}
if quality != nil {
ep.Quality = json.RawMessage(quality)
}
result.Episodes = append(result.Episodes, ep)
}
}
}
// Step 5: Build subtitle info for each media file (from DB cache)
result.FilesWithSubs = s.buildFilesWithSubtitlesFromDB(ctx, baseDetail.Files)
return result, nil
}
func (s *MediaDetailService) buildFilesWithSubtitlesFromDB(ctx context.Context, files []MediaFile) []FileWithSubtitles {
if len(files) == 0 {
return []FileWithSubtitles{}
}
fileIDs := make([]interface{}, len(files))
for i, f := range files {
fileIDs[i] = f.ID
}
subMap := make(map[int64][]SubtitleInfo)
if len(fileIDs) > 0 {
rows, err := s.db.Pool.Query(ctx,
`SELECT media_file_id, file_name, language, language_code, hi, forced, source
FROM media_subtitles WHERE media_file_id = ANY($1)`, fileIDs)
if err == nil {
defer rows.Close()
for rows.Next() {
var fileID int64
var sub SubtitleInfo
if err := rows.Scan(&fileID, &sub.FileName, &sub.Language, &sub.LanguageCode, &sub.HI, &sub.Forced, &sub.Source); err == nil {
subMap[fileID] = append(subMap[fileID], sub)
}
}
}
}
result := make([]FileWithSubtitles, len(files))
for i, f := range files {
subs := subMap[f.ID]
if subs == nil {
subs = scanSubtitleFiles(f)
}
result[i] = FileWithSubtitles{
MediaFile: f,
Subtitles: subs,
}
}
return result
}
// buildFilesWithSubtitles creates FileWithSubtitles entries with scanned subtitle files.
func buildFilesWithSubtitles(files []MediaFile) []FileWithSubtitles {
if len(files) == 0 {
return []FileWithSubtitles{}
}
result := make([]FileWithSubtitles, len(files))
for i, f := range files {
subs := scanSubtitleFiles(f)
result[i] = FileWithSubtitles{
MediaFile: f,
Subtitles: subs,
}
}
return result
}
// scanSubtitleFiles looks for .srt sidecar files next to the media file.
func scanSubtitleFiles(f MediaFile) []SubtitleInfo {
if f.Path == "" {
return nil
}
dir := filepath.Dir(f.Path)
base := strings.TrimSuffix(f.FileName, filepath.Ext(f.FileName))
pattern := filepath.Join(filepath.Clean(dir), base+"*.srt")
matches, err := filepath.Glob(pattern)
if err != nil {
return nil
}
var subs []SubtitleInfo
for _, match := range matches {
filename := filepath.Base(match)
langCode, hi, forced := parseSubtitleFilename(filename, base)
if langCode == "" {
continue
}
source := "downloaded"
if hi || forced {
source = "extracted"
}
subs = append(subs, SubtitleInfo{
FileName: filename,
Language: langCode,
LanguageCode: langCode,
HI: hi,
Forced: forced,
Source: source,
})
}
return subs
}
// parseSubtitleFilename extracts language code and flags from a subtitle filename.
// Pattern: basename.lang[.sdh|.forced].srt
func parseSubtitleFilename(filename, baseName string) (langCode string, hi bool, forced bool) {
remainder := filename
if strings.HasPrefix(filename, baseName+".") {
remainder = filename[len(baseName)+1:]
}
remainder = strings.TrimSuffix(remainder, ".srt")
parts := strings.Split(remainder, ".")
if len(parts) == 0 {
return "", false, false
}
langCode = parts[0]
if langCode == "" {
return "", false, false
}
for _, part := range parts[1:] {
switch strings.ToLower(part) {
case "sdh", "hi":
hi = true
case "forced":
forced = true
}
}
return langCode, hi, forced
}