Sync from /srv/compose/unified-media-manager
This commit is contained in:
337
internal/service/media_detail.go
Normal file
337
internal/service/media_detail.go
Normal file
@@ -0,0 +1,337 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user