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 }