Files
unified-media-manager/internal/service/media.go
2026-04-24 10:45:19 -07:00

622 lines
18 KiB
Go

package service
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"strconv"
"strings"
"time"
"github.com/TopherMayor/unified-media-manager/internal/db"
"github.com/jackc/pgx/v5"
)
type Media struct {
ID int64 `json:"id"`
MediaType string `json:"media_type"`
Title string `json:"title"`
SortTitle string `json:"sort_title"`
OriginalTitle *string `json:"original_title,omitempty"`
Overview *string `json:"overview,omitempty"`
Year *int `json:"year,omitempty"`
Status string `json:"status"`
Monitored bool `json:"monitored"`
ExternalIDs json.RawMessage `json:"external_ids"`
Metadata json.RawMessage `json:"metadata"`
Images json.RawMessage `json:"images"`
QualityProfileID *int64 `json:"quality_profile_id,omitempty"`
RootFolderID *int64 `json:"root_folder_id,omitempty"`
ReleaseDate *time.Time `json:"release_date,omitempty"`
CurrentQuality json.RawMessage `json:"current_quality,omitempty"`
DesiredQuality json.RawMessage `json:"desired_quality,omitempty"`
QualityUpgradeNeeded bool `json:"quality_upgrade_needed"`
AddedAt time.Time `json:"added_at"`
LastSearchAt *time.Time `json:"last_search_at,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
type MediaFile struct {
ID int64 `json:"id"`
MediaID int64 `json:"media_id"`
Path string `json:"path"`
OriginalPath *string `json:"original_path,omitempty"`
FileName string `json:"file_name"`
FileSize int64 `json:"file_size"`
Quality json.RawMessage `json:"quality"`
Codec *string `json:"codec,omitempty"`
Resolution *string `json:"resolution,omitempty"`
Source *string `json:"source,omitempty"`
TranscodeStatus string `json:"transcode_status"`
CreatedAt time.Time `json:"created_at"`
}
type MediaRelation struct {
ID int64 `json:"id"`
ParentID int64 `json:"parent_id"`
ChildID int64 `json:"child_id"`
Relation string `json:"relation"`
Position *int `json:"position,omitempty"`
Season *int `json:"season,omitempty"`
}
type MediaDetail struct {
Media Media `json:"media"`
Files []MediaFile `json:"files"`
Relations []MediaRelation `json:"relations"`
}
type MediaFilters struct {
MediaType string
Status string
Monitored string
Query string
Tag string
Page int
PageSize int
}
type CreateMediaRequest struct {
MediaType string `json:"media_type"`
Title string `json:"title"`
SortTitle string `json:"sort_title,omitempty"`
OriginalTitle *string `json:"original_title,omitempty"`
Overview *string `json:"overview,omitempty"`
Year *int `json:"year,omitempty"`
ReleaseDate *time.Time `json:"release_date,omitempty"`
Status string `json:"status"`
Monitored bool `json:"monitored"`
ExternalIDs json.RawMessage `json:"external_ids,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
Images json.RawMessage `json:"images,omitempty"`
QualityProfileID *int64 `json:"quality_profile_id,omitempty"`
RootFolderID *int64 `json:"root_folder_id,omitempty"`
CurrentQuality json.RawMessage `json:"current_quality,omitempty"`
DesiredQuality json.RawMessage `json:"desired_quality,omitempty"`
}
type UpdateMediaRequest struct {
Title *string `json:"title,omitempty"`
SortTitle *string `json:"sort_title,omitempty"`
OriginalTitle *string `json:"original_title,omitempty"`
Overview *string `json:"overview,omitempty"`
Year *int `json:"year,omitempty"`
Status *string `json:"status,omitempty"`
Monitored *bool `json:"monitored,omitempty"`
ExternalIDs json.RawMessage `json:"external_ids,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
Images json.RawMessage `json:"images,omitempty"`
QualityProfileID *int64 `json:"quality_profile_id,omitempty"`
RootFolderID *int64 `json:"root_folder_id,omitempty"`
CurrentQuality json.RawMessage `json:"current_quality,omitempty"`
DesiredQuality json.RawMessage `json:"desired_quality,omitempty"`
}
const mediaColumns = `id, media_type, title, sort_title, original_title, overview, year,
release_date,
status, monitored, external_ids, metadata, images, quality_profile_id, root_folder_id,
current_quality, desired_quality,
has_files,
CASE
WHEN has_files AND desired_quality IS NOT NULL
AND current_quality IS NOT NULL
AND current_quality::text != desired_quality::text
THEN true
ELSE false
END AS quality_upgrade_needed,
added_at, last_search_at, updated_at`
type MediaService struct {
db *db.DB
}
func NewMediaService(database *db.DB) *MediaService {
return &MediaService{db: database}
}
func scanMedia(scanner interface{ Scan(...interface{}) error }) (*Media, error) {
var m Media
var origTitle, overview sql.NullString
var year sql.NullInt64
var releaseDate sql.NullTime
var qpID, rfID sql.NullInt64
var lastSearchAt sql.NullTime
var hasFiles bool
err := scanner.Scan(&m.ID, &m.MediaType, &m.Title, &m.SortTitle, &origTitle, &overview, &year,
&releaseDate,
&m.Status, &m.Monitored, &m.ExternalIDs, &m.Metadata, &m.Images, &qpID, &rfID,
&m.CurrentQuality, &m.DesiredQuality, &hasFiles, &m.QualityUpgradeNeeded, &m.AddedAt, &lastSearchAt, &m.UpdatedAt)
if err != nil {
return nil, err
}
if origTitle.Valid {
m.OriginalTitle = &origTitle.String
}
if overview.Valid {
m.Overview = &overview.String
}
if year.Valid {
y := int(year.Int64)
m.Year = &y
}
if releaseDate.Valid {
m.ReleaseDate = &releaseDate.Time
}
if qpID.Valid {
m.QualityProfileID = &qpID.Int64
}
if rfID.Valid {
m.RootFolderID = &rfID.Int64
}
if lastSearchAt.Valid {
m.LastSearchAt = &lastSearchAt.Time
}
return &m, nil
}
type mediaWithTotal struct {
Media
total int
}
func scanMediaRowWithTotal(scanner interface{ Scan(...interface{}) error }) (*mediaWithTotal, error) {
var m Media
var origTitle, overview sql.NullString
var year sql.NullInt64
var releaseDate sql.NullTime
var qpID, rfID sql.NullInt64
var lastSearchAt sql.NullTime
var hasFiles bool
var total int
err := scanner.Scan(&m.ID, &m.MediaType, &m.Title, &m.SortTitle, &origTitle, &overview, &year,
&releaseDate,
&m.Status, &m.Monitored, &m.ExternalIDs, &m.Metadata, &m.Images, &qpID, &rfID,
&m.CurrentQuality, &m.DesiredQuality, &hasFiles, &m.QualityUpgradeNeeded, &m.AddedAt, &lastSearchAt, &m.UpdatedAt,
&total)
if err != nil {
return nil, err
}
if origTitle.Valid {
m.OriginalTitle = &origTitle.String
}
if overview.Valid {
m.Overview = &overview.String
}
if year.Valid {
y := int(year.Int64)
m.Year = &y
}
if releaseDate.Valid {
m.ReleaseDate = &releaseDate.Time
}
if qpID.Valid {
m.QualityProfileID = &qpID.Int64
}
if rfID.Valid {
m.RootFolderID = &rfID.Int64
}
if lastSearchAt.Valid {
m.LastSearchAt = &lastSearchAt.Time
}
return &mediaWithTotal{Media: m, total: total}, nil
}
func scanMediaRows(rows pgx.Rows) ([]Media, error) {
var results []Media
for rows.Next() {
m, err := scanMedia(rows)
if err != nil {
return nil, fmt.Errorf("scan media row: %w", err)
}
results = append(results, *m)
}
return results, nil
}
func buildMediaFilters(filters *MediaFilters) *QueryBuilder {
qb := NewQueryBuilder(1)
qb.AddLiteral("deleted_at IS NULL")
if filters.MediaType != "" {
qb.Add("media_type = $%d", filters.MediaType)
}
if filters.Status != "" {
qb.Add("status = $%d", filters.Status)
}
if filters.Monitored != "" {
qb.Add("monitored = $%d", filters.Monitored == "true")
}
return qb
}
func (s *MediaService) List(ctx context.Context, filters MediaFilters) ([]Media, int, error) {
qb := buildMediaFilters(&filters)
query := fmt.Sprintf("SELECT %s, COUNT(*) OVER() AS total FROM media%s ORDER BY sort_title LIMIT $%d OFFSET $%d",
mediaColumns, qb.Where(), qb.Idx(), qb.Idx()+1)
args := append(qb.Args(), filters.PageSize, (filters.Page-1)*filters.PageSize)
rows, err := s.db.Pool.Query(ctx, query, args...)
if err != nil {
return nil, 0, fmt.Errorf("list media: %w", err)
}
defer rows.Close()
var items []Media
var total int
for rows.Next() {
m, err := scanMediaRowWithTotal(rows)
if err != nil {
return nil, 0, fmt.Errorf("scan media row: %w", err)
}
if total == 0 {
total = m.total
}
items = append(items, m.Media)
}
return items, total, nil
}
func (s *MediaService) GetByID(ctx context.Context, id int64, mediaType string) (*MediaDetail, error) {
qb := NewQueryBuilder(1)
qb.AddLiteral("deleted_at IS NULL")
qb.Add("id = $%d", id)
if mediaType != "" {
qb.Add("media_type = $%d", mediaType)
}
row := s.db.Pool.QueryRow(ctx,
"SELECT "+mediaColumns+" FROM media"+qb.Where(), qb.Args()...)
m, err := scanMedia(row)
if err != nil {
return nil, fmt.Errorf("get media: %w", err)
}
detail := &MediaDetail{Media: *m}
fileRows, err := s.db.Pool.Query(ctx,
`SELECT id, media_id, path, original_path, file_name, file_size, quality, codec, resolution, source, transcode_status, created_at
FROM media_files WHERE media_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC`, id)
if err == nil {
defer fileRows.Close()
for fileRows.Next() {
var f MediaFile
var origPath, codec, resolution, source sql.NullString
if err := fileRows.Scan(&f.ID, &f.MediaID, &f.Path, &origPath, &f.FileName, &f.FileSize,
&f.Quality, &codec, &resolution, &source, &f.TranscodeStatus, &f.CreatedAt); err != nil {
slog.Error("failed to scan media file", "error", err)
continue
}
if origPath.Valid {
f.OriginalPath = &origPath.String
}
if codec.Valid {
f.Codec = &codec.String
}
if resolution.Valid {
f.Resolution = &resolution.String
}
if source.Valid {
f.Source = &source.String
}
detail.Files = append(detail.Files, f)
}
}
relRows, err := s.db.Pool.Query(ctx,
`SELECT id, parent_id, child_id, relation, position, season
FROM media_relations WHERE parent_id = $1 OR child_id = $1 ORDER BY relation, position`, id)
if err == nil {
defer relRows.Close()
for relRows.Next() {
var r MediaRelation
if err := relRows.Scan(&r.ID, &r.ParentID, &r.ChildID, &r.Relation, &r.Position, &r.Season); err != nil {
slog.Error("failed to scan media relation", "error", err)
continue
}
detail.Relations = append(detail.Relations, r)
}
}
return detail, nil
}
func (s *MediaService) Create(ctx context.Context, req CreateMediaRequest) (int64, error) {
if req.SortTitle == "" {
req.SortTitle = req.Title
}
if req.Status == "" {
req.Status = "unavailable"
}
if req.ExternalIDs == nil {
req.ExternalIDs = json.RawMessage("{}")
}
if req.Metadata == nil {
req.Metadata = json.RawMessage("{}")
}
if req.Images == nil {
req.Images = json.RawMessage("[]")
}
var id int64
err := s.db.Pool.QueryRow(ctx,
`INSERT INTO media (media_type, title, sort_title, original_title, overview, year,
release_date,
status, monitored, external_ids, metadata, images, quality_profile_id, root_folder_id,
current_quality, desired_quality)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) RETURNING id`,
req.MediaType, req.Title, req.SortTitle, req.OriginalTitle, req.Overview, req.Year,
req.ReleaseDate,
req.Status, req.Monitored, req.ExternalIDs, req.Metadata, req.Images,
req.QualityProfileID, req.RootFolderID, req.CurrentQuality, req.DesiredQuality).Scan(&id)
if err != nil {
return 0, fmt.Errorf("create media: %w", err)
}
return id, nil
}
func (s *MediaService) Update(ctx context.Context, id int64, mediaType string, req UpdateMediaRequest) error {
var setClauses []string
var args []interface{}
idx := 1
addCol := func(col string, val interface{}) {
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", col, idx))
args = append(args, val)
idx++
}
if req.Title != nil {
addCol("title", *req.Title)
}
if req.SortTitle != nil {
addCol("sort_title", *req.SortTitle)
}
if req.OriginalTitle != nil {
addCol("original_title", *req.OriginalTitle)
}
if req.Overview != nil {
addCol("overview", *req.Overview)
}
if req.Year != nil {
addCol("year", *req.Year)
}
if req.Status != nil {
addCol("status", *req.Status)
}
if req.Monitored != nil {
addCol("monitored", *req.Monitored)
}
if req.ExternalIDs != nil {
addCol("external_ids", req.ExternalIDs)
}
if req.Metadata != nil {
addCol("metadata", req.Metadata)
}
if req.Images != nil {
addCol("images", req.Images)
}
if req.QualityProfileID != nil {
addCol("quality_profile_id", *req.QualityProfileID)
}
if req.RootFolderID != nil {
addCol("root_folder_id", *req.RootFolderID)
}
if req.CurrentQuality != nil {
addCol("current_quality", req.CurrentQuality)
}
if req.DesiredQuality != nil {
addCol("desired_quality", req.DesiredQuality)
}
if len(setClauses) == 0 {
return fmt.Errorf("no fields to update")
}
addCol("updated_at", time.Now())
query := fmt.Sprintf("UPDATE media SET %s WHERE id = $%d AND deleted_at IS NULL",
strings.Join(setClauses, ", "), idx)
args = append(args, id)
if mediaType != "" {
query += fmt.Sprintf(" AND media_type = $%d", idx+1)
args = append(args, mediaType)
}
tag, err := s.db.Pool.Exec(ctx, query, args...)
if err != nil {
return fmt.Errorf("update media: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("media not found")
}
return nil
}
func (s *MediaService) Delete(ctx context.Context, id int64, mediaType string) error {
query := "UPDATE media SET deleted_at = NOW(), updated_at = NOW() WHERE id = $1 AND deleted_at IS NULL"
args := []interface{}{id}
if mediaType != "" {
query += " AND media_type = $2"
args = append(args, mediaType)
}
tag, err := s.db.Pool.Exec(ctx, query, args...)
if err != nil {
return fmt.Errorf("delete media: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("media not found")
}
return nil
}
func (s *MediaService) Search(ctx context.Context, filters MediaFilters) ([]Media, int, error) {
qb := NewQueryBuilder(1)
qb.AddLiteral("deleted_at IS NULL")
if filters.Query != "" {
qb.Add("to_tsvector('english', coalesce(title, '')) @@ plainto_tsquery('english', $%d)", filters.Query)
}
if filters.MediaType != "" {
qb.Add("media_type = $%d", filters.MediaType)
}
if filters.Status != "" {
qb.Add("status = $%d", filters.Status)
}
if filters.Tag != "" {
qb.Add("id IN (SELECT mt.media_id FROM media_tags mt JOIN tags t ON mt.tag_id = t.id WHERE t.name = $%d)", filters.Tag)
}
query := fmt.Sprintf("SELECT %s, COUNT(*) OVER() AS total FROM media%s ORDER BY sort_title LIMIT $%d OFFSET $%d",
mediaColumns, qb.Where(), qb.Idx(), qb.Idx()+1)
args := append(qb.Args(), filters.PageSize, (filters.Page-1)*filters.PageSize)
rows, err := s.db.Pool.Query(ctx, query, args...)
if err != nil {
return nil, 0, fmt.Errorf("search media: %w", err)
}
defer rows.Close()
var items []Media
var total int
for rows.Next() {
m, err := scanMediaRowWithTotal(rows)
if err != nil {
return nil, 0, fmt.Errorf("scan search results: %w", err)
}
if total == 0 {
total = m.total
}
items = append(items, m.Media)
}
return items, total, nil
}
func (s *MediaService) SearchMissing(ctx context.Context, filters MediaFilters) ([]Media, int, error) {
qb := NewQueryBuilder(1)
qb.AddLiteral("monitored = true")
qb.AddLiteral("status = 'unavailable'")
qb.AddLiteral("deleted_at IS NULL")
if filters.MediaType != "" {
qb.Add("media_type = $%d", filters.MediaType)
}
query := fmt.Sprintf("SELECT %s, COUNT(*) OVER() AS total FROM media%s ORDER BY sort_title LIMIT $%d OFFSET $%d",
mediaColumns, qb.Where(), qb.Idx(), qb.Idx()+1)
args := append(qb.Args(), filters.PageSize, (filters.Page-1)*filters.PageSize)
rows, err := s.db.Pool.Query(ctx, query, args...)
if err != nil {
return nil, 0, fmt.Errorf("query missing media: %w", err)
}
defer rows.Close()
var items []Media
var total int
for rows.Next() {
m, err := scanMediaRowWithTotal(rows)
if err != nil {
return nil, 0, fmt.Errorf("scan missing media: %w", err)
}
if total == 0 {
total = m.total
}
items = append(items, m.Media)
}
return items, total, nil
}
func (s *MediaService) SearchUpgrades(ctx context.Context, filters MediaFilters) ([]Media, int, error) {
qb := NewQueryBuilder(1)
qb.AddLiteral("deleted_at IS NULL")
qb.AddLiteral("monitored = true")
qb.AddLiteral("has_files = true")
qb.AddLiteral("current_quality IS NOT NULL")
qb.AddLiteral("desired_quality IS NOT NULL")
qb.AddLiteral("current_quality::text != desired_quality::text")
if filters.MediaType != "" {
qb.Add("media_type = $%d", filters.MediaType)
}
query := fmt.Sprintf("SELECT %s, COUNT(*) OVER() AS total FROM media%s ORDER BY sort_title LIMIT $%d OFFSET $%d",
mediaColumns, qb.Where(), qb.Idx(), qb.Idx()+1)
args := append(qb.Args(), filters.PageSize, (filters.Page-1)*filters.PageSize)
rows, err := s.db.Pool.Query(ctx, query, args...)
if err != nil {
return nil, 0, fmt.Errorf("query upgrades: %w", err)
}
defer rows.Close()
var items []Media
var total int
for rows.Next() {
m, err := scanMediaRowWithTotal(rows)
if err != nil {
return nil, 0, fmt.Errorf("scan upgrades: %w", err)
}
if total == 0 {
total = m.total
}
items = append(items, m.Media)
}
return items, total, nil
}
func CalcTotalPages(total, pageSize int) int {
totalPages := total / pageSize
if total%pageSize > 0 {
totalPages++
}
return totalPages
}
func ParsePagination(pageStr, pageSizeStr string) (page, pageSize int) {
page, _ = strconv.Atoi(pageStr)
if page < 1 {
page = 1
}
pageSize, _ = strconv.Atoi(pageSizeStr)
if pageSize < 1 || pageSize > 100 {
pageSize = 50
}
return
}