* fix(scanner): stop reporting an unusable ffprobe as an empty folder parseAudiobookFolder and parsePodcastShow signalled "this folder holds no audio files" by wrapping os.ErrNotExist, and their reconcile callers skipped on that. exec also wraps fs.ErrNotExist when the configured ffprobe binary cannot be run, so a wrong playback.ffmpeg_path made every candidate folder look empty: the scan logged processed=N failed=0, indexed nothing, and gave the operator no clue why the library stayed empty. Introduce an errFolderHasNoMedia sentinel that deliberately does not wrap os.ErrNotExist, and skip on that instead. A folder that disappears between the scan walk and the parse still maps to the sentinel, so a mid-scan rename or delete stays a quiet skip rather than a scan failure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(scanner): bound the all-failed scan summary instead of joining every failure The sentinel change in this PR makes a previously-unreachable path reachable. Before it, a misconfigured ffprobe made every candidate folder look empty, so the scan skipped everything and `failed` stayed 0 — the `failedCount == processedCount` branch never fired. Now that an unusable ffprobe propagates as a real failure, that branch is the expected outcome of a first scan with a bad `playback.ffmpeg_path`, and it joins one wrapped error per failed folder. On the 240k-folder library the scan code is written for, `errors.Join` over that slice produces a ~64 MB error string (measured) that is written verbatim into `scan_runs.error_message` and republished over the Redis events channel and the admin SSE stream. The `failures` slice itself also grew unbounded for the whole scan even when the all-failed guard could not fire (any rescan with `skipped > 0`), holding hundreds of megabytes across a multi-hour scan before discarding it. Add a `scanFailures` collector that retains the first 20 failures and counts the rest, joining them with a trailing "and N more failures (elided)". The retained sample still names the cause, which is the entire purpose of the summary. The same 64 MB case now produces 5.4 KB. The ebook and manga scans have the identical shape and the same exposure via their own probe failures, so all four call sites share the collector rather than fixing the two audio paths alone. `failMu` now guards only `cancelErr` and is renamed `cancelMu` to match. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
1222 lines
37 KiB
Go
1222 lines
37 KiB
Go
package scanner
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/idgen"
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
"github.com/Silo-Server/silo-server/internal/titleutil"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
)
|
|
|
|
type filesystemRootContentFinder interface {
|
|
FindContentIDByRootPath(ctx context.Context, folderID int, rootPath, preferredType string) (string, error)
|
|
}
|
|
|
|
type filesystemMediaItemWriter interface {
|
|
Upsert(ctx context.Context, item *models.MediaItem) error
|
|
}
|
|
|
|
type audiobookPosterExec interface {
|
|
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
|
|
}
|
|
|
|
type audiobookPosterPathReader interface {
|
|
GetPosterPath(ctx context.Context, contentID string) (string, error)
|
|
}
|
|
|
|
const audiobookDuplicateCandidateSQL = `
|
|
SELECT mi.content_id, mi.title
|
|
FROM media_items mi
|
|
JOIN item_people ipa ON ipa.content_id = mi.content_id AND ipa.kind = 7
|
|
JOIN people pa ON pa.id = ipa.person_id AND LOWER(pa.name) = LOWER($1)
|
|
JOIN item_people ipn ON ipn.content_id = mi.content_id AND ipn.kind = 8
|
|
JOIN people pn ON pn.id = ipn.person_id AND LOWER(pn.name) = LOWER($2)
|
|
JOIN LATERAL (
|
|
SELECT COALESCE(SUM(mf.duration), 0) AS dur
|
|
FROM media_files mf WHERE mf.content_id = mi.content_id
|
|
) f ON TRUE
|
|
JOIN LATERAL (
|
|
SELECT btrim(regexp_replace(
|
|
regexp_replace(lower(mi.title), '[^a-z0-9]+', ' ', 'g'),
|
|
'[[:space:]]+', ' ', 'g'
|
|
)) AS normalized_title
|
|
) t ON TRUE
|
|
WHERE mi.type = 'audiobook'
|
|
AND mi.year = $3
|
|
AND f.dur > 0
|
|
AND ABS(f.dur - $4) <= $5
|
|
AND (
|
|
t.normalized_title = $6
|
|
OR t.normalized_title LIKE $6 || ' %'
|
|
OR $6 LIKE t.normalized_title || ' %'
|
|
)
|
|
ORDER BY ABS(f.dur - $4), LENGTH(mi.title), mi.content_id
|
|
LIMIT 25
|
|
`
|
|
|
|
// audiobookDiskFile is the on-disk projection used by audiobookFolderUnchanged.
|
|
// Path is the absolute file path; Size and ModTime come from os.Stat.
|
|
type audiobookDiskFile struct {
|
|
Path string
|
|
Size int64
|
|
ModTime time.Time
|
|
}
|
|
|
|
// audiobookFolderUnchanged reports whether the audio files on disk match the
|
|
// existing media_files rows for the same folder one-for-one on path, size,
|
|
// and mtime. A new file, removed file, or any byte-level / mtime drift returns
|
|
// false so the caller falls through to a full reconcile.
|
|
//
|
|
// Comparison uses sameFileModifiedAt for mtime to absorb sub-second precision
|
|
// differences between filesystem reads.
|
|
func audiobookFolderUnchanged(existing []*models.MediaFile, onDisk []audiobookDiskFile) bool {
|
|
if len(existing) != len(onDisk) {
|
|
return false
|
|
}
|
|
byPath := make(map[string]*models.MediaFile, len(existing))
|
|
for _, mf := range existing {
|
|
byPath[mf.FilePath] = mf
|
|
}
|
|
for _, d := range onDisk {
|
|
mf, ok := byPath[d.Path]
|
|
if !ok {
|
|
return false
|
|
}
|
|
if mf.FileSize != d.Size {
|
|
return false
|
|
}
|
|
if mf.FileModifiedAt == nil || !sameFileModifiedAt(mf.FileModifiedAt, d.ModTime) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// audiobookFolderShouldSkip returns true when every audio file on disk in
|
|
// folderPath matches an existing media_files row by size + mtime AND the
|
|
// linked media_items row is in a non-unmatched status. False under any
|
|
// drift, missing row, or unmatched status — the caller then falls through
|
|
// to the full reconcile path.
|
|
//
|
|
// Errors are returned but the worker loop treats them as "do not skip".
|
|
func (s *Scanner) audiobookFolderShouldSkip(ctx context.Context, folder *models.MediaFolder, folderPath string) (string, bool, error) {
|
|
if s.fileRepo == nil || s.itemRepo == nil {
|
|
return "", false, nil
|
|
}
|
|
|
|
entries, err := os.ReadDir(folderPath)
|
|
if err != nil {
|
|
return "", false, fmt.Errorf("read folder: %w", err)
|
|
}
|
|
var onDisk []audiobookDiskFile
|
|
for _, e := range entries {
|
|
if e.IsDir() || !SupportsAudioFile(e.Name()) {
|
|
continue
|
|
}
|
|
full := filepath.Join(folderPath, e.Name())
|
|
info, statErr := os.Stat(full)
|
|
if statErr != nil {
|
|
return "", false, fmt.Errorf("stat %s: %w", full, statErr)
|
|
}
|
|
onDisk = append(onDisk, audiobookDiskFile{
|
|
Path: full,
|
|
Size: info.Size(),
|
|
ModTime: normalizeFileModifiedAt(info.ModTime()),
|
|
})
|
|
}
|
|
if len(onDisk) == 0 {
|
|
return "", false, nil
|
|
}
|
|
|
|
existing, err := s.fileRepo.ListByObservedRootPath(ctx, folder.ID, folderPath)
|
|
if err != nil {
|
|
return "", false, fmt.Errorf("list existing files: %w", err)
|
|
}
|
|
if !audiobookFolderUnchanged(existing, onDisk) {
|
|
return "", false, nil
|
|
}
|
|
|
|
contentID := existing[0].ContentID
|
|
if contentID == "" {
|
|
return "", false, nil
|
|
}
|
|
// All files must share the same content_id; fragmented folders need reconcile.
|
|
for _, mf := range existing[1:] {
|
|
if mf.ContentID != contentID {
|
|
return "", false, nil
|
|
}
|
|
}
|
|
items, err := s.itemRepo.GetByIDs(ctx, []string{contentID})
|
|
if err != nil {
|
|
return "", false, fmt.Errorf("get item for skip check: %w", err)
|
|
}
|
|
if len(items) == 0 || items[0] == nil {
|
|
return "", false, nil
|
|
}
|
|
if strings.TrimSpace(items[0].Title) == "" {
|
|
return "", false, nil
|
|
}
|
|
if strings.EqualFold(strings.TrimSpace(items[0].Status), "unmatched") {
|
|
return "", false, nil
|
|
}
|
|
return contentID, true, nil
|
|
}
|
|
|
|
// audiobookScanWorkers returns the configured number of parallel workers
|
|
// for audiobook reconciliation. Defaults to 8 — high enough to keep all
|
|
// cores busy on the ffprobe step (which dominates per-book wall time)
|
|
// without overwhelming a small server. Override with SILO_AUDIOBOOK_SCAN_WORKERS.
|
|
func audiobookScanWorkers() int {
|
|
if v := os.Getenv("SILO_AUDIOBOOK_SCAN_WORKERS"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
|
return n
|
|
}
|
|
}
|
|
return 8
|
|
}
|
|
|
|
type audiobookRootScan struct {
|
|
root string
|
|
candidates []string
|
|
seenPaths map[string]bool
|
|
rootErr error
|
|
walkFailures int
|
|
}
|
|
|
|
func (r *audiobookRootScan) failed() bool {
|
|
return r.rootErr != nil || r.walkFailures > 0
|
|
}
|
|
|
|
func collectAudiobookRootScans(ctx context.Context, folderID int, roots []string) ([]audiobookRootScan, error) {
|
|
scans := make([]audiobookRootScan, 0, len(roots))
|
|
for _, root := range roots {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
cleanRoot := filepath.Clean(strings.TrimSpace(root))
|
|
if cleanRoot == "" || cleanRoot == "." {
|
|
continue
|
|
}
|
|
scan := audiobookRootScan{
|
|
root: cleanRoot,
|
|
seenPaths: make(map[string]bool),
|
|
}
|
|
info, statErr := os.Stat(cleanRoot)
|
|
switch {
|
|
case statErr != nil:
|
|
scan.rootErr = fmt.Errorf("stat root: %w", statErr)
|
|
case !info.IsDir():
|
|
scan.rootErr = fmt.Errorf("root is not a directory after symlink resolution")
|
|
}
|
|
if statErr == nil && scan.rootErr == nil {
|
|
walkErr := filepath.WalkDir(cleanRoot, func(path string, d fs.DirEntry, walkErr error) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if walkErr != nil {
|
|
scan.walkFailures++
|
|
slog.WarnContext(ctx, "audiobook scan: walk error", "component", "scanner", "path", path, "error", walkErr)
|
|
return nil
|
|
}
|
|
if !d.IsDir() {
|
|
return nil
|
|
}
|
|
entries, err := os.ReadDir(path)
|
|
if err != nil {
|
|
scan.walkFailures++
|
|
slog.WarnContext(ctx, "audiobook scan: read dir failed", "component", "scanner", "path", path, "error", err)
|
|
return nil
|
|
}
|
|
hadAudio := false
|
|
for _, e := range entries {
|
|
if !e.IsDir() && SupportsAudioFile(e.Name()) {
|
|
scan.seenPaths[filepath.Join(path, e.Name())] = true
|
|
hadAudio = true
|
|
}
|
|
}
|
|
if hadAudio {
|
|
scan.candidates = append(scan.candidates, path)
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
})
|
|
if walkErr != nil {
|
|
if errors.Is(walkErr, context.Canceled) || errors.Is(walkErr, context.DeadlineExceeded) {
|
|
return nil, walkErr
|
|
}
|
|
scan.rootErr = fmt.Errorf("walk root: %w", walkErr)
|
|
}
|
|
}
|
|
if scan.failed() {
|
|
slog.WarnContext(ctx, "audiobook scan: root walk incomplete; root excluded from missing-file reconciliation", "component", "scanner",
|
|
"folder_id", folderID,
|
|
"root", cleanRoot,
|
|
"walk_failures", scan.walkFailures,
|
|
"error", scan.rootErr,
|
|
)
|
|
}
|
|
scans = append(scans, scan)
|
|
}
|
|
return scans, nil
|
|
}
|
|
|
|
func splitAudiobookReconcileRoots(scans []audiobookRootScan) (roots []string, seenPaths map[string]bool, sawFiles bool) {
|
|
roots = make([]string, 0, len(scans))
|
|
seenPaths = make(map[string]bool)
|
|
for i := range scans {
|
|
scan := &scans[i]
|
|
if scan.failed() {
|
|
continue
|
|
}
|
|
roots = append(roots, scan.root)
|
|
if len(scan.seenPaths) > 0 {
|
|
sawFiles = true
|
|
for path := range scan.seenPaths {
|
|
seenPaths[path] = true
|
|
}
|
|
}
|
|
}
|
|
return roots, seenPaths, sawFiles
|
|
}
|
|
|
|
// ScanAudiobookFolder walks an audiobooks-typed media folder and writes
|
|
// one media_items row per subdirectory it can parse as an audiobook,
|
|
// plus the corresponding media_files rows and author/narrator links in
|
|
// item_people.
|
|
//
|
|
// Each immediate subdirectory of one of folder.Paths is treated as a
|
|
// single audiobook. Subdirectories that contain zero audio files are
|
|
// silently skipped (parseAudiobookFolder returns errFolderHasNoMedia).
|
|
//
|
|
// This bypasses the per-file movie/TV pipeline because audiobooks are
|
|
// inherently folder-scoped (one book = one item, possibly multi-file).
|
|
func (s *Scanner) ScanAudiobookFolder(ctx context.Context, folder *models.MediaFolder, fullScan bool) error {
|
|
if s == nil || folder == nil {
|
|
return fmt.Errorf("ScanAudiobookFolder: nil scanner or folder")
|
|
}
|
|
|
|
// Phase 1: walk the tree to collect candidate book folders. This is
|
|
// I/O-light (no ffprobe), and avoids holding the worker pool open
|
|
// for the duration of a 240k-folder scan.
|
|
scans, err := collectAudiobookRootScans(ctx, folder.ID, folder.Paths)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
candidates := make([]string, 0)
|
|
for i := range scans {
|
|
candidates = append(candidates, scans[i].candidates...)
|
|
}
|
|
reconcileRoots, seenPaths, _ := splitAudiobookReconcileRoots(scans)
|
|
|
|
if len(candidates) == 0 {
|
|
// No books on disk. Still reconcile (guarded) so a legitimately-emptied
|
|
// library converges — but only when a root was readable, and the
|
|
// empty-walk guard requires operator confirmation before deleting.
|
|
if len(reconcileRoots) > 0 {
|
|
if err := s.reconcileAudiobookMissingFiles(ctx, folder, reconcileRoots, seenPaths, fullScan); err != nil {
|
|
slog.WarnContext(ctx, "audiobook scan: missing-file reconcile failed", "component", "scanner", "folder_id", folder.ID, "error", err)
|
|
}
|
|
} else if len(scans) > 0 {
|
|
slog.WarnContext(ctx, "audiobook scan: every root walk failed; skipping missing-file reconciliation", "component", "scanner",
|
|
"folder_id", folder.ID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Phase 2: reconcile in parallel. ffprobe dominates per-book wall time
|
|
// (hundreds of ms each), so single-threaded scans of a large library
|
|
// take days. Worker pool brings this to ~hours.
|
|
workers := audiobookScanWorkers()
|
|
slog.InfoContext(ctx, "audiobook scan: starting", "component", "scanner",
|
|
"folder_id", folder.ID,
|
|
"candidates", len(candidates),
|
|
"workers", workers,
|
|
)
|
|
|
|
ch := make(chan string, workers*2)
|
|
var (
|
|
wg sync.WaitGroup
|
|
processed int64
|
|
failed int64
|
|
skipped int64
|
|
cancelMu sync.Mutex
|
|
failures scanFailures
|
|
cancelErr error
|
|
)
|
|
start := time.Now()
|
|
for i := 0; i < workers; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for path := range ch {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
if err := s.reconcileAudiobookFolder(ctx, folder, path, &skipped); err != nil {
|
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
|
cancelMu.Lock()
|
|
if cancelErr == nil {
|
|
cancelErr = err
|
|
}
|
|
cancelMu.Unlock()
|
|
return
|
|
}
|
|
atomic.AddInt64(&failed, 1)
|
|
failures.addf("%s: %w", path, err)
|
|
slog.WarnContext(ctx, "audiobook scan: folder failed", "component", "scanner",
|
|
"folder_id", folder.ID,
|
|
"path", path,
|
|
"error", err,
|
|
)
|
|
}
|
|
n := atomic.AddInt64(&processed, 1)
|
|
if n%500 == 0 {
|
|
slog.InfoContext(ctx, "audiobook scan: progress", "component", "scanner",
|
|
"folder_id", folder.ID,
|
|
"processed", n,
|
|
"failed", atomic.LoadInt64(&failed),
|
|
"skipped", atomic.LoadInt64(&skipped),
|
|
"total", len(candidates),
|
|
"elapsed_sec", int(time.Since(start).Seconds()),
|
|
)
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
for _, p := range candidates {
|
|
if ctx.Err() != nil {
|
|
break
|
|
}
|
|
ch <- p
|
|
}
|
|
close(ch)
|
|
wg.Wait()
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if cancelErr != nil {
|
|
return cancelErr
|
|
}
|
|
|
|
slog.InfoContext(ctx, "audiobook scan: completed", "component", "scanner",
|
|
"folder_id", folder.ID,
|
|
"processed", atomic.LoadInt64(&processed),
|
|
"failed", atomic.LoadInt64(&failed),
|
|
"skipped", atomic.LoadInt64(&skipped),
|
|
"elapsed_sec", int(time.Since(start).Seconds()),
|
|
)
|
|
if processedCount := atomic.LoadInt64(&processed); processedCount > 0 {
|
|
failedCount := atomic.LoadInt64(&failed)
|
|
skippedCount := atomic.LoadInt64(&skipped)
|
|
if failedCount > 0 && skippedCount == 0 && failedCount == processedCount {
|
|
return fmt.Errorf("audiobook scan failed for every attempted folder_id=%d: %w", folder.ID, failures.join())
|
|
}
|
|
}
|
|
|
|
// Reconcile files that vanished from disk now that the full walk's
|
|
// seenPaths is known and the scan completed without cancellation.
|
|
if err := s.reconcileAudiobookMissingFiles(ctx, folder, reconcileRoots, seenPaths, fullScan); err != nil {
|
|
slog.WarnContext(ctx, "audiobook scan: missing-file reconcile failed", "component", "scanner", "folder_id", folder.ID, "error", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// reconcileAudiobookMissingFiles mirrors reconcileMissingEbookFiles: DB files
|
|
// under the scanned roots that were not seen on disk are marked missing, the
|
|
// folder trash is optionally emptied, and library memberships are reconciled so
|
|
// books with no remaining files are removed (a rename therefore converges on
|
|
// the newly indexed path instead of leaving a stale duplicate). A scan that saw
|
|
// zero files while the DB still has rows only reconciles when the operator has
|
|
// confirmed cleanup, so an unmounted source can't wipe the catalog.
|
|
func (s *Scanner) reconcileAudiobookMissingFiles(ctx context.Context, folder *models.MediaFolder, roots []string, seenPaths map[string]bool, fullScan bool) error {
|
|
if s.fileRepo == nil || s.libraryRepo == nil || len(roots) == 0 {
|
|
return nil
|
|
}
|
|
|
|
confirmedCleanup, blockAll, err := s.emptyCleanupDecision(
|
|
ctx, folder, roots, seenPaths, fullScan, "audiobook",
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if blockAll {
|
|
return nil
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
missing := 0
|
|
for _, root := range roots {
|
|
existing, err := s.fileRepo.GetByFolderAndPathPrefix(ctx, folder.ID, root)
|
|
if err != nil {
|
|
return fmt.Errorf("listing existing audiobook files for %q: %w", root, err)
|
|
}
|
|
for _, mf := range existing {
|
|
if mf == nil || seenPaths[mf.FilePath] {
|
|
continue
|
|
}
|
|
if mf.MissingSince == nil {
|
|
if err := s.fileRepo.MarkMissing(ctx, mf.ID, now); err != nil {
|
|
slog.ErrorContext(ctx, "audiobook scan: failed to mark file missing", "component", "scanner",
|
|
"folder_id", folder.ID, "path", mf.FilePath, "error", err)
|
|
continue
|
|
}
|
|
}
|
|
missing++
|
|
}
|
|
}
|
|
|
|
trashed, removedMemberships, deletedItems, err := s.sweepMissingAndReconcile(ctx, folder, confirmedCleanup)
|
|
if trashed > 0 {
|
|
slog.InfoContext(ctx, "audiobook scan: emptied trash", "component", "scanner", "folder_id", folder.ID, "deleted", trashed)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if missing > 0 || removedMemberships > 0 || deletedItems > 0 {
|
|
slog.InfoContext(ctx, "audiobook scan: reconciled missing files", "component", "scanner",
|
|
"folder_id", folder.ID, "missing", missing,
|
|
"memberships_removed", removedMemberships, "items_deleted", deletedItems)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Scanner) reconcileAudiobookFolder(ctx context.Context, folder *models.MediaFolder, folderPath string, skipped *int64) error {
|
|
existingContentID, isUnchanged, skipErr := s.audiobookFolderShouldSkip(ctx, folder, folderPath)
|
|
if skipErr != nil {
|
|
slog.WarnContext(ctx, "audiobook scan: skip-check failed, falling through", "component", "scanner",
|
|
"folder_id", folder.ID,
|
|
"path", folderPath,
|
|
"error", skipErr,
|
|
)
|
|
} else if isUnchanged {
|
|
s.autoLinkLiteraryWork(ctx, existingContentID)
|
|
atomic.AddInt64(skipped, 1)
|
|
return nil
|
|
}
|
|
parsed, err := parseAudiobookFolder(ctx, s.ffprobePath, folderPath)
|
|
if err != nil {
|
|
if errors.Is(err, errFolderHasNoMedia) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("parse audiobook folder %s: %w", folderPath, err)
|
|
}
|
|
|
|
contentID, err := s.upsertAudiobookMediaItem(ctx, folder.ID, folderPath, parsed)
|
|
if err != nil {
|
|
return fmt.Errorf("upsert audiobook item: %w", err)
|
|
}
|
|
if err := s.upsertAudiobookMediaFiles(ctx, folder, contentID, folderPath, parsed); err != nil {
|
|
return fmt.Errorf("upsert audiobook files: %w", err)
|
|
}
|
|
if err := applyAudiobookSidecarCover(ctx, s.itemRepo, s.imageCacher, contentID, folderPath); err != nil {
|
|
slog.WarnContext(ctx, "audiobook scan: sidecar cover upload failed", "component", "scanner",
|
|
"folder_id", folder.ID,
|
|
"content_id", contentID,
|
|
"path", folderPath,
|
|
"error", err,
|
|
)
|
|
}
|
|
if len(parsed.Files) > 0 && s.fileRepo != nil {
|
|
if _, err := applyAudiobookEmbeddedCover(ctx, s.itemRepo, s.fileRepo.Pool(), FFmpegPathFromFFprobe(s.ffprobePath), s.imageCacher, parsed.Files[0].Path, contentID); err != nil {
|
|
slog.WarnContext(ctx, "audiobook scan: embedded cover failed", "component", "scanner",
|
|
"folder_id", folder.ID,
|
|
"content_id", contentID,
|
|
"path", parsed.Files[0].Path,
|
|
"error", err,
|
|
)
|
|
}
|
|
}
|
|
if err := s.upsertAudiobookPeople(ctx, contentID, parsed); err != nil {
|
|
return fmt.Errorf("upsert audiobook people: %w", err)
|
|
}
|
|
if err := s.upsertAudiobookSeries(ctx, contentID, parsed); err != nil {
|
|
return fmt.Errorf("upsert audiobook series: %w", err)
|
|
}
|
|
if _, err := s.fileRepo.Pool().Exec(ctx, `
|
|
INSERT INTO media_item_libraries (content_id, media_folder_id, first_seen_at)
|
|
VALUES ($1, $2, NOW())
|
|
ON CONFLICT (content_id, media_folder_id) DO NOTHING
|
|
`, contentID, folder.ID); err != nil {
|
|
return fmt.Errorf("upsert audiobook library membership: %w", err)
|
|
}
|
|
if parsed.ASIN != "" {
|
|
// Two unique constraints can conflict: (content_id, provider) is the
|
|
// PK, and (provider, provider_id, item_type) prevents two content
|
|
// rows from claiming the same external ID. We want to silently skip
|
|
// when either fires; ON CONFLICT DO NOTHING (no target) catches both.
|
|
if _, err := s.fileRepo.Pool().Exec(ctx, `
|
|
INSERT INTO media_item_provider_ids (content_id, provider, provider_id, item_type)
|
|
VALUES ($1, 'asin', $2, 'audiobook')
|
|
ON CONFLICT DO NOTHING
|
|
`, contentID, parsed.ASIN); err != nil {
|
|
return fmt.Errorf("upsert audiobook ASIN provider id: %w", err)
|
|
}
|
|
}
|
|
s.autoLinkLiteraryWork(ctx, contentID)
|
|
slog.InfoContext(ctx, "audiobook scan: indexed", "component", "scanner",
|
|
"folder_id", folder.ID,
|
|
"content_id", contentID,
|
|
"title", parsed.Title,
|
|
"author", parsed.Author,
|
|
"files", len(parsed.Files),
|
|
)
|
|
return nil
|
|
}
|
|
|
|
func applyAudiobookEmbeddedCover(
|
|
ctx context.Context,
|
|
reader audiobookPosterPathReader,
|
|
exec audiobookPosterExec,
|
|
ffmpegPath string,
|
|
cacher audiobookCoverCacher,
|
|
audioFilePath string,
|
|
contentID string,
|
|
) (bool, error) {
|
|
if exec == nil {
|
|
return false, nil
|
|
}
|
|
if reader != nil {
|
|
existingPosterPath, err := reader.GetPosterPath(ctx, contentID)
|
|
if err != nil {
|
|
return false, fmt.Errorf("get audiobook poster path for embedded cover: %w", err)
|
|
}
|
|
if strings.TrimSpace(existingPosterPath) != "" {
|
|
return false, nil
|
|
}
|
|
}
|
|
poster, thumb := ExtractAndUploadAudiobookCover(ctx, ffmpegPath, cacher, audioFilePath, contentID)
|
|
if poster == "" {
|
|
return false, nil
|
|
}
|
|
_, err := exec.Exec(ctx, `
|
|
UPDATE media_items
|
|
SET poster_path = $1, poster_thumbhash = $2, updated_at = NOW()
|
|
WHERE content_id = $3 AND (poster_path IS NULL OR poster_path = '')
|
|
`, poster, thumb, contentID)
|
|
if err != nil {
|
|
return false, fmt.Errorf("update audiobook embedded cover: %w", err)
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// upsertAudiobookMediaItem reuses an item already linked to the same filesystem
|
|
// root, then falls back to the stricter ABS duplicate rule, or creates a new row.
|
|
// It intentionally avoids title/year-only dedupe because audiobooks often share
|
|
// titles, unknown years, or edition-specific credits. Returns the content_id used.
|
|
func (s *Scanner) upsertAudiobookMediaItem(ctx context.Context, folderID int, folderPath string, book *parsedAudiobook) (string, error) {
|
|
if s.itemRepo == nil {
|
|
return "", fmt.Errorf("itemRepo not configured on Scanner")
|
|
}
|
|
if s.fileRepo == nil {
|
|
return "", fmt.Errorf("fileRepo not configured on Scanner")
|
|
}
|
|
|
|
existingID, err := s.fileRepo.FindContentIDByRootPath(ctx, folderID, folderPath, "audiobook")
|
|
if err != nil {
|
|
return "", fmt.Errorf("find audiobook by root path: %w", err)
|
|
}
|
|
if existingID != "" {
|
|
if err := s.updateExistingAudiobookMediaItem(ctx, existingID, book); err != nil {
|
|
return "", err
|
|
}
|
|
return existingID, nil
|
|
}
|
|
|
|
cleanTitle := stripNarratorSuffix(book.Title)
|
|
if cleanTitle == "" {
|
|
cleanTitle = book.Title
|
|
}
|
|
|
|
if existing := s.findAudiobookByFilePath(ctx, folderID, book); existing != nil {
|
|
applyBookToMediaItem(existing, book)
|
|
if existing.SortTitle == "" {
|
|
existing.SortTitle = titleutil.DeriveDefaultSortTitle(existing.Title)
|
|
}
|
|
if err := s.itemRepo.Upsert(ctx, existing); err != nil {
|
|
return "", err
|
|
}
|
|
return existing.ContentID, nil
|
|
}
|
|
|
|
// Secondary dedup: catch books stored under two folders ("Foo" + "Foo
|
|
// Subtitle Version"). Same scan-time rule as the one-shot
|
|
// scripts/dedup_audiobooks.py — author + narrator + year + duration
|
|
// within tolerance + title is equal or an exact "X" / "X: subtitle"
|
|
// prefix relation. Attaches the new file to the existing row so we
|
|
// don't pile up duplicates as the scan progresses.
|
|
if existing := s.findAudiobookDuplicate(ctx, book, cleanTitle); existing != nil {
|
|
applyBookToMediaItem(existing, book)
|
|
if existing.SortTitle == "" {
|
|
existing.SortTitle = titleutil.DeriveDefaultSortTitle(existing.Title)
|
|
}
|
|
if err := s.itemRepo.Upsert(ctx, existing); err != nil {
|
|
return "", err
|
|
}
|
|
return existing.ContentID, nil
|
|
}
|
|
|
|
return createAudiobookMediaItem(ctx, s.itemRepo, book, cleanTitle)
|
|
}
|
|
|
|
func (s *Scanner) updateExistingAudiobookMediaItem(ctx context.Context, contentID string, book *parsedAudiobook) error {
|
|
items, err := s.itemRepo.GetByIDs(ctx, []string{contentID})
|
|
if err != nil {
|
|
return fmt.Errorf("get audiobook media item %s: %w", contentID, err)
|
|
}
|
|
if len(items) == 0 || items[0] == nil {
|
|
return fmt.Errorf("audiobook media item %s not found", contentID)
|
|
}
|
|
item := items[0]
|
|
applyBookToMediaItem(item, book)
|
|
if item.SortTitle == "" {
|
|
item.SortTitle = titleutil.DeriveDefaultSortTitle(item.Title)
|
|
}
|
|
return s.itemRepo.Upsert(ctx, item)
|
|
}
|
|
|
|
func resolveAudiobookMediaItem(
|
|
ctx context.Context,
|
|
rootFinder filesystemRootContentFinder,
|
|
itemWriter filesystemMediaItemWriter,
|
|
folderID int,
|
|
folderPath string,
|
|
book *parsedAudiobook,
|
|
) (string, error) {
|
|
if rootFinder == nil {
|
|
return "", fmt.Errorf("root content finder not configured")
|
|
}
|
|
if itemWriter == nil {
|
|
return "", fmt.Errorf("media item writer not configured")
|
|
}
|
|
existingID, err := rootFinder.FindContentIDByRootPath(ctx, folderID, folderPath, "audiobook")
|
|
if err != nil {
|
|
return "", fmt.Errorf("find audiobook by root path: %w", err)
|
|
}
|
|
if existingID != "" {
|
|
return existingID, nil
|
|
}
|
|
|
|
cleanTitle := stripNarratorSuffix(book.Title)
|
|
if cleanTitle == "" {
|
|
cleanTitle = book.Title
|
|
}
|
|
return createAudiobookMediaItem(ctx, itemWriter, book, cleanTitle)
|
|
}
|
|
|
|
func createAudiobookMediaItem(ctx context.Context, itemWriter filesystemMediaItemWriter, book *parsedAudiobook, cleanTitle string) (string, error) {
|
|
id, err := idgen.NextID()
|
|
if err != nil {
|
|
return "", fmt.Errorf("generate content_id: %w", err)
|
|
}
|
|
item := &models.MediaItem{
|
|
ContentID: id,
|
|
Type: "audiobook",
|
|
Title: cleanTitle,
|
|
SortTitle: titleutil.DeriveDefaultSortTitle(cleanTitle),
|
|
Year: book.Year,
|
|
}
|
|
applyBookToMediaItem(item, book)
|
|
if item.SortTitle == "" {
|
|
item.SortTitle = titleutil.DeriveDefaultSortTitle(item.Title)
|
|
}
|
|
if err := itemWriter.Upsert(ctx, item); err != nil {
|
|
return "", err
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
// findAudiobookDuplicate returns an existing audiobook media_items row that
|
|
// matches the parsed book on (author, narrator, year, duration ±0.5%/±10s,
|
|
// punctuation-normalized title). Used after the file-path lookup misses to
|
|
// detect the same recording stored under a different folder name. Returns nil
|
|
// when no match exists or any required attribute (author, narrator, year,
|
|
// duration) is missing — under-tagged files don't qualify for automatic dedup.
|
|
func (s *Scanner) findAudiobookDuplicate(ctx context.Context, book *parsedAudiobook, cleanTitle string) *models.MediaItem {
|
|
if s.fileRepo == nil {
|
|
return nil
|
|
}
|
|
if book.Author == "" || book.Narrator == "" || book.Year == 0 {
|
|
return nil
|
|
}
|
|
var totalDuration int
|
|
for _, f := range book.Files {
|
|
totalDuration += f.Duration
|
|
}
|
|
if totalDuration <= 0 {
|
|
return nil
|
|
}
|
|
tolerance := totalDuration / 200 // 0.5%
|
|
if tolerance < 10 {
|
|
tolerance = 10
|
|
}
|
|
titleKey := normalizeAudiobookDedupeTitle(cleanTitle)
|
|
if titleKey == "" {
|
|
return nil
|
|
}
|
|
rows, err := s.fileRepo.Pool().Query(ctx,
|
|
audiobookDuplicateCandidateSQL,
|
|
book.Author,
|
|
book.Narrator,
|
|
book.Year,
|
|
totalDuration,
|
|
tolerance,
|
|
titleKey,
|
|
)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defer rows.Close()
|
|
|
|
var existingID string
|
|
for rows.Next() {
|
|
var candidateID, candidateTitle string
|
|
if err := rows.Scan(&candidateID, &candidateTitle); err != nil {
|
|
return nil
|
|
}
|
|
if audiobookDedupeTitlesMatch(candidateTitle, cleanTitle) {
|
|
existingID = candidateID
|
|
break
|
|
}
|
|
}
|
|
if rows.Err() != nil || existingID == "" {
|
|
return nil
|
|
}
|
|
|
|
items, err := s.itemRepo.GetByIDs(ctx, []string{existingID})
|
|
if err != nil || len(items) == 0 {
|
|
return nil
|
|
}
|
|
return items[0]
|
|
}
|
|
|
|
// findAudiobookByFilePath returns an existing audiobook media_items row
|
|
// whose media_files reference any of this book's audio file paths.
|
|
// Returns nil when no match exists (a fresh scan of a new folder).
|
|
func (s *Scanner) findAudiobookByFilePath(ctx context.Context, folderID int, book *parsedAudiobook) *models.MediaItem {
|
|
if s.fileRepo == nil || len(book.Files) == 0 {
|
|
return nil
|
|
}
|
|
paths := audiobookLookupPaths(book.Files)
|
|
if len(paths) == 0 {
|
|
return nil
|
|
}
|
|
var existingID string
|
|
err := s.fileRepo.Pool().QueryRow(ctx, `
|
|
SELECT mf.content_id
|
|
FROM media_files mf
|
|
JOIN media_items mi ON mi.content_id = mf.content_id
|
|
WHERE mf.media_folder_id = $2
|
|
AND (mf.file_path = ANY($1) OR LOWER(mf.file_path) = ANY($1))
|
|
AND mi.type = 'audiobook'
|
|
LIMIT 1
|
|
`, paths, folderID).Scan(&existingID)
|
|
if err != nil || existingID == "" {
|
|
return nil
|
|
}
|
|
items, err := s.itemRepo.GetByIDs(ctx, []string{existingID})
|
|
if err != nil || len(items) == 0 {
|
|
return nil
|
|
}
|
|
return items[0]
|
|
}
|
|
|
|
func audiobookLookupPaths(files []parsedAudiobookFile) []string {
|
|
seen := make(map[string]struct{}, len(files)*2)
|
|
paths := make([]string, 0, len(files)*2)
|
|
for _, f := range files {
|
|
path := strings.TrimSpace(f.Path)
|
|
if path == "" {
|
|
continue
|
|
}
|
|
for _, candidate := range []string{path, strings.ToLower(path)} {
|
|
if candidate == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[candidate]; ok {
|
|
continue
|
|
}
|
|
seen[candidate] = struct{}{}
|
|
paths = append(paths, candidate)
|
|
}
|
|
}
|
|
return paths
|
|
}
|
|
|
|
// applyBookToMediaItem copies parsed-audiobook tag fields onto the
|
|
// MediaItem. Used for both fresh inserts and re-scans of existing rows
|
|
// so manual edits to fields not driven by the file (e.g., poster_path
|
|
// set by the metadata enricher) survive.
|
|
//
|
|
// Audiobook titles are stripped of trailing narrator/edition suffixes
|
|
// (`Foo (Read by Bar)`, `Foo - read by Bar`, etc.) since that data is
|
|
// already captured as item_people kind=8. The raw tag value is preserved
|
|
// in OriginalTitle when it differs so the original is never lost.
|
|
func applyBookToMediaItem(item *models.MediaItem, book *parsedAudiobook) {
|
|
item.Type = "audiobook"
|
|
raw := strings.TrimSpace(book.Title)
|
|
cleaned := stripNarratorSuffix(raw)
|
|
if cleaned == "" {
|
|
cleaned = raw
|
|
}
|
|
item.Title = cleaned
|
|
if cleaned != raw && item.OriginalTitle == "" {
|
|
item.OriginalTitle = raw
|
|
}
|
|
item.Year = book.Year
|
|
if book.Overview != "" && item.Overview == "" {
|
|
item.Overview = book.Overview
|
|
}
|
|
if book.Publisher != "" {
|
|
item.Studios = mergeUniqueStrings(item.Studios, []string{book.Publisher})
|
|
}
|
|
if len(book.Genres) > 0 {
|
|
item.Genres = mergeUniqueStrings(item.Genres, book.Genres)
|
|
}
|
|
if rd := normalizeReleaseDateForSQL(book.ReleaseDate); rd != "" && (item.ReleaseDate == nil || *item.ReleaseDate == "") {
|
|
item.ReleaseDate = &rd
|
|
}
|
|
if book.Language != "" && item.OriginalLanguage == "" {
|
|
item.OriginalLanguage = book.Language
|
|
}
|
|
}
|
|
|
|
// normalizeReleaseDateForSQL coerces tag-derived date strings into the
|
|
// ISO YYYY-MM-DD shape media_items.release_date expects. Year-only
|
|
// tags ("2018") become "2018-01-01"; ISO already-correct values pass
|
|
// through; everything else returns "" (skip the column).
|
|
func normalizeReleaseDateForSQL(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
if len(s) >= 10 && s[4] == '-' && s[7] == '-' {
|
|
return s[:10]
|
|
}
|
|
if len(s) >= 4 {
|
|
y := s[:4]
|
|
ok := true
|
|
for _, c := range y {
|
|
if c < '0' || c > '9' {
|
|
ok = false
|
|
break
|
|
}
|
|
}
|
|
if ok {
|
|
return y + "-01-01"
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func mergeUniqueStrings(existing, additions []string) []string {
|
|
seen := make(map[string]struct{}, len(existing)+len(additions))
|
|
out := make([]string, 0, len(existing)+len(additions))
|
|
for _, v := range existing {
|
|
k := strings.ToLower(strings.TrimSpace(v))
|
|
if k == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[k]; ok {
|
|
continue
|
|
}
|
|
seen[k] = struct{}{}
|
|
out = append(out, v)
|
|
}
|
|
for _, v := range additions {
|
|
k := strings.ToLower(strings.TrimSpace(v))
|
|
if k == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[k]; ok {
|
|
continue
|
|
}
|
|
seen[k] = struct{}{}
|
|
out = append(out, v)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// upsertAudiobookMediaFiles writes one media_files row per audio file in the
|
|
// parsed audiobook. The content_id ties each file back to the media_items row.
|
|
// folderPath is used as the canonical_root_path / observed_root_path.
|
|
func (s *Scanner) upsertAudiobookMediaFiles(
|
|
ctx context.Context,
|
|
folder *models.MediaFolder,
|
|
contentID string,
|
|
folderPath string,
|
|
book *parsedAudiobook,
|
|
) error {
|
|
partTotal := len(book.Files)
|
|
for idx, af := range book.Files {
|
|
chapters := make([]models.MediaChapter, len(af.Chapters))
|
|
for i, ch := range af.Chapters {
|
|
chapters[i] = models.MediaChapter{
|
|
Index: ch.Index,
|
|
Title: ch.Title,
|
|
StartSeconds: ch.StartSeconds,
|
|
EndSeconds: ch.EndSeconds,
|
|
Source: ch.Source,
|
|
}
|
|
}
|
|
|
|
mf := models.MediaFile{
|
|
ContentID: contentID,
|
|
MediaFolderID: folder.ID,
|
|
CanonicalRootPath: folderPath,
|
|
ObservedRootPath: folderPath,
|
|
ContentGroupKey: contentID,
|
|
GroupKeyVersion: 1,
|
|
BaseTitle: book.Title,
|
|
BaseYear: book.Year,
|
|
BaseType: "audiobook",
|
|
IdentityConfidence: audiobookIdentityConfidence(book, af),
|
|
FilePath: af.Path,
|
|
Chapters: chapters,
|
|
ProbeSource: "local",
|
|
Duration: af.Duration,
|
|
Bitrate: af.Bitrate,
|
|
CodecAudio: af.CodecAudio,
|
|
Container: af.Container,
|
|
AudioChannels: af.AudioChannels,
|
|
}
|
|
if partTotal > 1 {
|
|
mf.PresentationKind = "multipart"
|
|
mf.PresentationGroupKey = contentID
|
|
mf.PresentationPartIndex = idx + 1
|
|
mf.PresentationPartTotal = partTotal
|
|
}
|
|
|
|
if _, err := s.fileRepo.Upsert(ctx, mf); err != nil {
|
|
return fmt.Errorf("upsert media file %s: %w", af.Path, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// audiobookCredit pairs a person name with a credit kind. Used to compare
|
|
// the desired-from-tags set against the existing item_people set without
|
|
// having to materialize a full []models.ItemPerson (which requires
|
|
// resolved person IDs).
|
|
type audiobookCredit struct {
|
|
Name string
|
|
Kind models.PersonKind
|
|
}
|
|
|
|
// audiobookPeopleCreditsEqual returns true when the existing item_people
|
|
// rows for an audiobook match the desired credit set one-for-one on
|
|
// (case-insensitive name, kind). Order is irrelevant because the upsert
|
|
// path orders by SortOrder.
|
|
//
|
|
// Case-insensitive comparison: audiobook tag casing drifts between rips
|
|
// and we don't want a stylistic re-cap to trigger DELETE+INSERT on every
|
|
// scan.
|
|
func audiobookPeopleCreditsEqual(existing []models.ItemPerson, desired []audiobookCredit) bool {
|
|
if len(existing) != len(desired) {
|
|
return false
|
|
}
|
|
type key struct {
|
|
name string
|
|
kind models.PersonKind
|
|
}
|
|
have := make(map[key]struct{}, len(existing))
|
|
for _, p := range existing {
|
|
have[key{strings.ToLower(strings.TrimSpace(p.Person.Name)), p.Kind}] = struct{}{}
|
|
}
|
|
for _, d := range desired {
|
|
k := key{strings.ToLower(strings.TrimSpace(d.Name)), d.Kind}
|
|
if _, ok := have[k]; !ok {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// upsertAudiobookPeople upserts author and narrator rows into item_people,
|
|
// using the PersonRepository to find-or-create each person by name. When the
|
|
// parsed credits differ, ReplacePeople makes scanner-derived credits authoritative.
|
|
// Skips the DELETE+INSERT entirely when the existing credit set already
|
|
// matches the desired set (case-insensitive on name, exact on kind).
|
|
func (s *Scanner) upsertAudiobookPeople(ctx context.Context, contentID string, book *parsedAudiobook) error {
|
|
if s.personRepo == nil {
|
|
return fmt.Errorf("personRepo not configured on Scanner")
|
|
}
|
|
|
|
var desired []audiobookCredit
|
|
if book.Author != "" {
|
|
desired = append(desired, audiobookCredit{Name: book.Author, Kind: models.PersonKindAuthor})
|
|
}
|
|
if book.Narrator != "" {
|
|
desired = append(desired, audiobookCredit{Name: book.Narrator, Kind: models.PersonKindNarrator})
|
|
}
|
|
if len(desired) == 0 {
|
|
return nil
|
|
}
|
|
|
|
existing, err := s.itemRepo.GetPeople(ctx, contentID)
|
|
if err == nil && audiobookPeopleCreditsEqual(existing, desired) {
|
|
return nil
|
|
}
|
|
|
|
people := make([]models.ItemPerson, 0, len(desired))
|
|
for i, c := range desired {
|
|
personID, err := s.personRepo.FindOrCreate(ctx, models.Person{Name: c.Name})
|
|
if err != nil {
|
|
return fmt.Errorf("find-or-create person %q: %w", c.Name, err)
|
|
}
|
|
people = append(people, models.ItemPerson{
|
|
Person: models.Person{ID: personID},
|
|
Kind: c.Kind,
|
|
SortOrder: i,
|
|
})
|
|
}
|
|
|
|
return s.itemRepo.ReplacePeople(ctx, contentID, people)
|
|
}
|
|
|
|
// upsertAudiobookSeries writes the parsed series_name and series_index into
|
|
// the audiobook_series table, overwriting any prior row (e.g. one populated
|
|
// by migration 145's title-pattern backfill). A blank book.Series clears
|
|
// the row so books explicitly retagged out of a series stop appearing in
|
|
// the "In this series" rail on the next scan.
|
|
func (s *Scanner) upsertAudiobookSeries(ctx context.Context, contentID string, book *parsedAudiobook) error {
|
|
if s.fileRepo == nil {
|
|
return fmt.Errorf("fileRepo not configured on Scanner")
|
|
}
|
|
desiredName := strings.TrimSpace(book.Series)
|
|
desiredIdx := parseSeriesIndex(book.SeriesPosition)
|
|
|
|
// Read current row.
|
|
var currentName *string
|
|
var currentIdx *float64
|
|
err := s.fileRepo.Pool().QueryRow(ctx, `
|
|
SELECT series_name, series_index FROM audiobook_series WHERE content_id = $1
|
|
`, contentID).Scan(¤tName, ¤tIdx)
|
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
|
return fmt.Errorf("query audiobook_series: %w", err)
|
|
}
|
|
|
|
if desiredName == "" {
|
|
if currentName == nil {
|
|
return nil // already absent
|
|
}
|
|
if _, delErr := s.fileRepo.Pool().Exec(ctx,
|
|
`DELETE FROM audiobook_series WHERE content_id = $1`, contentID); delErr != nil {
|
|
return fmt.Errorf("delete audiobook_series row: %w", delErr)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if currentName != nil && *currentName == desiredName && floatPtrEqual(currentIdx, desiredIdx) {
|
|
return nil // identical row, skip the write
|
|
}
|
|
|
|
var idx any
|
|
if desiredIdx != nil {
|
|
idx = *desiredIdx
|
|
}
|
|
if _, err := s.fileRepo.Pool().Exec(ctx, `
|
|
INSERT INTO audiobook_series (content_id, series_name, series_index, updated_at)
|
|
VALUES ($1, $2, $3, NOW())
|
|
ON CONFLICT (content_id) DO UPDATE SET
|
|
series_name = EXCLUDED.series_name,
|
|
series_index = EXCLUDED.series_index,
|
|
updated_at = NOW()
|
|
`, contentID, desiredName, idx); err != nil {
|
|
return fmt.Errorf("upsert audiobook_series row: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func audiobookIdentityConfidence(book *parsedAudiobook, file parsedAudiobookFile) string {
|
|
if book == nil {
|
|
return "low"
|
|
}
|
|
|
|
score := 0
|
|
if book.Title != "" {
|
|
score++
|
|
}
|
|
if book.Author != "" || book.Narrator != "" {
|
|
score++
|
|
}
|
|
if book.Year > 0 {
|
|
score++
|
|
}
|
|
for _, ch := range file.Chapters {
|
|
if ch.Title != "" && ch.EndSeconds > ch.StartSeconds {
|
|
score++
|
|
break
|
|
}
|
|
}
|
|
|
|
switch {
|
|
case score >= 4:
|
|
return "high"
|
|
case score > 0:
|
|
return "medium"
|
|
default:
|
|
return "low"
|
|
}
|
|
}
|
|
|
|
// floatPtrEqual returns true when two *float64 values represent the same
|
|
// state — both nil, or both non-nil and equal. Used to skip audiobook_series
|
|
// re-upserts when only the series_index needs to be NULL=NULL compared.
|
|
func floatPtrEqual(a, b *float64) bool {
|
|
if a == nil && b == nil {
|
|
return true
|
|
}
|
|
if a == nil || b == nil {
|
|
return false
|
|
}
|
|
return *a == *b
|
|
}
|
|
|
|
// parseSeriesIndex extracts a leading numeric value from a freeform tag
|
|
// like "5", "1.5", "2 of 8", or "1a". Returns nil when no leading number
|
|
// is present so the audiobook_series.series_index column stays NULL rather
|
|
// than carrying a misleading zero.
|
|
func parseSeriesIndex(raw string) *float64 {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return nil
|
|
}
|
|
end := 0
|
|
dot := false
|
|
for end < len(raw) {
|
|
c := raw[end]
|
|
if c >= '0' && c <= '9' {
|
|
end++
|
|
continue
|
|
}
|
|
if c == '.' && !dot {
|
|
dot = true
|
|
end++
|
|
continue
|
|
}
|
|
break
|
|
}
|
|
if end == 0 {
|
|
return nil
|
|
}
|
|
var v float64
|
|
if _, err := fmt.Sscanf(raw[:end], "%f", &v); err != nil {
|
|
return nil
|
|
}
|
|
return &v
|
|
}
|