Files
silo-server/internal/catalog/filesplit/filesplit.go

284 lines
8.6 KiB
Go

// Package filesplit moves a subset of a catalog item's files to an existing
// item while preserving file-attributable user state. It is shared by the
// admin Split Versions flow and automatic scanner identity repair.
package filesplit
import (
"context"
"fmt"
"sort"
"github.com/jackc/pgx/v5"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/catalog/reattribute"
"github.com/Silo-Server/silo-server/internal/contentid"
)
const (
itemTypeMovie = "movie"
itemTypeSeries = "series"
)
// File is the media-file identity needed for a subset move.
type File struct {
ID int
ContentID string
MediaFolderID int
FilePath string
CanonicalRootPath string
ObservedRootPath string
GroupKeyVersion int
ContentGroupKey string
BaseType string
SeasonNumber int
EpisodeNumber int
EpisodeID string
}
// Options describes one transactional file-subset move.
type Options struct {
FromContentID string
ToContentID string
ItemType string
Files []File
HistoryMode reattribute.HistoryMode
}
// Result reports the state reattribution performed by Move.
type Result struct {
EpisodePairs []reattribute.IDPair
Reattribution *reattribute.Report
}
// Move reassigns files and file-attributable user state inside the caller's
// transaction. The source predicate makes stale/concurrent selections fail
// closed instead of moving an unexpected row.
func Move(ctx context.Context, tx pgx.Tx, opts Options) (*Result, error) {
if opts.FromContentID == "" || opts.ToContentID == "" || opts.FromContentID == opts.ToContentID {
return nil, fmt.Errorf("filesplit: from/to content ids required and must differ")
}
if opts.ItemType != itemTypeMovie && opts.ItemType != itemTypeSeries {
return nil, fmt.Errorf("filesplit: unsupported item type %q", opts.ItemType)
}
if len(opts.Files) == 0 {
return nil, fmt.Errorf("filesplit: at least one file is required")
}
if opts.HistoryMode == "" {
opts.HistoryMode = reattribute.HistoryModeEvidence
}
if !reattribute.ValidHistoryMode(opts.HistoryMode) {
return nil, fmt.Errorf("filesplit: invalid history mode %q", opts.HistoryMode)
}
fileIDs := distinctFileIDs(opts.Files)
if len(fileIDs) != len(opts.Files) {
return nil, fmt.Errorf("filesplit: file ids must be positive and unique")
}
tag, err := tx.Exec(ctx, `
UPDATE media_files
SET content_id = $1,
episode_id = NULL,
match_attempted_at = NULL,
updated_at = NOW()
WHERE content_id = $2
AND id = ANY($3::int[])
`, opts.ToContentID, opts.FromContentID, fileIDs)
if err != nil {
return nil, fmt.Errorf("filesplit: moving files: %w", err)
}
if tag.RowsAffected() != int64(len(fileIDs)) {
return nil, fmt.Errorf(
"filesplit: moved %d of %d selected files; source ownership changed",
tag.RowsAffected(),
len(fileIDs),
)
}
for _, folderID := range distinctFolderIDs(opts.Files) {
if _, err := tx.Exec(ctx, `
INSERT INTO media_item_libraries (content_id, media_folder_id)
VALUES ($1, $2)
ON CONFLICT DO NOTHING
`, opts.ToContentID, folderID); err != nil {
return nil, fmt.Errorf("filesplit: adding target library membership: %w", err)
}
}
derivedEpisodePairs := DeriveEpisodePairs(opts.ItemType, opts.Files, opts.ToContentID)
episodePairs, err := keepFullyMovedEpisodePairs(ctx, tx, derivedEpisodePairs)
if err != nil {
return nil, err
}
report, err := reattribute.Run(ctx, tx, reattribute.Options{
FromContentID: opts.FromContentID,
ToContentID: opts.ToContentID,
MovedFileIDs: fileIDs,
Mode: opts.HistoryMode,
EpisodePairs: episodePairs,
})
if err != nil {
return nil, fmt.Errorf("filesplit: reattributing user state: %w", err)
}
// Existing matched series already have deterministic target episode rows.
// Relink immediately when they are available; a later metadata refresh can
// fill any episode that does not exist yet.
if opts.ItemType == itemTypeSeries {
if _, err := tx.Exec(ctx, `
UPDATE media_files mf
SET episode_id = e.content_id,
updated_at = NOW()
FROM episodes e
WHERE mf.id = ANY($1::int[])
AND e.series_id = $2
AND e.season_number = mf.season_number
AND e.episode_number = mf.episode_number
`, fileIDs, opts.ToContentID); err != nil {
return nil, fmt.Errorf("filesplit: relinking target episodes: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO episode_libraries (episode_id, media_folder_id, first_seen_at)
SELECT mf.episode_id, mf.media_folder_id, MIN(mf.created_at)
FROM media_files mf
WHERE mf.id = ANY($1::int[])
AND mf.episode_id IS NOT NULL
GROUP BY mf.episode_id, mf.media_folder_id
ON CONFLICT (episode_id, media_folder_id) DO NOTHING
`, fileIDs); err != nil {
return nil, fmt.Errorf("filesplit: adding target episode library membership: %w", err)
}
if len(derivedEpisodePairs) > 0 {
if _, err := tx.Exec(ctx, `
DELETE FROM episode_libraries source_membership
WHERE source_membership.episode_id = ANY($1::text[])
AND NOT EXISTS (
SELECT 1
FROM media_files remaining
WHERE remaining.episode_id = source_membership.episode_id
AND remaining.media_folder_id = source_membership.media_folder_id
AND remaining.missing_since IS NULL
)
`, episodePairSources(derivedEpisodePairs)); err != nil {
return nil, fmt.Errorf("filesplit: removing stale source episode library membership: %w", err)
}
}
if err := catalog.RecomputeSeriesLatestEpisodeAdded(
ctx,
tx,
[]string{opts.FromContentID, opts.ToContentID},
); err != nil {
return nil, fmt.Errorf("filesplit: %w", err)
}
}
return &Result{
EpisodePairs: episodePairs,
Reattribution: report,
}, nil
}
// DeriveEpisodePairs maps moved files' current episode ids onto deterministic
// episode ids under the target series.
func DeriveEpisodePairs(itemType string, files []File, targetContentID string) []reattribute.IDPair {
if itemType != itemTypeSeries || !contentid.IsProviderAnchored(targetContentID) {
return nil
}
seen := make(map[string]bool)
pairs := make([]reattribute.IDPair, 0)
for _, file := range files {
if file.EpisodeID == "" || file.SeasonNumber <= 0 || file.EpisodeNumber <= 0 || seen[file.EpisodeID] {
continue
}
newID, ok := contentid.ForEpisode(targetContentID, file.SeasonNumber, file.EpisodeNumber)
if !ok || newID == file.EpisodeID {
continue
}
seen[file.EpisodeID] = true
pairs = append(pairs, reattribute.IDPair{From: file.EpisodeID, To: newID})
}
return pairs
}
// keepFullyMovedEpisodePairs excludes a source episode when another active
// file still points at it after the selected files were detached. Episode
// progress has no file dimension, so moving a shared episode's state would
// steal progress from the source show.
func keepFullyMovedEpisodePairs(
ctx context.Context,
tx pgx.Tx,
pairs []reattribute.IDPair,
) ([]reattribute.IDPair, error) {
if len(pairs) == 0 {
return nil, nil
}
fromIDs := make([]string, 0, len(pairs))
toIDs := make([]string, 0, len(pairs))
for _, pair := range pairs {
fromIDs = append(fromIDs, pair.From)
toIDs = append(toIDs, pair.To)
}
rows, err := tx.Query(ctx, `
SELECT pair.from_id, pair.to_id
FROM UNNEST($1::text[], $2::text[]) AS pair(from_id, to_id)
WHERE NOT EXISTS (
SELECT 1
FROM media_files remaining
WHERE remaining.episode_id = pair.from_id
AND remaining.missing_since IS NULL
)
`, fromIDs, toIDs)
if err != nil {
return nil, fmt.Errorf("filesplit: filtering shared episode state: %w", err)
}
defer rows.Close()
filtered := make([]reattribute.IDPair, 0, len(pairs))
for rows.Next() {
var pair reattribute.IDPair
if err := rows.Scan(&pair.From, &pair.To); err != nil {
return nil, fmt.Errorf("filesplit: scanning movable episode state: %w", err)
}
filtered = append(filtered, pair)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("filesplit: iterating movable episode state: %w", err)
}
return filtered, nil
}
func episodePairSources(pairs []reattribute.IDPair) []string {
ids := make([]string, 0, len(pairs))
for _, pair := range pairs {
ids = append(ids, pair.From)
}
return ids
}
func distinctFileIDs(files []File) []int {
seen := make(map[int]bool, len(files))
ids := make([]int, 0, len(files))
for _, file := range files {
if file.ID > 0 && !seen[file.ID] {
seen[file.ID] = true
ids = append(ids, file.ID)
}
}
sort.Ints(ids)
return ids
}
func distinctFolderIDs(files []File) []int {
seen := make(map[int]bool, len(files))
ids := make([]int, 0)
for _, file := range files {
if file.MediaFolderID > 0 && !seen[file.MediaFolderID] {
seen[file.MediaFolderID] = true
ids = append(ids, file.MediaFolderID)
}
}
sort.Ints(ids)
return ids
}