Files
silo-server/internal/metadata/skipped_root_repo.go
6f716d3404 fix(scanner): skip misplaced TV episodes in movie libraries (#90)
* fix(scanner): skip misplaced TV episodes in movie libraries

A movie-type library containing a TV show laid out as
"Show/Season NN/SxxExx.mkv" (e.g. a fan "supercuts" pack) created one
bogus movie item per episode, all titled after the season folder
("Season 01"). They surfaced on Recently Added and never matched.

Add naming.IsMisplacedSeriesFile, which detects a file with an SxxExx
name inside an explicit "Season NN"/"Specials" directory, and guard
createOrFindSkeleton so such files in a strict movie library are recorded
as a skipped root (reason series_in_movie_library) instead of becoming
items. The movie match-queue worker now dequeues files the skeleton step
deliberately skips instead of erroring on the empty content id.

The guard fires on the structural signal alone, regardless of any parsed
provider id, because a "Season NN" folder otherwise yields a bogus tmdb
id (the season number). Movies whose release filename merely contains an
SxxExx substring but live in a proper "Title (Year)/" folder are
unaffected (covered by the new test).

* fix(metadata): durably exclude misplaced-series files from movie queue

Address review findings on the misplaced-TV skip:

- The skipped file's content_id is never set, so every library sync
  re-enqueued it just for the worker to skip and dequeue it again.
  Exclude files beneath a series_in_movie_library skipped root in the
  movie match queue predicates; deleting the skipped root row makes the
  files eligible again. The worker drain remains to flush rows claimed
  before the root was recorded.
- Extract the eligibility predicate (previously duplicated verbatim in
  eight queries) into movieQueueFileEligibleCond.
- Derive skipped-root file_count from media_files under the root via a
  new UpsertObservedFile instead of hardcoding 1: a 34-episode pack now
  reports 34 instead of each per-file upsert overwriting the count.
  This also fixes the pre-existing missing_folder_ids undercount.
- Fold the two near-identical Upsert+log blocks in createOrFindSkeleton
  into a recordSkippedRoot helper, normalize libraryType once, and name
  the reason strings as constants.
- Drop a no-op filepath.Base on already-split path segments in
  IsMisplacedSeriesFile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 17:18:41 -04:00

197 lines
6.1 KiB
Go

package metadata
import (
"context"
"fmt"
"path/filepath"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/models"
)
const (
// skippedReasonSeriesInMovieLibrary marks roots holding TV episodes dropped
// into a strict movie library. The movie match queue durably excludes files
// beneath such roots (see movieQueueFileEligibleCond); deleting the row
// makes the files eligible for matching again.
skippedReasonSeriesInMovieLibrary = "series_in_movie_library"
// skippedReasonMissingFolderIDs marks roots whose folder names carry no
// provider IDs. Recorded for diagnostics only; matching still proceeds.
skippedReasonMissingFolderIDs = "missing_folder_ids"
)
// SkippedRootRepository persists media roots that were skipped during scans.
type SkippedRootRepository struct {
pool *pgxpool.Pool
}
// NewSkippedRootRepository creates a new SkippedRootRepository.
func NewSkippedRootRepository(pool *pgxpool.Pool) *SkippedRootRepository {
return &SkippedRootRepository{pool: pool}
}
const skippedRootColumns = `media_folder_id, root_path, reason, sample_file_path, file_count, first_seen_at, last_seen_at`
func scanSkippedRoot(row pgx.Row) (*models.SkippedMediaRoot, error) {
var root models.SkippedMediaRoot
if err := row.Scan(
&root.MediaFolderID,
&root.RootPath,
&root.Reason,
&root.SampleFilePath,
&root.FileCount,
&root.FirstSeenAt,
&root.LastSeenAt,
); err != nil {
return nil, fmt.Errorf("scanning skipped media root: %w", err)
}
return &root, nil
}
func scanSkippedRoots(rows pgx.Rows) ([]*models.SkippedMediaRoot, error) {
var roots []*models.SkippedMediaRoot
for rows.Next() {
var root models.SkippedMediaRoot
if err := rows.Scan(
&root.MediaFolderID,
&root.RootPath,
&root.Reason,
&root.SampleFilePath,
&root.FileCount,
&root.FirstSeenAt,
&root.LastSeenAt,
); err != nil {
return nil, fmt.Errorf("scanning skipped media root row: %w", err)
}
roots = append(roots, &root)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating skipped media root rows: %w", err)
}
if roots == nil {
roots = []*models.SkippedMediaRoot{}
}
return roots, nil
}
// Upsert inserts or updates a skipped media root keyed by folder and root path.
func (r *SkippedRootRepository) Upsert(ctx context.Context, root models.SkippedMediaRoot) error {
query := `
INSERT INTO skipped_media_roots (
media_folder_id, root_path, reason, sample_file_path, file_count, first_seen_at, last_seen_at
)
VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
ON CONFLICT (media_folder_id, root_path) DO UPDATE
SET reason = EXCLUDED.reason,
sample_file_path = EXCLUDED.sample_file_path,
file_count = EXCLUDED.file_count,
last_seen_at = NOW()
`
_, err := r.pool.Exec(ctx, query,
root.MediaFolderID,
root.RootPath,
root.Reason,
root.SampleFilePath,
root.FileCount,
)
if err != nil {
return fmt.Errorf("upserting skipped media root: %w", err)
}
return nil
}
// UpsertObservedFile records a skipped media root observed through a single
// file, deriving file_count from the media_files currently present beneath the
// root. Unlike Upsert, repeated per-file calls stay accurate and idempotent: a
// 34-episode pack converges on file_count=34 instead of overwriting it with
// each caller's partial count.
func (r *SkippedRootRepository) UpsertObservedFile(ctx context.Context, folderID int, rootPath, reason, sampleFilePath string) error {
query := `
INSERT INTO skipped_media_roots (
media_folder_id, root_path, reason, sample_file_path, file_count, first_seen_at, last_seen_at
)
VALUES ($1, $2, $3, $4,
GREATEST(1, (
SELECT COUNT(*)
FROM media_files mf
WHERE mf.media_folder_id = $1
AND mf.missing_since IS NULL
AND strpos(mf.file_path, $2 || '/') = 1
)),
NOW(), NOW())
ON CONFLICT (media_folder_id, root_path) DO UPDATE
SET reason = EXCLUDED.reason,
sample_file_path = EXCLUDED.sample_file_path,
file_count = EXCLUDED.file_count,
last_seen_at = NOW()
`
_, err := r.pool.Exec(ctx, query, folderID, filepath.Clean(rootPath), reason, sampleFilePath)
if err != nil {
return fmt.Errorf("upserting observed skipped media root: %w", err)
}
return nil
}
// Delete removes one skipped media root.
func (r *SkippedRootRepository) Delete(ctx context.Context, folderID int, rootPath string) error {
_, err := r.pool.Exec(ctx,
`DELETE FROM skipped_media_roots WHERE media_folder_id = $1 AND root_path = $2`,
folderID, rootPath,
)
if err != nil {
return fmt.Errorf("deleting skipped media root: %w", err)
}
return nil
}
// DeleteMissingInScope removes skipped roots under scopePath that were not seen.
func (r *SkippedRootRepository) DeleteMissingInScope(ctx context.Context, folderID int, scopePath string, seenRoots []string) error {
if seenRoots == nil {
seenRoots = []string{}
}
scopePath = filepath.Clean(scopePath)
_, err := r.pool.Exec(ctx, `
DELETE FROM skipped_media_roots
WHERE media_folder_id = $1
AND (root_path = $2 OR strpos(root_path, $2 || '/') = 1)
AND NOT (root_path = ANY($3))
`, folderID, scopePath, seenRoots)
if err != nil {
return fmt.Errorf("deleting missing skipped media roots in scope: %w", err)
}
return nil
}
// ListByFolder returns skipped roots for a folder ordered by most recent sighting.
func (r *SkippedRootRepository) ListByFolder(ctx context.Context, folderID int) ([]*models.SkippedMediaRoot, error) {
rows, err := r.pool.Query(ctx, `
SELECT `+skippedRootColumns+`
FROM skipped_media_roots
WHERE media_folder_id = $1
ORDER BY last_seen_at DESC, root_path ASC
`, folderID)
if err != nil {
return nil, fmt.Errorf("listing skipped media roots by folder: %w", err)
}
defer rows.Close()
return scanSkippedRoots(rows)
}
// ListAll returns all skipped roots ordered by most recent sighting.
func (r *SkippedRootRepository) ListAll(ctx context.Context) ([]*models.SkippedMediaRoot, error) {
rows, err := r.pool.Query(ctx, `
SELECT `+skippedRootColumns+`
FROM skipped_media_roots
ORDER BY last_seen_at DESC, media_folder_id ASC, root_path ASC
`)
if err != nil {
return nil, fmt.Errorf("listing skipped media roots: %w", err)
}
defer rows.Close()
return scanSkippedRoots(rows)
}