338 lines
9.4 KiB
Go
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
|
|
}
|