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 }