Release / build-and-push (push) Successful in 3m5s
The archive pass looped over the scan's folders and, inside each
iteration, compared the ENTIRE library's item list against that one
folder's walk. With two registered folders every item living under the
other folder was flagged "missing (1/2)" on every reconciling scan —
prod's audiobook library (the only multi-folder one) showed 743/743
flagged — and nothing reached the two-consecutive-miss threshold only
because each scan's walk clears missing_scan_count before the pass
re-flags it. With three or more folders the defect stops bouncing: a
later iteration sees an earlier iteration's count-1 marks on items its
own walk lacks and archives them mid-pass — one registered folder away
from archiving a third of a library per scan. Single-folder libraries
were unaffected, which is why comics/ebooks/manga showed zero flags.
Folder-book presence was suspected alongside it (audioBookRoots read
during the comparison), but the map is written for every >=2-track
folder-book root on every walk (processAudioBookFile, since 6216bd9b);
the flagged folder-books were victims of the same cross-folder
comparison. The roots attribution did carry the same unchecked
strings.HasPrefix boundary bug as the rest of the scanner (8th site,
variable named `root`, missed by the earlier sweep); it now uses
folderContains.
The pass now groups folders by owning library and, per library,
compares ListMediaItemsByLibraryIncludingArchived against the UNION of
all that library's folders' walks. The coverage (partial-scope) guard,
the walk-failure skip, and the mark/archive/purge item loop are
unchanged.
Regression test (archive_multifolder_test.go) builds a two-folder
audiobooks library — single-file book in one folder, folder-of-tracks
book in the other — and scans twice: pre-fix both items are flagged
missing on both scans (verified by running the test against the old
code), post-fix neither is ever flagged or archived.
5127 lines
174 KiB
Go
5127 lines
174 KiB
Go
// Package services provides the core business logic layer for bookhoard,
|
|
// including media scanning, library management, search, analytics, and conversion services.
|
|
package services
|
|
|
|
import (
|
|
"archive/tar"
|
|
"archive/zip"
|
|
"bookhoard/internal/config"
|
|
"bookhoard/internal/database"
|
|
"bookhoard/internal/utils"
|
|
"bytes"
|
|
"compress/bzip2"
|
|
"compress/gzip"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"errors"
|
|
"fmt"
|
|
"image"
|
|
_ "image/jpeg"
|
|
_ "image/png"
|
|
"io"
|
|
"io/fs"
|
|
"math"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/microcosm-cc/bluemonday"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"bookhoard/internal/sevenzip"
|
|
|
|
epub "github.com/ArcadiaLin/go-epub"
|
|
"github.com/fsnotify/fsnotify"
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/nwaples/rardecode"
|
|
pdfcpuapi "github.com/pdfcpu/pdfcpu/pkg/api"
|
|
)
|
|
|
|
// MediaMetadata contains extracted metadata for media files (ebooks, comics, manga)
|
|
type MediaMetadata struct {
|
|
Title string
|
|
Subtitle string
|
|
Author string // legacy single-value fill; Authors[] is the write truth
|
|
Authors []string
|
|
Description string
|
|
Series string
|
|
SeriesNumber float64
|
|
Publisher string // legacy single-value fill; Publishers[] is the write truth
|
|
Publishers []string
|
|
PublishDate time.Time
|
|
Contributors []string
|
|
CoverPath string
|
|
ISBN string
|
|
ASIN string
|
|
Tags []string
|
|
Genre string
|
|
Explicit bool
|
|
|
|
FileHashInfo *HashInfo
|
|
FileFormats []*FormatInfo
|
|
|
|
// Reading direction fields (from ComicInfo.xml or computed)
|
|
MangaType string // Raw ComicInfo.xml Manga field
|
|
ReadingDirection string // Computed: auto, ltr, rtl, vertical
|
|
Language string // ISO 639-1 language code
|
|
|
|
// Additional metadata fields (from ComicInfo.xml or other metadata sources)
|
|
// Universal fields (apply to ebooks, audiobooks, comics)
|
|
SeriesCount int32 // Total items in series (Count field for comics, series count for books)
|
|
Volume int32 // Volume/omnibus number
|
|
Imprint string // Publisher imprint
|
|
AgeRating string // Age rating (Everyone, Teen, Mature, Adult)
|
|
WebURL string // URL to info page (Goodreads, ComicVine, etc.)
|
|
MetadataNotes string // Notes from metadata files (not user notes)
|
|
CommunityRating float64 // Pre-existing community rating (0-10)
|
|
PageCount int32 // Actual page count (images for comics, pages for PDF)
|
|
TotalCharacters int64 // Total text characters (for reflowable EPUBs)
|
|
ChapterCount int32 // Number of chapters detected
|
|
|
|
// Comic-specific fields
|
|
StoryArc string // Story arc name
|
|
IsBlackAndWhite bool // Black and white flag
|
|
AlternateInfo string // JSONB string of alternate series info
|
|
ScanInformation string // Scan information
|
|
Summary string // Summary from ComicInfo.xml
|
|
|
|
// A2c: ordering-construct series entries parsed from the sidecar
|
|
// ("The Witcher (Reading Order) #5"). They never touch the series
|
|
// columns — the collections layer curates them into playlists.
|
|
PlaylistSeries []SeriesCandidate
|
|
|
|
// Audiobook fields (media_items.audio_metadata JSONB — only the
|
|
// audio paths read it; see media_scanner_audio.go)
|
|
AudioMeta *AudioMetadataPayload
|
|
|
|
// Narrators/abridged land in real media_items columns (A2a): the
|
|
// editorial surface of audiobooks (edit form, filters, search),
|
|
// while AudioMeta stays scan-derived.
|
|
Narrators []string
|
|
Abridged bool
|
|
}
|
|
|
|
type HashInfo struct {
|
|
FileSHA256 string
|
|
OPFIdentifier string
|
|
OPFUUID string
|
|
HashConfidence string
|
|
}
|
|
|
|
type FormatInfo struct {
|
|
FormatType string
|
|
FilePath string
|
|
FileSHA256 string
|
|
FileSizeBytes int64
|
|
MimeType string
|
|
}
|
|
|
|
// MediaScanner scans library folders for media files (ebooks, comics, manga)
|
|
type MediaScanner struct {
|
|
db *database.Queries
|
|
watcher *fsnotify.Watcher
|
|
folders []string
|
|
adminID pgtype.UUID
|
|
defaultLibraryID pgtype.UUID
|
|
libraryTypes map[string][]string
|
|
forceRescan bool
|
|
archiveRetentionDays int
|
|
logger *ScannerLogger
|
|
dirtyDirs map[string]time.Time
|
|
dirtyDirsMu sync.RWMutex
|
|
fileStability map[string]*atomic.Bool
|
|
fileStabilityMu sync.RWMutex
|
|
scanMutex sync.Mutex
|
|
scanInProgress atomic.Bool
|
|
watching atomic.Bool
|
|
settingsCache *SettingsCache
|
|
|
|
// evBudgetRemaining is the per-scan extractor-version catch-up
|
|
// budget (faceted plan Phase 10): how many ev-mismatch books this
|
|
// pass may still re-extract. Reset at the top of ScanFolders;
|
|
// atomic because the watcher's scanDirectory pass can overlap a
|
|
// poll scan. Zero → ev-mismatch books wait for a later poll.
|
|
evBudgetRemaining atomic.Int64
|
|
|
|
totalFiles int
|
|
newItems int
|
|
errors int
|
|
job *Job
|
|
|
|
// audioBookRoots tracks the folder-book roots (absolute paths)
|
|
// already ingested during THIS scan walk, so per-track walk visits
|
|
// skip instead of reprocessing. Reset at the top of ScanFolders; the
|
|
// archive pass reuses it to keep book roots off the missing list.
|
|
audioBookRoots map[string]bool
|
|
|
|
// Bulk scan state (faceted plan Part 2 §2): one GetScanStateByLibrary
|
|
// load per library replaces the per-file GetMediaItemByFilePath flood.
|
|
// Keyed by "libraryID\x00relative-path" (multi-folder libraries make a
|
|
// bare relative path ambiguous); nil map = per-file lookups (the
|
|
// watcher's scanDirectory path). Guarded by its own mutex — scans can
|
|
// overlap on one instance.
|
|
scanState map[string]*scanEntry
|
|
scanStateMu sync.RWMutex
|
|
folderLibrary map[string]pgtype.UUID
|
|
|
|
// Summary + cache state (faceted plan Part 2 §4-6): scanTrigger
|
|
// labels the per-scan summary line (poll/watcher/admin/initial);
|
|
// changedItems counts re-processes for it; siblingBookCache caches
|
|
// hasSiblingBookFile's ReadDir per directory for one scan (loose-
|
|
// image manga re-stats thousands of images in a handful of dirs).
|
|
scanTrigger string
|
|
changedItems int
|
|
siblingBookCache map[string]bool
|
|
}
|
|
|
|
// scanEntry is one media item's scanner-owned facts, bulk-loaded per
|
|
// scan: everything the unchanged-skip path needs, nothing else. The
|
|
// manifest field feeds Phase 4's sidecar change detection (NULL until
|
|
// the first manifest pass writes it).
|
|
type scanEntry struct {
|
|
ID pgtype.UUID
|
|
FilePath string // relative, as stored
|
|
FileSize int64
|
|
Archived bool
|
|
MissingCount int32
|
|
FileSha256 string
|
|
Manifest []byte
|
|
}
|
|
|
|
// CalibreOPFMetadata represents intermediate parsed metadata from Calibre metadata.opf files
|
|
type CalibreOPFMetadata struct {
|
|
Title string
|
|
Authors []string
|
|
Tags []string
|
|
Description string
|
|
Publisher string
|
|
PublishDate *time.Time
|
|
Language string
|
|
ISBN string
|
|
ASIN string
|
|
UUID string
|
|
Contributors []string
|
|
Series string
|
|
SeriesIndex *float64
|
|
Rating *int32
|
|
Timestamp *time.Time
|
|
}
|
|
|
|
// NewMediaScanner creates a new media scanner instance.
|
|
//
|
|
// The fsnotify watcher is NOT created here. It is created lazily inside
|
|
// SetFolders only when watch=true (the long-lived watch-mode scanner).
|
|
// Ephemeral one-off scan jobs pass watch=false, so they never allocate a
|
|
// watcher (and thus can never panic on EMFILE/ENOSPC). This fixes the
|
|
// fd/inotify-watch leak where every scan job created a watcher that was
|
|
// never closed.
|
|
// nilToEmpty guards the NOT NULL narrators columns (A2): a Go nil slice
|
|
// becomes an explicit NULL in INSERT/UPDATE params and 23502s — the
|
|
// column DEFAULT never fires for explicit NULLs. "No narrators known"
|
|
// is an empty array, never NULL.
|
|
func nilToEmpty(slice []string) []string {
|
|
if slice == nil {
|
|
return []string{}
|
|
}
|
|
return slice
|
|
}
|
|
|
|
func NewMediaScanner(db *database.Queries) *MediaScanner {
|
|
return &MediaScanner{
|
|
db: db,
|
|
watcher: nil,
|
|
archiveRetentionDays: config.ArchiveRetentionDays(),
|
|
settingsCache: NewSettingsCache(30 * time.Second),
|
|
dirtyDirs: make(map[string]time.Time),
|
|
fileStability: make(map[string]*atomic.Bool),
|
|
watching: atomic.Bool{},
|
|
scanInProgress: atomic.Bool{},
|
|
folders: []string{},
|
|
adminID: pgtype.UUID{},
|
|
defaultLibraryID: pgtype.UUID{Valid: false},
|
|
libraryTypes: make(map[string][]string),
|
|
logger: NewScannerLogger(),
|
|
}
|
|
}
|
|
|
|
func (s *MediaScanner) GetPollInterval() time.Duration {
|
|
if cached, ok := s.settingsCache.Get("scan_poll_interval_seconds"); ok {
|
|
if seconds, err := strconv.Atoi(cached); err == nil {
|
|
return time.Duration(seconds) * time.Second
|
|
}
|
|
}
|
|
|
|
if s.db == nil {
|
|
return 5 * time.Minute
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
setting, err := s.db.GetSystemSetting(ctx, "scan_poll_interval_seconds")
|
|
if err != nil || setting == "" {
|
|
return 5 * time.Minute
|
|
}
|
|
|
|
s.settingsCache.Set("scan_poll_interval_seconds", setting)
|
|
|
|
seconds, err := strconv.Atoi(setting)
|
|
if err != nil {
|
|
return 5 * time.Minute
|
|
}
|
|
return time.Duration(seconds) * time.Second
|
|
}
|
|
|
|
// GetExtractorVersion is the parser version stamped on each book's
|
|
// scan manifest (faceted plan Phase 10). Read through the same 30s-TTL
|
|
// settings cache as GetPollInterval — a bump via the SETTINGS API (the
|
|
// only sanctioned write path) applies on the next read within TTL.
|
|
// Version 1 is the shipped baseline parser; absent/garbage rows ride it.
|
|
func (s *MediaScanner) GetExtractorVersion() int {
|
|
if cached, ok := s.settingsCache.Get("scan_extractor_version"); ok {
|
|
if v, err := strconv.Atoi(cached); err == nil && v >= 1 {
|
|
return v
|
|
}
|
|
return 1 // cached garbage: the registry-clamped default
|
|
}
|
|
|
|
if s.db == nil {
|
|
return 1
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
setting, err := s.db.GetSystemSetting(ctx, "scan_extractor_version")
|
|
if err != nil || setting == "" {
|
|
return 1
|
|
}
|
|
|
|
s.settingsCache.Set("scan_extractor_version", setting)
|
|
|
|
v, err := strconv.Atoi(setting)
|
|
if err != nil || v < 1 {
|
|
return 1
|
|
}
|
|
return v
|
|
}
|
|
|
|
func (s *MediaScanner) GetAutoScanEnabled() bool {
|
|
// Check cache first
|
|
if cached, ok := s.settingsCache.Get("auto_scan_enabled"); ok {
|
|
return strings.ToLower(cached) == "true"
|
|
}
|
|
|
|
if s.db == nil {
|
|
return true
|
|
}
|
|
|
|
// Cache miss - query database
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
setting, err := s.db.GetSystemSetting(ctx, "auto_scan_enabled")
|
|
if err != nil || setting == "" {
|
|
return true
|
|
}
|
|
|
|
// Store in cache
|
|
s.settingsCache.Set("auto_scan_enabled", setting)
|
|
|
|
return strings.ToLower(setting) == "true"
|
|
}
|
|
|
|
// InvalidateSettingsCache drops all cached setting values so the next read
|
|
// re-queries system_settings. Called when scan settings are saved; without it
|
|
// saved values only take effect after the cache TTL (30s).
|
|
func (s *MediaScanner) InvalidateSettingsCache() {
|
|
s.settingsCache.Invalidate()
|
|
}
|
|
|
|
// GetAutoPlaylistCollections mirrors GetAutoScanEnabled for the A2c
|
|
// playlist-collection toggle. Defaults ON: the feature only creates
|
|
// deletable curated collections from explicit "(Reading Order)" markers.
|
|
func (s *MediaScanner) GetAutoPlaylistCollections() bool {
|
|
if cached, ok := s.settingsCache.Get("auto_playlist_collections"); ok {
|
|
return strings.ToLower(cached) == "true"
|
|
}
|
|
if s.db == nil {
|
|
return true
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
setting, err := s.db.GetSystemSetting(ctx, "auto_playlist_collections")
|
|
if err != nil || setting == "" {
|
|
return true
|
|
}
|
|
s.settingsCache.Set("auto_playlist_collections", setting)
|
|
return strings.ToLower(setting) == "true"
|
|
}
|
|
|
|
// syncPlaylistCollectionsAfterCreate turns a created item's playlist-marked
|
|
// series entries (A2c §2.2) into curated static collections (§2.4):
|
|
// upsert by imported_from_series provenance then name, add-if-missing with
|
|
// position ONLY on insert, provenance in description + view_settings,
|
|
// dashboard-eligible by default. Deleting a sidecar later deletes nothing —
|
|
// the collection is user data from the moment it exists.
|
|
func (s *MediaScanner) syncPlaylistCollectionsAfterCreate(ctx context.Context, itemID pgtype.UUID, entries []SeriesCandidate) {
|
|
if len(entries) == 0 || s.db == nil || !s.GetAutoPlaylistCollections() {
|
|
return
|
|
}
|
|
for _, entry := range entries {
|
|
collection, created, err := s.upsertPlaylistCollection(ctx, entry)
|
|
if err != nil {
|
|
fmt.Printf("Warning: playlist collection sync failed for %q: %v\n", entry.Name, err)
|
|
continue
|
|
}
|
|
position := cleanIntegerPosition(entry.Sequence)
|
|
if _, err := s.db.AddBookToCollectionOrdered(ctx, database.AddBookToCollectionOrderedParams{
|
|
CollectionID: collection.ID,
|
|
MediaItemID: itemID,
|
|
AddedByUserID: s.adminID,
|
|
Position: pgtype.Int4{Int32: position, Valid: position > 0},
|
|
}); err != nil {
|
|
fmt.Printf("Warning: playlist collection add failed for %q: %v\n", entry.Name, err)
|
|
continue
|
|
}
|
|
if created {
|
|
fmt.Printf("Created playlist collection %q from sidecar series (safe to edit or delete)\n", entry.Name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// upsertPlaylistCollection finds the playlist collection for a sidecar
|
|
// series: provenance key first (survives renames), name second.
|
|
func (s *MediaScanner) upsertPlaylistCollection(ctx context.Context, entry SeriesCandidate) (database.Collections, bool, error) {
|
|
owner := s.adminID
|
|
if collection, err := s.db.GetCollectionByImportedSeries(ctx, database.GetCollectionByImportedSeriesParams{
|
|
UserID: owner,
|
|
ImportedFromSeries: entry.Name,
|
|
}); err == nil {
|
|
return collection, false, nil
|
|
}
|
|
if collection, err := s.db.GetCollectionByUserAndName(ctx, database.GetCollectionByUserAndNameParams{
|
|
UserID: owner,
|
|
Name: entry.Name,
|
|
}); err == nil {
|
|
// Name match, no provenance: mark it so future scans find it by
|
|
// provenance and don't spawn a duplicate after a rename.
|
|
s.markPlaylistProvenance(ctx, collection.ID, entry.Name)
|
|
return collection, false, nil
|
|
}
|
|
|
|
description := fmt.Sprintf("Imported from metadata.json sidecar (series %q) — safe to edit or delete.", entry.Name)
|
|
viewSettings, _ := json.Marshal(map[string]interface{}{
|
|
"show_up_next": true,
|
|
"imported_from_series": entry.Name,
|
|
})
|
|
created, err := s.db.CreateCollection(ctx, database.CreateCollectionParams{
|
|
UserID: owner,
|
|
Name: entry.Name,
|
|
Description: pgtype.Text{String: description, Valid: true},
|
|
AutoAssignRules: nil, // jsonb NULL — an empty []byte encodes as bytea and 22P02s
|
|
ViewSettings: viewSettings,
|
|
ShowOnDashboard: pgtype.Bool{Bool: true, Valid: true}, // playlists land on the dashboard by design; hide via dashboard prefs
|
|
})
|
|
if err != nil {
|
|
return database.Collections{}, false, err
|
|
}
|
|
return created, true, nil
|
|
}
|
|
|
|
func (s *MediaScanner) markPlaylistProvenance(ctx context.Context, collectionID pgtype.UUID, seriesName string) {
|
|
collection, err := s.db.GetCollection(ctx, collectionID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
var settings map[string]interface{}
|
|
if len(collection.ViewSettings) > 0 {
|
|
_ = json.Unmarshal(collection.ViewSettings, &settings)
|
|
}
|
|
if settings == nil {
|
|
settings = map[string]interface{}{}
|
|
}
|
|
settings["imported_from_series"] = seriesName
|
|
settings["show_up_next"] = true
|
|
b, err := json.Marshal(settings)
|
|
if err != nil {
|
|
return
|
|
}
|
|
s.db.UpdateCollectionViewSettings(ctx, database.UpdateCollectionViewSettingsParams{
|
|
ID: collectionID,
|
|
ViewSettings: b,
|
|
})
|
|
}
|
|
|
|
// cleanIntegerPosition accepts only CLEAN integer sequences ("3") as a
|
|
// playlist position; decimals ("1.5") and lettered ("1a") stay un-positioned
|
|
// (ordering falls back to added_at) rather than guessing.
|
|
func cleanIntegerPosition(sequence string) int32 {
|
|
sequence = strings.TrimSpace(sequence)
|
|
if sequence == "" {
|
|
return 0
|
|
}
|
|
f, err := strconv.ParseFloat(sequence, 64)
|
|
if err != nil || f != math.Trunc(f) || f < 1 || f > 2147483647 {
|
|
return 0
|
|
}
|
|
return int32(f)
|
|
}
|
|
|
|
func (s *MediaScanner) SetAdminID(adminID pgtype.UUID) {
|
|
s.adminID = adminID
|
|
}
|
|
|
|
func (s *MediaScanner) SetLibraryID(libraryID pgtype.UUID) {
|
|
s.defaultLibraryID = libraryID
|
|
}
|
|
|
|
func (s *MediaScanner) SetForce(force bool) {
|
|
s.forceRescan = force
|
|
}
|
|
|
|
// SetTrigger labels the scan's summary line (plan Part 2 §4): poll,
|
|
// watcher, admin, initial.
|
|
func (s *MediaScanner) SetTrigger(trigger string) {
|
|
s.scanTrigger = trigger
|
|
}
|
|
|
|
func (s *MediaScanner) GetStats() (int, int, int) {
|
|
return s.totalFiles, s.newItems, s.errors
|
|
}
|
|
|
|
// SetFolders configures the scanner's folders and (optionally) sets up an
|
|
// fsnotify watcher over the full directory tree.
|
|
//
|
|
// watch should be true only for the single long-lived watch-mode scanner that
|
|
// actually consumes watcher.Events. Ephemeral scan jobs must pass false so no
|
|
// watcher (and thus no fd/inotify watches) is allocated — the watcher is never
|
|
// read by scan jobs and previously leaked one watcher per job.
|
|
func (s *MediaScanner) SetFolders(folders []string, watch bool) error {
|
|
s.folders = folders
|
|
|
|
// Always close any previously-owned watcher so reconfiguration doesn't leak.
|
|
if s.watcher != nil {
|
|
if err := s.watcher.Close(); err != nil {
|
|
fmt.Printf("Warning: failed to close old watcher during folder reconfiguration: %v\n", err)
|
|
}
|
|
s.watcher = nil
|
|
}
|
|
|
|
// Create + populate a fresh watcher only when the caller intends to read events.
|
|
if watch {
|
|
watcher, err := fsnotify.NewWatcher()
|
|
if err != nil {
|
|
// Return an error instead of panicking so a failed watcher can't
|
|
// take down the whole process.
|
|
return fmt.Errorf("failed to create watcher: %w", err)
|
|
}
|
|
s.watcher = watcher
|
|
}
|
|
|
|
// Build cache of allowed extensions per folder
|
|
// Uses Go AllowedExtensions map as source of truth (not DB)
|
|
s.libraryTypes = make(map[string][]string)
|
|
ctx := context.Background()
|
|
|
|
for _, folder := range folders {
|
|
lib, err := s.db.GetLibraryByFolder(ctx, folder)
|
|
if err != nil {
|
|
fmt.Printf("Warning: failed to get library for folder %s: %v\n", folder, err)
|
|
continue
|
|
}
|
|
|
|
libType, err := s.db.GetLibraryType(ctx, lib.LibraryTypeID)
|
|
if err != nil {
|
|
fmt.Printf("Warning: failed to get library type for %s: %v\n", folder, err)
|
|
continue
|
|
}
|
|
|
|
if exts, ok := AllowedExtensions[libType.Name]; ok {
|
|
s.libraryTypes[folder] = exts
|
|
fmt.Printf("Scanner: Folder %s (type: %s) allows extensions: %v\n",
|
|
folder, libType.Name, exts)
|
|
} else {
|
|
s.libraryTypes[folder] = libType.AllowedExtensions
|
|
fmt.Printf("Scanner: Folder %s (type: %s) using DB extensions (no Go map entry): %v\n",
|
|
folder, libType.Name, libType.AllowedExtensions)
|
|
}
|
|
}
|
|
|
|
// Add all folders and their subdirectories to the watcher (like Audiobookshelf).
|
|
// Only when watching; scan jobs (watch=false) skip this entirely.
|
|
if s.watcher != nil {
|
|
// Capability probe (plan Part 2 §4): ONE Add on a root folder
|
|
// before committing. inotify-broken deployments (watch limits,
|
|
// network mounts) used to spam one warning per directory; on
|
|
// probe failure the watcher is disabled with a single line — it
|
|
// stays an optional latency reducer over the scheduled scans.
|
|
if err := s.watcher.Add(folders[0]); err != nil {
|
|
fmt.Printf("[WATCHER] Disabled: cannot watch %s (%v) — scheduled scans unaffected, only change-triggered latency is lost\n", folders[0], err)
|
|
_ = s.watcher.Close()
|
|
s.watcher = nil
|
|
} else {
|
|
watchCount := 1
|
|
for _, folder := range folders {
|
|
_ = s.watcher.Add(folder) // probe proved the capability; failures here are counted, not spammed
|
|
filepath.WalkDir(folder, func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !d.IsDir() || path == folder {
|
|
return nil
|
|
}
|
|
if err := s.watcher.Add(path); err == nil {
|
|
watchCount++
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
fmt.Printf("[WATCHER] Now watching %d directories across %d root folders\n", watchCount, len(folders))
|
|
}
|
|
} else {
|
|
fmt.Printf("[SCANNER] Configured %d root folders (watch mode disabled, no inotify watcher)\n", len(folders))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *MediaScanner) enqueueLibraryScan(rootFolder, trigger string) {
|
|
if s.db == nil {
|
|
return
|
|
}
|
|
|
|
libRow, err := s.db.GetLibraryByFolderPathPrefix(context.Background(), rootFolder)
|
|
if err != nil {
|
|
fmt.Printf("[MTIME-POLL] Warning: could not find library for %s: %v\n", rootFolder, err)
|
|
return
|
|
}
|
|
|
|
folders, err := s.db.GetLibraryFolders(context.Background(), libRow.LibraryID)
|
|
if err != nil {
|
|
fmt.Printf("[MTIME-POLL] Warning: could not get folders for library: %v\n", err)
|
|
return
|
|
}
|
|
|
|
folderPaths := make([]string, len(folders))
|
|
for i, f := range folders {
|
|
folderPaths[i] = f.FolderPath
|
|
}
|
|
|
|
adminIDStr := ""
|
|
if libRow.CreatedByAdminID.Valid {
|
|
adminIDStr = uuid.UUID(libRow.CreatedByAdminID.Bytes).String()
|
|
}
|
|
if adminIDStr == "" {
|
|
fmt.Printf("[MTIME-POLL] Library has no owner, falling back to first admin\n")
|
|
fallbackAdmin, err := s.db.GetFirstAdmin(context.Background())
|
|
if err != nil {
|
|
fmt.Printf("[MTIME-POLL] Warning: no admin found in database, skipping scan\n")
|
|
return
|
|
}
|
|
adminIDStr = uuid.UUID(fallbackAdmin.Bytes).String()
|
|
}
|
|
|
|
libraryIDStr := uuid.UUID(libRow.LibraryID.Bytes).String()
|
|
|
|
// Coalescing (plan Part 2 §5): a pending/running scan for the SAME
|
|
// library makes this enqueue a no-op — a poll tick that lands while
|
|
// a scan is still running must not stack a second full scan, and
|
|
// the bulk scan-state map leans on one-scan-per-library.
|
|
if WorkerInstance.HasActiveScanForLibrary(libraryIDStr) {
|
|
return
|
|
}
|
|
|
|
job := &Job{
|
|
ID: uuid.New().String(),
|
|
Type: JobTypeScan,
|
|
Status: JobStatusPending,
|
|
UserID: adminIDStr,
|
|
Context: context.Background(),
|
|
Params: map[string]any{
|
|
"library_id": libraryIDStr,
|
|
"folders": folderPaths,
|
|
"admin_id": adminIDStr,
|
|
"db": s.db,
|
|
"force": false,
|
|
"trigger": trigger,
|
|
},
|
|
}
|
|
|
|
if WorkerInstance != nil {
|
|
WorkerInstance.Enqueue(job)
|
|
}
|
|
}
|
|
|
|
func (s *MediaScanner) ScanFolders(ctx context.Context) error {
|
|
// Cancellation is checked at every stage boundary and inside the
|
|
// walk callback: the in-flight file finishes, then the scan aborts
|
|
// and reports context.Canceled (processJob classifies that as a
|
|
// cancelled job, not a failed one).
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
|
|
if len(s.folders) == 0 {
|
|
return fmt.Errorf("no folders set")
|
|
}
|
|
|
|
s.totalFiles = 0
|
|
s.newItems = 0
|
|
s.errors = 0
|
|
s.changedItems = 0
|
|
// Fresh parser-version catch-up budget every scan pass (faceted
|
|
// plan Phase 10): each pass re-extracts at most
|
|
// evReextractBudgetPerScan stale-parser books; the rest resume on
|
|
// later polls.
|
|
s.evBudgetRemaining.Store(evReextractBudgetPerScan)
|
|
s.audioBookRoots = make(map[string]bool)
|
|
// Bulk scan state: per-run reset; one load per distinct library.
|
|
s.scanStateMu.Lock()
|
|
s.scanState = make(map[string]*scanEntry)
|
|
s.folderLibrary = make(map[string]pgtype.UUID)
|
|
s.siblingBookCache = make(map[string]bool)
|
|
s.scanStateMu.Unlock()
|
|
loadedLibraries := make(map[string]bool)
|
|
|
|
for _, folder := range s.folders {
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
if err := filepath.WalkDir(folder, func(path string, d fs.DirEntry, err error) error {
|
|
if !d.IsDir() && s.isScannableFile(path) {
|
|
s.totalFiles++
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
fmt.Printf("Warning: failed to count files in %s: %v\n", folder, err)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
fmt.Printf("Starting scan of %d folders: %v (%d files to scan)\n", len(s.folders), s.folders, s.totalFiles)
|
|
|
|
scanStart := time.Now()
|
|
processedFiles := 0
|
|
|
|
for _, folder := range s.folders {
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
|
|
fmt.Printf("Scanning folder: %s\n", folder)
|
|
|
|
if _, err := os.Stat(folder); os.IsNotExist(err) {
|
|
fmt.Printf("Folder does not exist: %s\n", folder)
|
|
s.errors++
|
|
continue
|
|
}
|
|
|
|
// Resolve this folder's library once and cache the bulk scan
|
|
// state for it (one query per library, not per file).
|
|
lib, err := s.db.GetLibraryByFolder(ctx, folder)
|
|
if err != nil {
|
|
fmt.Printf("Warning: no library found for folder %s, skipping: %v\n", folder, err)
|
|
s.errors++
|
|
continue
|
|
}
|
|
s.scanStateMu.Lock()
|
|
s.folderLibrary[folder] = lib.LibraryID
|
|
s.scanStateMu.Unlock()
|
|
if !loadedLibraries[lib.LibraryID.String()] {
|
|
if err := s.loadScanState(ctx, lib.LibraryID); err != nil {
|
|
fmt.Printf("Warning: bulk scan-state load failed for %s, falling back to per-file lookups: %v\n", lib.Name, err)
|
|
}
|
|
loadedLibraries[lib.LibraryID.String()] = true
|
|
}
|
|
|
|
err = filepath.WalkDir(folder, func(path string, d fs.DirEntry, err error) error {
|
|
if ctx.Err() != nil {
|
|
return fs.SkipAll
|
|
}
|
|
|
|
if err != nil {
|
|
fmt.Printf("Error accessing path %s: %v\n", path, err)
|
|
s.errors++
|
|
return err
|
|
}
|
|
|
|
if d.IsDir() {
|
|
if s.watcher != nil {
|
|
if err := s.watcher.Add(path); err != nil {
|
|
fmt.Printf("Warning: failed to watch subdirectory %s: %v\n", path, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if s.isScannableFile(path) {
|
|
processedFiles++
|
|
|
|
if processedFiles%10 == 0 && s.totalFiles > 0 {
|
|
progress := float64(processedFiles) / float64(s.totalFiles)
|
|
if s.job != nil {
|
|
s.job.UpdateProgress(progress, processedFiles, s.newItems, s.errors)
|
|
}
|
|
}
|
|
|
|
// Multi-file audiobooks first: zip-of-audio books and
|
|
// folder-of-tracks books ingest as whole units, and tracks
|
|
// inside an already-ingested root skip entirely. Everything
|
|
// else (lone audio files, all non-audio media) falls
|
|
// through to the single-file path below.
|
|
if handled, err := s.maybeProcessAudioBook(ctx, path); handled {
|
|
if err != nil {
|
|
fmt.Printf("Error processing audio book %s: %v\n", path, err)
|
|
s.errors++
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Quiet scans (plan Part 2 §4): inserts, changes and
|
|
// errors log inside processMediaFile — the walk itself
|
|
// stays silent so an unchanged library costs ~1 summary
|
|
// line, not one per file.
|
|
if _, err := s.processMediaFile(ctx, path); err != nil {
|
|
fmt.Printf("Error processing media file %s: %v\n", path, err)
|
|
s.errors++
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
s.errors++
|
|
return fmt.Errorf("failed to scan folder %s: %v", folder, err)
|
|
}
|
|
}
|
|
|
|
elapsed := time.Since(scanStart)
|
|
foldersLabel := strings.Join(s.folders, ", ")
|
|
if s.scanTrigger == "" {
|
|
s.scanTrigger = "unknown"
|
|
}
|
|
fmt.Printf("Scan of %s (trigger: %s): %d files, %d changed, %d new, %.1fs, %d errors\n",
|
|
foldersLabel, s.scanTrigger, processedFiles, s.changedItems, s.newItems, elapsed.Seconds(), s.errors)
|
|
|
|
// A cancelled scan must not run the archive lifecycle pass below:
|
|
// it never visited every folder, so unvisited files would be
|
|
// treated as missing and archived (and eventually purged). Abort
|
|
// instead — cancelling never destroys library data.
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
|
|
// Archive lifecycle pass. Items whose files vanished from disk are
|
|
// archived after two consecutive missing scans (reading history kept,
|
|
// item hidden), and purged for good once archived older than the
|
|
// retention window (ARCHIVE_RETENTION_DAYS; 0 = manual purge only).
|
|
// Every branch logs - the previous hard-delete cleanup failed silently
|
|
// and left orphaned rows undetected.
|
|
// Group this scan's folders by owning library: the reconciliation is
|
|
// per LIBRARY, comparing every library item against the union of all
|
|
// its folders' walks. The old per-folder loop listed the whole
|
|
// library's items against each single folder's walk, so with two
|
|
// folders every item living under the other folder was flagged
|
|
// missing on every scan (prod: 743/743 audiobooks stuck at
|
|
// "missing (1/2)", unarchivable only because the walk kept clearing
|
|
// them) — and with three or more folders earlier iterations' marks
|
|
// crossed later ones' walks and archived real content mid-pass.
|
|
scannedSet := make(map[string]bool, len(s.folders))
|
|
for _, scanned := range s.folders {
|
|
scannedSet[scanned] = true
|
|
}
|
|
type archiveScope struct {
|
|
libraryID pgtype.UUID
|
|
name string
|
|
folders []string
|
|
}
|
|
scopes := make([]archiveScope, 0, len(s.folders))
|
|
scopeIndex := make(map[pgtype.UUID]int, len(s.folders))
|
|
for _, folder := range s.folders {
|
|
lib, err := s.db.GetLibraryByFolder(ctx, folder)
|
|
if err != nil {
|
|
fmt.Printf("[ARCHIVE] Warning: no library found for folder %s, skipping it in archive pass: %v\n", folder, err)
|
|
continue
|
|
}
|
|
idx, seen := scopeIndex[lib.LibraryID]
|
|
if !seen {
|
|
idx = len(scopes)
|
|
scopeIndex[lib.LibraryID] = idx
|
|
scopes = append(scopes, archiveScope{libraryID: lib.LibraryID, name: lib.Name})
|
|
}
|
|
scopes[idx].folders = append(scopes[idx].folders, folder)
|
|
}
|
|
|
|
for _, scope := range scopes {
|
|
if ctx.Err() != nil {
|
|
fmt.Printf("[ARCHIVE] Scan cancelled, stopping archive pass\n")
|
|
return ctx.Err()
|
|
}
|
|
libraryID := scope.libraryID
|
|
|
|
// Partial-scope guard (per-folder scans, 2026-10): the
|
|
// reconciliation compares EVERY library item against this scan's
|
|
// disk walk — a scan that covered only some of the library's
|
|
// registered folders (the LibraryPanel per-folder Scan buttons)
|
|
// saw nothing from the others and would mark their items missing
|
|
// → archived. Reconcile only when the scan covered all of them.
|
|
registeredFolders, regErr := s.db.GetLibraryFolders(ctx, libraryID)
|
|
if regErr != nil {
|
|
fmt.Printf("[ARCHIVE] Warning: failed to list folders for library, skipping archive pass: %v\n", regErr)
|
|
continue
|
|
}
|
|
covered := 0
|
|
for _, rf := range registeredFolders {
|
|
if scannedSet[rf.FolderPath] {
|
|
covered++
|
|
}
|
|
}
|
|
if covered < len(registeredFolders) {
|
|
fmt.Printf("[ARCHIVE] Skipping reconciliation for %s: scan covered %d of %d registered folders\n",
|
|
scope.name, covered, len(registeredFolders))
|
|
continue
|
|
}
|
|
|
|
dbItems, err := s.db.ListMediaItemsByLibraryIncludingArchived(ctx, libraryID)
|
|
if err != nil {
|
|
fmt.Printf("[ARCHIVE] Warning: failed to get library items for archive pass: %v\n", err)
|
|
continue
|
|
}
|
|
|
|
// Build the set of scanned paths across ALL of this library's
|
|
// folders.
|
|
scannedPaths := make(map[string]bool)
|
|
walkFailed := false
|
|
for _, folder := range scope.folders {
|
|
if err := filepath.WalkDir(folder, func(path string, d os.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
if !d.IsDir() && s.isScannableFile(path) {
|
|
scannedPaths[s.getRelativePath(path)] = true
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
fmt.Printf("[ARCHIVE] Warning: failed to walk directory %s, skipping archive pass: %v\n", folder, err)
|
|
walkFailed = true
|
|
break
|
|
}
|
|
}
|
|
if walkFailed {
|
|
continue // Skip this library to avoid false archivals
|
|
}
|
|
|
|
// Folder-book roots count as present: the item's file_path is the
|
|
// DIRECTORY, which the file walk above never emits. Attribution is
|
|
// boundary-safe — a sibling folder whose name merely extends this
|
|
// one's must not contribute its roots here.
|
|
for root := range s.audioBookRoots {
|
|
for _, folder := range scope.folders {
|
|
if folderContains(folder, root) {
|
|
scannedPaths[s.getRelativePath(root)] = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, item := range dbItems {
|
|
if item.FilePath == "" || scannedPaths[item.FilePath] {
|
|
continue // File present; unarchive is handled in processMediaFile
|
|
}
|
|
|
|
if item.ArchivedAt.Valid {
|
|
// Still missing and already archived: purge once past the
|
|
// retention window (0 = keep until manual purge).
|
|
if s.archiveRetentionDays > 0 && time.Now().AddDate(0, 0, -s.archiveRetentionDays).After(item.ArchivedAt.Time) {
|
|
if err := s.db.DeleteMediaItem(ctx, item.ID); err != nil {
|
|
fmt.Printf("[ARCHIVE] Error: failed to purge archived item %s: %v\n", item.Title, err)
|
|
s.logger.LogError(fmt.Sprintf("[ARCHIVE] ERROR: failed to purge archived item '%s': %v", item.Title, err))
|
|
} else {
|
|
fmt.Printf("[ARCHIVE] Purged archived item '%s' (retention %d days): %s\n", item.Title, s.archiveRetentionDays, item.FilePath)
|
|
s.logger.LogDelete(fmt.Sprintf("[ARCHIVE] Purged archived item '%s' after retention window (file missing at %s)", item.Title, item.FilePath))
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Missing but not yet archived.
|
|
if item.MissingScanCount >= 1 {
|
|
// Second consecutive missing scan: archive it.
|
|
if err := s.db.ArchiveMediaItem(ctx, item.ID); err != nil {
|
|
fmt.Printf("[ARCHIVE] Error: failed to archive item %s: %v\n", item.Title, err)
|
|
s.logger.LogError(fmt.Sprintf("[ARCHIVE] ERROR: failed to archive item '%s': %v", item.Title, err))
|
|
} else {
|
|
fmt.Printf("[ARCHIVE] Archived item '%s' (missing from disk for %d scans): %s\n", item.Title, item.MissingScanCount+1, item.FilePath)
|
|
s.logger.LogDelete(fmt.Sprintf("[ARCHIVE] Archived item '%s' (file missing from disk at %s)", item.Title, item.FilePath))
|
|
}
|
|
} else {
|
|
// First missing scan: mark, archive on the next one.
|
|
if err := s.db.MarkMediaItemMissing(ctx, item.ID); err != nil {
|
|
fmt.Printf("[ARCHIVE] Warning: failed to mark item missing %s: %v\n", item.Title, err)
|
|
} else {
|
|
fmt.Printf("[ARCHIVE] Item missing from disk (1/2 scans before archiving): %s\n", item.FilePath)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if s.job != nil && s.totalFiles > 0 {
|
|
s.job.UpdateProgress(1.0, processedFiles, s.newItems, s.errors)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *MediaScanner) isScannableFile(path string) bool {
|
|
ext := strings.ToLower(filepath.Ext(path))
|
|
|
|
// Find which folder this file belongs to
|
|
var folder string
|
|
for _, f := range s.folders {
|
|
if folderContains(f, path) {
|
|
folder = f
|
|
break
|
|
}
|
|
}
|
|
|
|
// If no folder match, don't scan
|
|
if folder == "" {
|
|
return false
|
|
}
|
|
|
|
// Get allowed extensions for this folder's library
|
|
allowed, ok := s.libraryTypes[folder]
|
|
if !ok {
|
|
// No library type info, skip file
|
|
fmt.Printf("Warning: No library type info for folder %s, skipping %s\n", folder, path)
|
|
return false
|
|
}
|
|
|
|
// Check if file extension is allowed for this library type
|
|
for _, allowedExt := range allowed {
|
|
if ext == strings.ToLower(allowedExt) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// extractFolderStructureMetadata extracts metadata from folder paths, prioritizing Calibre structure
|
|
func (s *MediaScanner) extractFolderStructureMetadata(path, rootFolder string) *MediaMetadata {
|
|
metadata := &MediaMetadata{}
|
|
|
|
// Get the relative path from root folder
|
|
relPath, err := filepath.Rel(rootFolder, path)
|
|
if err != nil {
|
|
return metadata
|
|
}
|
|
|
|
// Split into directory components
|
|
dir := filepath.Dir(relPath)
|
|
components := strings.Split(dir, string(filepath.Separator))
|
|
|
|
if len(components) < 2 {
|
|
return metadata // Not enough structure to extract
|
|
}
|
|
|
|
// Calibre structure detection
|
|
// Pattern 1: Author Name/Book Title/
|
|
// Pattern 2: Author Name/Series Name/Book Title/
|
|
// Pattern 3: Author Name/Series Name, Book #1 - Book Title/
|
|
|
|
author := strings.TrimSuffix(components[0], "_") // Remove trailing underscore if present
|
|
metadata.Author = strings.ReplaceAll(author, "_", " ")
|
|
|
|
if len(components) >= 3 {
|
|
// This might be a series structure
|
|
possibleSeries := components[1]
|
|
possibleTitle := components[2]
|
|
|
|
// Check for Calibre series format: "Series Name, Book #1 - Title"
|
|
seriesMatch := regexp.MustCompile(`^(.*),\s+Book\s+#(\d+)\s*-\s*(.*)$`).FindStringSubmatch(possibleSeries)
|
|
if len(seriesMatch) == 4 {
|
|
metadata.Series = strings.ReplaceAll(seriesMatch[1], "_", " ")
|
|
if seriesNum, err := strconv.ParseFloat(seriesMatch[2], 64); err == nil {
|
|
metadata.SeriesNumber = seriesNum
|
|
}
|
|
metadata.Title = strings.ReplaceAll(possibleTitle, "_", " ")
|
|
} else {
|
|
// Simple series structure: Author/Series/Title
|
|
metadata.Series = strings.ReplaceAll(possibleSeries, "_", " ")
|
|
metadata.Title = strings.ReplaceAll(possibleTitle, "_", " ")
|
|
|
|
// Try to extract series number from title
|
|
titleNumMatch := regexp.MustCompile(`^(.*)\s+(\d+(?:\.\d+)?)$`).FindStringSubmatch(metadata.Title)
|
|
if len(titleNumMatch) == 3 {
|
|
metadata.Title = titleNumMatch[1]
|
|
if seriesNum, err := strconv.ParseFloat(titleNumMatch[2], 64); err == nil {
|
|
metadata.SeriesNumber = seriesNum
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Simple structure: Author/Title — with the A2c conservative
|
|
// "Title: <series>, Book N" split when the post-colon tail is
|
|
// unambiguous (arbitrary colons never split).
|
|
if title, subtitle, split := splitTitleOnSubtitle(strings.ReplaceAll(components[1], "_", " ")); split {
|
|
metadata.Title = title
|
|
metadata.Subtitle = subtitle
|
|
} else {
|
|
metadata.Title = strings.ReplaceAll(components[1], "_", " ")
|
|
}
|
|
}
|
|
|
|
return metadata
|
|
}
|
|
|
|
var bookExtensions = map[string]bool{
|
|
".epub": true, ".pdf": true, ".mobi": true, ".azw": true, ".azw3": true,
|
|
".fb2": true, ".txt": true, ".rtf": true, ".docx": true,
|
|
".pdb": true, ".djvu": true,
|
|
".cbz": true, ".cbr": true, ".cb7": true, ".cbt": true,
|
|
}
|
|
|
|
func hasSiblingBookFile(dir string) bool {
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() && bookExtensions[strings.ToLower(filepath.Ext(entry.Name()))] {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// hasSiblingBookFileCached is the per-directory cached variant (plan
|
|
// Part 2 §6): loose-image manga stats thousands of images that share a
|
|
// handful of directories — one ReadDir per directory per scan, not per
|
|
// image. Cache is reset with the scan run; a nil cache (watcher path)
|
|
// computes uncached.
|
|
func (s *MediaScanner) hasSiblingBookFileCached(dir string) bool {
|
|
s.scanStateMu.RLock()
|
|
cached, ok := s.siblingBookCache[dir]
|
|
cache := s.siblingBookCache
|
|
s.scanStateMu.RUnlock()
|
|
if ok {
|
|
return cached
|
|
}
|
|
result := hasSiblingBookFile(dir)
|
|
if cache != nil {
|
|
s.scanStateMu.Lock()
|
|
cache[dir] = result
|
|
s.scanStateMu.Unlock()
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool, error) {
|
|
// Get file info
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
fmt.Printf("Failed to get file info for %s: %v\n", path, err)
|
|
return false, fmt.Errorf("failed to get file info: %v", err)
|
|
}
|
|
|
|
if isImageFile(path) && s.hasSiblingBookFileCached(filepath.Dir(path)) {
|
|
return false, nil
|
|
}
|
|
|
|
fileModTime := info.ModTime()
|
|
|
|
// Find library for this file's folder (cached per scan run — the
|
|
// folder loop used to issue a GetLibraryByFolder per file too).
|
|
var libraryID pgtype.UUID
|
|
if s.defaultLibraryID.Valid {
|
|
libraryID = s.defaultLibraryID
|
|
} else {
|
|
s.scanStateMu.RLock()
|
|
folderLibrary := s.folderLibrary
|
|
s.scanStateMu.RUnlock()
|
|
for _, folder := range s.folders {
|
|
if !folderContains(folder, path) {
|
|
continue
|
|
}
|
|
if libID, cached := folderLibrary[folder]; cached {
|
|
libraryID = libID
|
|
break
|
|
}
|
|
lib, err := s.db.GetLibraryByFolder(ctx, folder)
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to find library for folder %s: %v", folder, err)
|
|
}
|
|
libraryID = lib.LibraryID
|
|
s.scanStateMu.Lock()
|
|
if s.folderLibrary != nil {
|
|
s.folderLibrary[folder] = lib.LibraryID
|
|
}
|
|
s.scanStateMu.Unlock()
|
|
break
|
|
}
|
|
}
|
|
|
|
if !libraryID.Valid {
|
|
return false, fmt.Errorf("no library found for file path: %s", path)
|
|
}
|
|
|
|
// Bulk scan state (faceted plan Part 2 §2): a map hit answers
|
|
// "exists + size + flags" with zero DB reads; a map miss in bulk
|
|
// mode means NEW (the map holds every row of the library). A nil
|
|
// map (watcher's scanDirectory path) keeps the per-file lookup.
|
|
if entry, ok := s.scanStateLookup(libraryID, path); ok {
|
|
return s.processExistingViaScanState(ctx, entry, libraryID, path, info)
|
|
}
|
|
if s.scanState == nil {
|
|
existingItem, err := s.getMediaItemByFilePath(ctx, path, libraryID)
|
|
if err == nil {
|
|
return s.processExistingRow(ctx, existingItem, libraryID, path, info)
|
|
} else if !errors.Is(err, pgx.ErrNoRows) {
|
|
fmt.Printf("Database error checking media item existence: %v\n", err)
|
|
return false, fmt.Errorf("failed to check if media item exists: %v", err)
|
|
}
|
|
}
|
|
// Quiet path (plan Part 2 §4): map miss in bulk mode means NEW —
|
|
// proceed straight to extraction + create.
|
|
|
|
// Extract metadata from file first
|
|
metadata, extractErr := s.extractMetadata(path)
|
|
if extractErr != nil {
|
|
fmt.Printf("Warning: failed to extract metadata from %s: %v\n", path, extractErr)
|
|
metadata = &MediaMetadata{}
|
|
}
|
|
|
|
// Extract hash information during metadata extraction
|
|
hashInfo, formatInfo, err := s.extractHashInfo(path)
|
|
if err != nil {
|
|
fmt.Printf("Warning: failed to extract hash info from %s: %v\n", path, err)
|
|
hashInfo = &HashInfo{}
|
|
} else {
|
|
metadata.FileHashInfo = hashInfo
|
|
metadata.FileFormats = []*FormatInfo{formatInfo}
|
|
fmt.Printf("Hash info for %s: SHA256=%s, OPF_ID=%s, OPF_UUID=%s, Confidence=%s\n",
|
|
path, hashInfo.FileSHA256, hashInfo.OPFIdentifier, hashInfo.OPFUUID, hashInfo.HashConfidence)
|
|
}
|
|
|
|
// Content dedup: if an item with the same SHA-256 already exists in this
|
|
// library (same file at a different path), treat it as existing rather than
|
|
// creating a duplicate. The file bytes are identical, so metadata matches.
|
|
if hashInfo.FileSHA256 != "" {
|
|
existingByHash, err := s.db.GetMediaItemBySHA256AndLibrary(ctx, database.GetMediaItemBySHA256AndLibraryParams{
|
|
FileSha256: pgtype.Text{String: hashInfo.FileSHA256, Valid: true},
|
|
LibraryID: libraryID,
|
|
})
|
|
if err == nil && existingByHash.ID.Valid {
|
|
// Same content at a different path. If the old location is gone,
|
|
// the book was MOVED: repoint the row so history follows it and
|
|
// the archive pass doesn't cycle it into missing/archived. Only
|
|
// treat it as a duplicate copy when the old path still exists.
|
|
oldPathStillExists := false
|
|
for _, folder := range s.folders {
|
|
if _, statErr := os.Stat(filepath.Join(folder, existingByHash.FilePath)); statErr == nil {
|
|
oldPathStillExists = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !oldPathStillExists {
|
|
if err := s.db.MoveMediaItemFilePath(ctx, database.MoveMediaItemFilePathParams{
|
|
ID: existingByHash.ID,
|
|
FilePath: s.getRelativePath(path),
|
|
FileSize: pgtype.Int8{Int64: info.Size(), Valid: true},
|
|
}); err != nil {
|
|
fmt.Printf("Warning: failed to repoint moved media item %s -> %s: %v\n", existingByHash.FilePath, path, err)
|
|
} else {
|
|
fmt.Printf("[MOVE] Item content moved: %q -> %s (history preserved)\n", existingByHash.FilePath, path)
|
|
s.logger.LogDelete(fmt.Sprintf("[MOVE] Repointed item '%s' from %q to %s", existingByHash.Title, existingByHash.FilePath, path))
|
|
}
|
|
if s.forceRescan {
|
|
moved := existingByHash
|
|
moved.FilePath = s.getRelativePath(path)
|
|
moved.ArchivedAt = pgtype.Timestamptz{}
|
|
moved.MissingScanCount = 0
|
|
_ = s.updateMediaItem(ctx, moved, path)
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
fmt.Printf("Media item with same SHA-256 already exists in library (path %q), skipping duplicate: %s\n",
|
|
existingByHash.FilePath, path)
|
|
// Content returned (possibly at a new path): restore archived rows.
|
|
if existingByHash.ArchivedAt.Valid || existingByHash.MissingScanCount > 0 {
|
|
if err := s.db.ClearMediaItemArchive(ctx, existingByHash.ID); err != nil {
|
|
fmt.Printf("Warning: failed to unarchive media item %s: %v\n", existingByHash.FilePath, err)
|
|
} else if existingByHash.ArchivedAt.Valid {
|
|
fmt.Printf("[ARCHIVE] Restored from archive, identical content found at %s\n", path)
|
|
s.logger.LogDelete(fmt.Sprintf("[ARCHIVE] Restored archived item '%s' (identical content found at %s)", existingByHash.Title, path))
|
|
}
|
|
}
|
|
if s.forceRescan {
|
|
_ = s.updateMediaItem(ctx, existingByHash, path)
|
|
}
|
|
return false, nil
|
|
} else if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
|
fmt.Printf("Warning: failed to check media item by SHA-256 for %s: %v\n", path, err)
|
|
}
|
|
}
|
|
|
|
// Cross-library move detection: the file may have been moved into a
|
|
// different library of the same type (e.g. reorganizing ebooks into a
|
|
// new library). If identical content is registered elsewhere and the old
|
|
// file is gone from disk, repoint that row so reading history follows
|
|
// the book instead of creating a duplicate.
|
|
if hashInfo.FileSHA256 != "" {
|
|
if handled := s.detectCrossLibraryMove(ctx, hashInfo.FileSHA256, libraryID, path, info.Size()); handled {
|
|
return false, nil
|
|
}
|
|
}
|
|
|
|
// REMOVED: Comic metadata extraction now handled by mergeMetadata()
|
|
// This avoids duplicate extraction and ensures smart merging happens
|
|
|
|
// Try to get metadata from folder structure as fallback/enhancement
|
|
// Use the root folder that contains this file
|
|
var rootFolder string
|
|
for _, folder := range s.folders {
|
|
if folderContains(folder, path) {
|
|
rootFolder = folder
|
|
break
|
|
}
|
|
}
|
|
|
|
if rootFolder != "" {
|
|
folderMetadata := s.extractFolderStructureMetadata(path, rootFolder)
|
|
|
|
// Use folder metadata as fallback for missing information
|
|
if metadata.Title == "" && folderMetadata.Title != "" {
|
|
metadata.Title = folderMetadata.Title
|
|
}
|
|
if metadata.Author == "" && folderMetadata.Author != "" {
|
|
metadata.Author = folderMetadata.Author
|
|
}
|
|
if metadata.Series == "" && folderMetadata.Series != "" {
|
|
metadata.Series = folderMetadata.Series
|
|
}
|
|
if metadata.SeriesNumber == 0 && folderMetadata.SeriesNumber > 0 {
|
|
metadata.SeriesNumber = folderMetadata.SeriesNumber
|
|
}
|
|
}
|
|
|
|
// Audiobooks: sidecar narrators/chapters + ffprobe facts (incl. the
|
|
// full tag fallbacks) into the audio_metadata JSONB. Runs AFTER the
|
|
// folder-structure pass so the precedence chain holds: sidecars →
|
|
// folder structure → tags → filename. The payload's shape is the
|
|
// chapter abstraction every format writes.
|
|
if isAudioFile(path) {
|
|
if metadata.AudioMeta == nil {
|
|
metadata.AudioMeta = readSidecarAudio(path)
|
|
}
|
|
s.enrichAudioMetadata(path, metadata)
|
|
if metadata.CoverPath == "" {
|
|
s.applyEmbeddedCoverFallback(path, metadata)
|
|
}
|
|
}
|
|
|
|
// A2c §2.7: title/subtitle-derived series fallback. Lowest precedence —
|
|
// fills blanks only. "The Witcher, Book 3" as subtitle (or the post-colon
|
|
// segment of the title) yields series "The Witcher" + number 3, so
|
|
// Audible-style filenames carry working series data with no sidecar.
|
|
applyTitleDerivedSeries(metadata)
|
|
|
|
// Final fallback if still missing essential metadata
|
|
if metadata.Title == "" {
|
|
metadata.Title = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
|
}
|
|
if metadata.Author == "" {
|
|
metadata.Author = "Unknown"
|
|
}
|
|
|
|
// libraryID already determined at start of function
|
|
|
|
// Normalize metadata fields for display
|
|
metadata.Contributors = utils.NormalizeContributors(metadata.Contributors)
|
|
metadata.Tags = utils.NormalizeTags(metadata.Tags)
|
|
|
|
// Normalize search fields
|
|
contributorsSearch := utils.NormalizeContributorsSearch(metadata.Contributors)
|
|
tagsSearch := utils.NormalizeTagsSearch(metadata.Tags)
|
|
|
|
// Create media item in database
|
|
relativePath := s.getRelativePath(path)
|
|
createdItem, err := s.db.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
|
LibraryID: libraryID,
|
|
Title: metadata.Title,
|
|
Subtitle: pgtype.Text{String: metadata.Subtitle, Valid: metadata.Subtitle != ""},
|
|
Authors: nilToEmpty(utils.SingleOrSlice(metadata.Author, metadata.Authors)),
|
|
AuthorsSearch: utils.NormalizeTagsSearch(utils.SingleOrSlice(metadata.Author, metadata.Authors)),
|
|
Isbn: pgtype.Text{String: utils.NormalizeISBNSafe(metadata.ISBN), Valid: metadata.ISBN != ""},
|
|
Asin: pgtype.Text{String: metadata.ASIN, Valid: metadata.ASIN != ""},
|
|
Description: pgtype.Text{String: metadata.Description, Valid: metadata.Description != ""},
|
|
FilePath: relativePath,
|
|
FileSize: pgtype.Int8{Int64: info.Size(), Valid: true},
|
|
MimeType: pgtype.Text{String: s.getMimeType(path), Valid: true},
|
|
CoverImagePath: pgtype.Text{String: metadata.CoverPath, Valid: metadata.CoverPath != ""},
|
|
Series: pgtype.Text{String: metadata.Series, Valid: metadata.Series != ""},
|
|
SeriesNumber: pgtype.Float8{Float64: metadata.SeriesNumber, Valid: metadata.SeriesNumber > 0},
|
|
Publishers: nilToEmpty(utils.SingleOrSlice(metadata.Publisher, metadata.Publishers)),
|
|
PublishersSearch: utils.NormalizeTagsSearch(utils.SingleOrSlice(metadata.Publisher, metadata.Publishers)),
|
|
Language: pgtype.Text{String: metadata.Language, Valid: metadata.Language != ""},
|
|
DatePublished: pgtype.Date{Time: metadata.PublishDate, Valid: !metadata.PublishDate.IsZero()},
|
|
Genre: pgtype.Text{String: metadata.Genre, Valid: metadata.Genre != ""},
|
|
Contributors: metadata.Contributors,
|
|
ContributorsSearch: contributorsSearch,
|
|
Narrators: nilToEmpty(metadata.Narrators),
|
|
NarratorsSearch: utils.NormalizeNarratorsSearch(metadata.Narrators),
|
|
Abridged: metadata.Abridged,
|
|
Explicit: metadata.Explicit,
|
|
Tags: metadata.Tags,
|
|
TagsSearch: tagsSearch,
|
|
AddedByAdminID: s.adminID,
|
|
CreatedAt: pgtype.Timestamptz{Time: fileModTime, Valid: true},
|
|
ImportedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
|
MangaType: pgtype.Text{String: metadata.MangaType, Valid: metadata.MangaType != ""},
|
|
ReadingDirection: pgtype.Text{String: metadata.ReadingDirection, Valid: metadata.ReadingDirection != ""},
|
|
SeriesCount: pgtype.Int4{Int32: metadata.SeriesCount, Valid: metadata.SeriesCount > 0},
|
|
Volume: pgtype.Int4{Int32: metadata.Volume, Valid: metadata.Volume > 0},
|
|
Imprint: pgtype.Text{String: metadata.Imprint, Valid: metadata.Imprint != ""},
|
|
AgeRating: pgtype.Text{String: metadata.AgeRating, Valid: metadata.AgeRating != ""},
|
|
WebUrl: pgtype.Text{String: metadata.WebURL, Valid: metadata.WebURL != ""},
|
|
StoryArc: pgtype.Text{String: metadata.StoryArc, Valid: metadata.StoryArc != ""},
|
|
IsBlackAndWhite: pgtype.Bool{Bool: metadata.IsBlackAndWhite, Valid: true},
|
|
MetadataNotes: pgtype.Text{String: metadata.MetadataNotes, Valid: metadata.MetadataNotes != ""},
|
|
AlternateInfo: func() []byte {
|
|
if metadata.AlternateInfo != "" {
|
|
return []byte(metadata.AlternateInfo)
|
|
}
|
|
return nil
|
|
}(),
|
|
ScanInformation: pgtype.Text{String: metadata.ScanInformation, Valid: metadata.ScanInformation != ""},
|
|
Summary: pgtype.Text{String: metadata.Summary, Valid: metadata.Summary != ""},
|
|
CommunityRating: pgtype.Float8{Float64: metadata.CommunityRating, Valid: metadata.CommunityRating > 0},
|
|
PageCount: pgtype.Int4{Int32: metadata.PageCount, Valid: metadata.PageCount > 0},
|
|
AudioMetadata: marshalAudioMetadata(metadata.AudioMeta),
|
|
})
|
|
if err != nil {
|
|
// Ledger (faceted plan Phase 11): the file's media_items row was
|
|
// never created — record it ROW-LESS keyed by (library, path) so
|
|
// the Processing Issues page shows the failure; the file's next
|
|
// successful import resolves it.
|
|
if logErr := s.LogRowlessProcessingIssue(ctx,
|
|
uuid.UUID(libraryID.Bytes), relativePath,
|
|
"import_failed", fmt.Sprintf("File failed to import: %v", err), "error"); logErr != nil {
|
|
fmt.Printf("Warning: failed to record import-failure issue for %s: %v\n", relativePath, logErr)
|
|
}
|
|
return false, fmt.Errorf("failed to create media item: %v", err)
|
|
}
|
|
|
|
// Ledger self-heal (Phase 11): the file finally imports — close any
|
|
// open row-less import-failure issues keyed to this (library, path).
|
|
s.resolveIssuesForPath(ctx, libraryID, relativePath)
|
|
|
|
// Extraction-fallback import (Phase 11 ledger): extraction hard-failed
|
|
// but the import proceeded on fallback metadata (folder structure →
|
|
// filename → "Unknown"). The item EXISTS — record the issue
|
|
// item-backed as a warning so the page can flag degraded metadata.
|
|
if extractErr != nil {
|
|
if logErr := s.LogProcessingIssue(ctx,
|
|
uuid.UUID(createdItem.ID.Bytes), uuid.UUID(libraryID.Bytes),
|
|
"metadata_extraction_failed",
|
|
fmt.Sprintf("Metadata extraction failed; imported with fallback metadata: %v", extractErr),
|
|
"warning"); logErr != nil {
|
|
fmt.Printf("Warning: failed to record extraction issue for %s: %v\n", relativePath, logErr)
|
|
}
|
|
}
|
|
|
|
// Update hash information before database storage
|
|
if metadata.FileHashInfo != nil && metadata.FileHashInfo.FileSHA256 != "" {
|
|
_, err = s.db.UpdateMediaItemIdentifiers(ctx, database.UpdateMediaItemIdentifiersParams{
|
|
ID: createdItem.ID,
|
|
FileSha256: pgtype.Text{String: metadata.FileHashInfo.FileSHA256, Valid: true},
|
|
OpfIdentifier: pgtype.Text{String: metadata.FileHashInfo.OPFIdentifier, Valid: metadata.FileHashInfo.OPFIdentifier != ""},
|
|
OpfUuid: pgtype.Text{String: metadata.FileHashInfo.OPFUUID, Valid: metadata.FileHashInfo.OPFUUID != ""},
|
|
HashConfidence: pgtype.Text{String: metadata.FileHashInfo.HashConfidence, Valid: true},
|
|
})
|
|
if err != nil {
|
|
fmt.Printf("Warning: failed to update hash identifiers for %s: %v\n", path, err)
|
|
}
|
|
}
|
|
|
|
// Set format group, total characters, and chapter count
|
|
mimeType := s.getMimeType(path)
|
|
ext := strings.ToLower(filepath.Ext(path))
|
|
var epubIsFixedLayout bool
|
|
if ext == ".epub" {
|
|
isFixed, fixedErr := s.DetectFixedLayoutEPUB(path)
|
|
epubIsFixedLayout = fixedErr == nil && isFixed
|
|
}
|
|
formatGroup, isReflowable, hasFixedLayout := classifyFormatGroup(ext, epubIsFixedLayout)
|
|
err = s.db.UpdateMediaItemFormatGroup(ctx, database.UpdateMediaItemFormatGroupParams{
|
|
ID: createdItem.ID,
|
|
FormatGroup: formatGroup,
|
|
FormatMimetype: pgtype.Text{String: mimeType, Valid: mimeType != ""},
|
|
IsReflowable: pgtype.Bool{Bool: isReflowable, Valid: true},
|
|
HasFixedLayout: pgtype.Bool{Bool: hasFixedLayout, Valid: true},
|
|
TotalCharacters: pgtype.Int8{Int64: metadata.TotalCharacters, Valid: metadata.TotalCharacters > 0},
|
|
ChapterCount: pgtype.Int4{Int32: metadata.ChapterCount, Valid: metadata.ChapterCount > 0},
|
|
})
|
|
if err != nil {
|
|
fmt.Printf("Warning: failed to update format info for %s: %v\n", path, err)
|
|
}
|
|
|
|
// Store format information in the database
|
|
for _, format := range metadata.FileFormats {
|
|
_, err = s.db.CreateMediaItemFormat(ctx, database.CreateMediaItemFormatParams{
|
|
MediaItemID: createdItem.ID,
|
|
FormatType: format.FormatType,
|
|
FilePath: pgtype.Text{String: s.getRelativePath(format.FilePath), Valid: true},
|
|
FileSha256: pgtype.Text{String: format.FileSHA256, Valid: true},
|
|
FileSizeBytes: pgtype.Int8{Int64: format.FileSizeBytes, Valid: true},
|
|
MimeType: pgtype.Text{String: format.MimeType, Valid: true},
|
|
})
|
|
if err != nil {
|
|
fmt.Printf("Warning: failed to create format entry for %s: %v\n", path, err)
|
|
}
|
|
}
|
|
|
|
s.newItems++
|
|
fmt.Printf("[SCAN] Added new media item: %s\n", path)
|
|
|
|
// A2c §2.4: obvious playlist sidecar series become curated collections.
|
|
s.syncPlaylistCollectionsAfterCreate(ctx, createdItem.ID, metadata.PlaylistSeries)
|
|
|
|
// Write-through (faceted plan Part 2 §2): the create's facts land in
|
|
// the per-run map so later passes in this scan read post-insert
|
|
// state. Manifest stays NULL — Phase 4's manifest pass writes it.
|
|
sha := ""
|
|
if metadata.FileHashInfo != nil {
|
|
sha = metadata.FileHashInfo.FileSHA256
|
|
}
|
|
newEntry := &scanEntry{
|
|
ID: createdItem.ID,
|
|
FilePath: relativePath,
|
|
FileSize: info.Size(),
|
|
FileSha256: sha,
|
|
}
|
|
s.scanStateUpsert(libraryID, newEntry)
|
|
|
|
// Seed the sidecar manifest at create time (Part 2 §3): the next
|
|
// scan compares instead of backfilling, so an edit is detected on
|
|
// its first post-seed scan. Sidecar-less books get an entries-less
|
|
// manifest carrying the current ev (Phase 10) — the book was JUST
|
|
// extracted by this binary's parser, so the stamp is truthful and
|
|
// no re-extraction can be skipped by later bumps.
|
|
dir := bookSidecarDir(path)
|
|
if computed := statSidecarManifest(path, dir); computed != nil && len(computed.Entries) > 0 {
|
|
s.storeManifest(ctx, libraryID, newEntry, hashSidecarManifest(path, dir, computed))
|
|
} else {
|
|
s.storeManifest(ctx, libraryID, newEntry, &sidecarManifest{Version: sidecarManifestVersion})
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// processExistingRow is the nil-map (per-file lookup) existing-item
|
|
// branch, preserved from the pre-bulk-load behavior so the watcher's
|
|
// scanDirectory path and any bulk-load failure keep working exactly as
|
|
// before. Quiet-scan rules (plan Part 2 §4): only changes and errors
|
|
// log — unchanged skips are silent.
|
|
func (s *MediaScanner) processExistingRow(ctx context.Context, existingItem database.MediaItems, libraryID pgtype.UUID, path string, info os.FileInfo) (bool, error) {
|
|
// The file is back on disk: lift any archive/missing state so the
|
|
// item reappears in libraries and future missing scans start fresh.
|
|
if existingItem.ArchivedAt.Valid || existingItem.MissingScanCount > 0 {
|
|
if err := s.db.ClearMediaItemArchive(ctx, existingItem.ID); err != nil {
|
|
fmt.Printf("Warning: failed to unarchive media item %s: %v\n", existingItem.FilePath, err)
|
|
} else if existingItem.ArchivedAt.Valid {
|
|
fmt.Printf("[ARCHIVE] Restored from archive, file is back: %s\n", existingItem.FilePath)
|
|
s.logger.LogDelete(fmt.Sprintf("[ARCHIVE] Restored archived item '%s' (file returned at %s)", existingItem.Title, existingItem.FilePath))
|
|
}
|
|
}
|
|
|
|
// If force rescan is enabled, always re-process
|
|
if s.forceRescan {
|
|
// Use UPDATE instead of DELETE+INSERT to preserve created_at
|
|
if err := s.updateMediaItem(ctx, existingItem, path); err != nil {
|
|
fmt.Printf("Warning: failed to update existing media item: %v\n", err)
|
|
} else {
|
|
// Force rescan = original-scan semantics (faceted plan
|
|
// Phase 13): stamp the fresh manifest with the current
|
|
// parser version and close open ledger issues — success-
|
|
// only, same truthfulness rule as the bulk path.
|
|
dir := bookSidecarDir(path)
|
|
s.storeManifest(ctx, libraryID, &scanEntry{ID: existingItem.ID, FilePath: s.getRelativePath(path)},
|
|
hashSidecarManifest(path, dir, statSidecarManifest(path, dir)))
|
|
s.resolveIssuesForItem(ctx, existingItem.ID)
|
|
}
|
|
// Recompute hash identifiers too - a force rescan is the admin's
|
|
// backfill tool and must refresh stale or missing hashes.
|
|
s.recomputeHashInfo(ctx, existingItem.ID, libraryID, path)
|
|
s.changedItems++
|
|
return false, nil
|
|
}
|
|
// Normal behavior: check if file has changed (by size)
|
|
if existingItem.FileSize.Int64 != info.Size() {
|
|
fmt.Printf("File size changed, updating media item: %s\n", path)
|
|
_ = s.updateMediaItem(ctx, existingItem, path)
|
|
// The bytes changed, so any stored hash is stale.
|
|
s.recomputeHashInfo(ctx, existingItem.ID, libraryID, path)
|
|
s.changedItems++
|
|
return false, nil
|
|
}
|
|
// Self-heal items imported before hashing existed: even an unchanged
|
|
// file gets its hash computed if missing.
|
|
if !existingItem.FileSha256.Valid || existingItem.FileSha256.String == "" {
|
|
s.recomputeHashInfo(ctx, existingItem.ID, libraryID, path)
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// processExistingViaScanState is the bulk-state fast path (faceted plan
|
|
// Part 2 §2): archive restore + size compare with ZERO DB reads on the
|
|
// steady skip path. The rare branches (force rescan, size change)
|
|
// re-read the full row — the change path is allowed to be expensive.
|
|
// Write-through keeps later passes in the same run honest.
|
|
func (s *MediaScanner) processExistingViaScanState(ctx context.Context, entry *scanEntry, libraryID pgtype.UUID, path string, info os.FileInfo) (bool, error) {
|
|
if entry.Archived || entry.MissingCount > 0 {
|
|
if err := s.db.ClearMediaItemArchive(ctx, entry.ID); err != nil {
|
|
fmt.Printf("Warning: failed to unarchive media item %s: %v\n", entry.FilePath, err)
|
|
} else {
|
|
if entry.Archived {
|
|
fmt.Printf("[ARCHIVE] Restored from archive, file is back: %s\n", entry.FilePath)
|
|
s.logger.LogDelete(fmt.Sprintf("[ARCHIVE] Restored archived item '%s' (file returned at %s)", entry.FilePath, entry.FilePath))
|
|
}
|
|
entry.Archived = false
|
|
entry.MissingCount = 0
|
|
s.scanStateUpsert(libraryID, entry)
|
|
}
|
|
}
|
|
|
|
if s.forceRescan {
|
|
existingItem, err := s.getMediaItemByFilePath(ctx, path, libraryID)
|
|
if err != nil {
|
|
return false, fmt.Errorf("force rescan re-read failed: %v", err)
|
|
}
|
|
if err := s.updateMediaItem(ctx, existingItem, path); err != nil {
|
|
fmt.Printf("Warning: failed to update existing media item: %v\n", err)
|
|
} else {
|
|
// Force rescan = original-scan semantics (faceted plan
|
|
// Phase 13): the full extraction just re-derived this row,
|
|
// so stamp the fresh manifest with the current parser
|
|
// version and close its open ledger issues. Success-only —
|
|
// a stamp over a failed update would claim parser work
|
|
// that never ran.
|
|
dir := bookSidecarDir(path)
|
|
s.storeManifest(ctx, libraryID, entry, hashSidecarManifest(path, dir, statSidecarManifest(path, dir)))
|
|
s.resolveIssuesForItem(ctx, entry.ID)
|
|
}
|
|
// Recompute hash identifiers too - a force rescan is the admin's
|
|
// backfill tool and must refresh stale or missing hashes.
|
|
s.recomputeHashInfo(ctx, entry.ID, libraryID, path)
|
|
s.changedItems++
|
|
return false, nil
|
|
}
|
|
|
|
if entry.FileSize != info.Size() {
|
|
fmt.Printf("File size changed, updating media item: %s\n", path)
|
|
existingItem, err := s.getMediaItemByFilePath(ctx, path, libraryID)
|
|
if err != nil {
|
|
return false, fmt.Errorf("change-path re-read failed: %v", err)
|
|
}
|
|
if err := s.updateMediaItem(ctx, existingItem, path); err != nil {
|
|
fmt.Printf("Warning: failed to update media item: %v\n", err)
|
|
// Ledger (Phase 11): the item exists — record item-backed so
|
|
// the Processing Issues page flags the degraded metadata and
|
|
// offers a retry, instead of failing silently on every scan
|
|
// (the 2026-10 Crusader genre-22001 loop did exactly that).
|
|
// Same type the sidecar-change path uses; resolveIssuesForItem
|
|
// clears it when an update finally succeeds.
|
|
if logErr := s.LogProcessingIssue(ctx,
|
|
uuid.UUID(existingItem.ID.Bytes), uuid.UUID(libraryID.Bytes),
|
|
"metadata_extraction_failed",
|
|
fmt.Sprintf("Metadata update failed; row left unchanged: %v", err),
|
|
"warning"); logErr != nil {
|
|
fmt.Printf("Warning: failed to record update issue for %s: %v\n", path, logErr)
|
|
}
|
|
} else {
|
|
s.resolveIssuesForItem(ctx, existingItem.ID)
|
|
}
|
|
// The bytes changed, so any stored hash is stale.
|
|
s.recomputeHashInfo(ctx, entry.ID, libraryID, path)
|
|
entry.FileSize = info.Size()
|
|
s.scanStateUpsert(libraryID, entry)
|
|
s.changedItems++
|
|
return false, nil
|
|
}
|
|
|
|
// Manifest-gated sidecar detection (Part 2 §3): a size-stable book
|
|
// is changed by EDITING its sidecars. NULL stored manifest →
|
|
// backfill only; stat drift with equal hashes → repoint
|
|
// reconciliation; content drift → re-extract below.
|
|
if s.detectSidecarChange(ctx, entry, libraryID, path) {
|
|
fmt.Printf("Sidecar changed, re-extracting metadata: %s\n", path)
|
|
existingItem, err := s.getMediaItemByFilePath(ctx, path, libraryID)
|
|
if err != nil {
|
|
return false, fmt.Errorf("sidecar change re-read failed: %v", err)
|
|
}
|
|
// Extract once; the result also carries the sidecar's playlist
|
|
// entries for the sync below.
|
|
metadata, extractErr := s.extractMetadata(path)
|
|
dir := bookSidecarDir(path)
|
|
if extractErr != nil || metadata == nil {
|
|
// Extract-failure guard (faceted plan Phase 6 audit find):
|
|
// UpdateMediaItem writes editorial columns unconditionally,
|
|
// and preExtracted != nil disables the update path's
|
|
// filename-title guard — a non-nil empty fallback would blank
|
|
// the row. Keep the row untouched and refresh the manifest so
|
|
// the failure isn't re-detected every scan; Phase 9's ledger
|
|
// records the processing issue. No extractor error path is
|
|
// reachable today (mergeMetadata never fails) — this is the
|
|
// regression net for the class of bug the guard exists for.
|
|
fmt.Printf("Warning: sidecar re-extraction failed for %s: %v — row left unchanged\n", path, extractErr)
|
|
// Ledger (Phase 11): the item exists — record item-backed so
|
|
// the page flags degraded metadata. Baseline store, never a
|
|
// stamp: a stamp would record the current parser version on
|
|
// a row the current parser never successfully processed —
|
|
// for an ev-triggered change that would PERMANENTLY skip the
|
|
// fix. Leaving ev stale retries on the next poll
|
|
// (budget-bounded).
|
|
s.storeManifestBaseline(ctx, libraryID, entry, hashSidecarManifest(path, dir, statSidecarManifest(path, dir)))
|
|
if logErr := s.LogProcessingIssue(ctx,
|
|
uuid.UUID(entry.ID.Bytes), uuid.UUID(libraryID.Bytes),
|
|
"metadata_extraction_failed",
|
|
fmt.Sprintf("Metadata re-extraction failed; row left unchanged: %v", extractErr),
|
|
"warning"); logErr != nil {
|
|
fmt.Printf("Warning: failed to record extraction issue for %s: %v\n", path, logErr)
|
|
}
|
|
return false, nil
|
|
}
|
|
// Playlist entries ride the FIRST extraction either way — capture
|
|
// them before the reroute below.
|
|
playlistSeries := metadata.PlaylistSeries
|
|
if metadata.Title == "" {
|
|
// Empty-success extraction (a tag-less book whose sidecar
|
|
// vanished): preExtracted != nil would skip the update path's
|
|
// filename-title guard AND the audio tag re-derivation —
|
|
// blanking the title. Passing nil delegates to the function's
|
|
// own create-equivalent chain instead (sidecars → folder
|
|
// structure → tags → filename), mirroring the create path's
|
|
// last rung.
|
|
metadata = nil
|
|
}
|
|
if err := s.updateMediaItemWithMetadata(ctx, existingItem, path, metadata); err != nil {
|
|
fmt.Printf("Warning: failed to update media item after sidecar change %s: %v\n", path, err)
|
|
// Ledger: same item-backed record as the size-change path —
|
|
// a failed re-extraction must be visible and retryable, not a
|
|
// scan-log whisper.
|
|
if logErr := s.LogProcessingIssue(ctx,
|
|
uuid.UUID(existingItem.ID.Bytes), uuid.UUID(libraryID.Bytes),
|
|
"metadata_extraction_failed",
|
|
fmt.Sprintf("Metadata update after sidecar change failed; row left unchanged: %v", err),
|
|
"warning"); logErr != nil {
|
|
fmt.Printf("Warning: failed to record sidecar-update issue for %s: %v\n", path, logErr)
|
|
}
|
|
} else {
|
|
s.resolveIssuesForItem(ctx, existingItem.ID)
|
|
}
|
|
// Playlist sync on the change path (plan Correction 2): a
|
|
// "(Reading Order)" entry added to an EXISTING book's sidecar
|
|
// still creates its collection — add-if-missing is idempotent.
|
|
s.syncPlaylistCollectionsAfterCreate(ctx, entry.ID, playlistSeries)
|
|
// Fresh full manifest: the next scan compares against TODAY's
|
|
// sidecars, not the stale pre-edit fingerprint.
|
|
s.storeManifest(ctx, libraryID, entry, hashSidecarManifest(path, dir, statSidecarManifest(path, dir)))
|
|
// Ledger self-heal (Phase 11): the item processed successfully —
|
|
// close its open issues (a healed extraction failure clears).
|
|
s.resolveIssuesForItem(ctx, entry.ID)
|
|
s.changedItems++
|
|
return false, nil
|
|
}
|
|
|
|
// Self-heal items imported before hashing existed: even an unchanged
|
|
// file gets its hash computed if missing. recomputeHashInfo persists
|
|
// it; the next scan's bulk load carries it — no write-through needed
|
|
// for a per-run map.
|
|
if entry.FileSha256 == "" {
|
|
s.recomputeHashInfo(ctx, entry.ID, libraryID, path)
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// applyTitleDerivedSeries fills a missing series and/or ordinal from the
|
|
// item's own subtitle or title text when they follow the unambiguous
|
|
// Audible/Amazon "<series>, Book N" convention. Lowest-precedence source:
|
|
// anything any other extractor set stays untouched. No display mutation —
|
|
// title/subtitle render exactly as written; only the series fields fill.
|
|
func applyTitleDerivedSeries(metadata *MediaMetadata) {
|
|
if metadata == nil {
|
|
return
|
|
}
|
|
needName := metadata.Series == ""
|
|
needNum := metadata.SeriesNumber == 0
|
|
if !needName && !needNum {
|
|
return
|
|
}
|
|
|
|
// Prefer the subtitle, then the post-colon segment of the title.
|
|
tryTexts := []string{metadata.Subtitle}
|
|
if _, tail, split := splitTitleOnSubtitle(metadata.Title); split {
|
|
tryTexts = append(tryTexts, tail)
|
|
}
|
|
// A number parsed alongside a DIFFERENT series name belongs to that
|
|
// name, not to the series we already have — accept it only when the
|
|
// derived name matches the existing series (case-insensitive).
|
|
for _, text := range tryTexts {
|
|
name, num, hasNum, ok := deriveSeriesFromText(text)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if name == metadata.Title {
|
|
continue // degenerate: "the series" is the whole title
|
|
}
|
|
if needName {
|
|
metadata.Series = name
|
|
metadata.SeriesNumber = num
|
|
needName = false
|
|
needNum = false
|
|
if !needName && !needNum {
|
|
return
|
|
}
|
|
continue
|
|
}
|
|
if needNum && hasNum && strings.EqualFold(name, metadata.Series) {
|
|
metadata.SeriesNumber = num
|
|
needNum = false
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// extractCalibreSidecar checks for and parses a Calibre metadata.opf sidecar file
|
|
func (s *MediaScanner) extractCalibreSidecar(path string) *MediaMetadata {
|
|
// Per-file sidecar FIRST (<basename>.opf, the writer's multi-book
|
|
// safe name), then Calibre's dir-scoped metadata.opf.
|
|
dir := filepath.Dir(path)
|
|
base := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
|
for _, name := range []string{base + ".opf", "metadata.opf"} {
|
|
opfPath := filepath.Join(dir, name)
|
|
if _, err := os.Stat(opfPath); err != nil {
|
|
continue
|
|
}
|
|
// Parse sidecar
|
|
metadata, err := s.parseCalibreMetadataOPF(opfPath)
|
|
if err != nil {
|
|
fmt.Printf("Warning: failed to parse Calibre metadata.opf: %v\n", err)
|
|
continue // fall back to the next candidate / embedded
|
|
}
|
|
return metadata
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// applyEmbeddedCoverFallback extracts a cover from the media file itself when
|
|
// a metadata sidecar (metadata.opf / metadata.json) supplied the metadata but
|
|
// no sidecar cover (cover.jpg etc.) exists. Without this, a full rescan would
|
|
// clear cover_image_path for sidecar-managed PDFs and EPUBs - mergeMetadata
|
|
// has no PDF/EPUB cover logic of its own.
|
|
func (s *MediaScanner) applyEmbeddedCoverFallback(path string, metadata *MediaMetadata) {
|
|
if metadata.CoverPath != "" {
|
|
return
|
|
}
|
|
switch strings.ToLower(filepath.Ext(path)) {
|
|
case ".pdf":
|
|
if coverPath, err := s.extractPDFCover(path); err == nil && coverPath != "" {
|
|
metadata.CoverPath = s.getRelativePath(coverPath)
|
|
}
|
|
case ".epub", ".kepub":
|
|
if coverPath, err := s.extractEPUBCover(path); err == nil && coverPath != "" {
|
|
metadata.CoverPath = s.getRelativePath(coverPath)
|
|
}
|
|
default:
|
|
// Audio containers (mp3 APIC, m4b/flac embedded picture): one
|
|
// ffmpeg call, written as a "<file>.cover.jpg" sibling — the
|
|
// same convention the EPUB path uses. Purely optional: absent
|
|
// ffmpeg or no embedded art simply leaves the cover empty.
|
|
if isAudioFile(path) && !strings.EqualFold(filepath.Ext(path), ".zip") {
|
|
if cover := extractEmbeddedAudioCover(path, path); cover != "" {
|
|
metadata.CoverPath = s.getRelativePath(cover)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// extractAudiobookshelfSidecar checks for and parses an Audiobookshelf-style
|
|
// metadata.json sidecar next to the media file. Common fields map to
|
|
// media_items columns; the audio-only fields (narrators, curated chapters)
|
|
// travel through the audio payload separately (readSidecarAudio). Returns
|
|
// nil when no sidecar exists.
|
|
func extractAudiobookshelfSidecar(path string) *MediaMetadata {
|
|
jsonPath := filepath.Join(filepath.Dir(path), "metadata.json")
|
|
if _, err := os.Stat(jsonPath); os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
|
|
data, err := os.ReadFile(jsonPath)
|
|
if err != nil {
|
|
fmt.Printf("Warning: failed to read metadata.json sidecar for %s: %v\n", path, err)
|
|
return nil
|
|
}
|
|
|
|
// Series is tolerant across ALL on-disk shapes: ABS abmetadata writes
|
|
// an array of STRINGS ("The Witcher #3"), some exports write an array
|
|
// of {series, sequence} OBJECTS, Libation writes a plain STRING. The
|
|
// string-array case used to fall through silently, dropping series for
|
|
// every ABS-matched book (the A2c finding).
|
|
var sidecar struct {
|
|
Title string `json:"title"`
|
|
Subtitle string `json:"subtitle"`
|
|
Authors []string `json:"authors"`
|
|
Narrators []string `json:"narrators"`
|
|
Abridged *bool `json:"abridged"`
|
|
Explicit *bool `json:"explicit"`
|
|
Series json.RawMessage `json:"series"`
|
|
Genres []string `json:"genres"`
|
|
Tags []string `json:"tags"`
|
|
PublishedYear json.RawMessage `json:"publishedYear"`
|
|
PublishedDate *string `json:"publishedDate"`
|
|
Publisher *string `json:"publisher"`
|
|
Description *string `json:"description"`
|
|
ISBN *string `json:"isbn"`
|
|
ASIN *string `json:"asin"`
|
|
Language *string `json:"language"`
|
|
}
|
|
if err := json.Unmarshal(data, &sidecar); err != nil {
|
|
fmt.Printf("Warning: failed to parse metadata.json sidecar for %s: %v\n", path, err)
|
|
return nil
|
|
}
|
|
|
|
candidates := parseSeriesCandidates(sidecar.Series)
|
|
|
|
metadata := &MediaMetadata{
|
|
Title: strings.TrimSpace(sidecar.Title),
|
|
}
|
|
// Subtitle is a first-class column now (A2b) — never joined into the
|
|
// title, never trimmed away.
|
|
if subtitle := strings.TrimSpace(sidecar.Subtitle); subtitle != "" {
|
|
metadata.Subtitle = subtitle
|
|
}
|
|
if len(sidecar.Authors) > 0 {
|
|
// Full array is the write truth; keep the legacy single fill for
|
|
// paths that still read metadata.Author.
|
|
metadata.Authors = sidecar.Authors
|
|
metadata.Author = strings.TrimSpace(sidecar.Authors[0])
|
|
}
|
|
if sidecar.Publisher != nil && strings.TrimSpace(*sidecar.Publisher) != "" {
|
|
metadata.Publisher = strings.TrimSpace(*sidecar.Publisher)
|
|
metadata.Publishers = []string{metadata.Publisher}
|
|
}
|
|
if sidecar.Explicit != nil {
|
|
metadata.Explicit = *sidecar.Explicit
|
|
}
|
|
|
|
// Three-way series assignment (A2c §2.2):
|
|
// primary bibliographic series -> series / series_number columns
|
|
// additional bibliographic ones -> alternate_info JSONB (comic shape)
|
|
// ordering constructs -> PlaylistSeries (curated collections;
|
|
// never the series column)
|
|
var extras, playlists []SeriesCandidate
|
|
var primary *SeriesCandidate
|
|
for i, c := range candidates {
|
|
if isPlaylistSeriesName(c.Name) {
|
|
playlists = append(playlists, c)
|
|
continue
|
|
}
|
|
if primary == nil {
|
|
primary = &candidates[i]
|
|
continue
|
|
}
|
|
extras = append(extras, c)
|
|
}
|
|
if primary != nil {
|
|
metadata.Series = primary.Name
|
|
metadata.SeriesNumber = primary.Number
|
|
if primary.HasNum && primary.Sequence != "" {
|
|
seq := strings.TrimSpace(primary.Sequence)
|
|
if _, err := strconv.ParseFloat(seq, 64); err != nil {
|
|
// Lossy extraction ("1a", "Book 2"): keep the raw text so
|
|
// nothing the source said is lost.
|
|
metadata.AlternateInfo = preserveSeriesSequenceRaw(metadata.AlternateInfo, seq)
|
|
}
|
|
}
|
|
}
|
|
if len(extras) > 0 && metadata.AlternateInfo == "" {
|
|
e := extras[0]
|
|
alt := map[string]interface{}{"alternate_series": e.Name}
|
|
if e.HasNum {
|
|
alt["alternate_number"] = e.Number
|
|
}
|
|
if b, err := json.Marshal(alt); err == nil {
|
|
metadata.AlternateInfo = string(b)
|
|
}
|
|
}
|
|
if len(playlists) > 0 {
|
|
metadata.PlaylistSeries = playlists
|
|
fmt.Printf("Series: %d playlist-style series entries on %q route to curated collections, not the series column\n", len(playlists), metadata.Title)
|
|
}
|
|
|
|
// Smart genre (A2c §2.5): most-specific canonical genre wins the genre
|
|
// column; every candidate (split parts included) stays in tags. The
|
|
// split is the point: ABS-style exports join genres into one
|
|
// comma-string ("Epic, Action & Adventure, Military") which must land
|
|
// as separate tags, while commas inside a single subject heading stay
|
|
// legal within one tag.
|
|
genre, all := pickSmartGenre(sidecar.Genres)
|
|
if genre != "" {
|
|
metadata.Genre = genre
|
|
}
|
|
tags := append(append([]string{}, all...), sidecar.Tags...)
|
|
if len(tags) > 0 {
|
|
metadata.Tags = utils.NormalizeTags(tags)
|
|
}
|
|
if sidecar.Language != nil {
|
|
// Display names ("English") normalize to ISO 639-1 ("en") — the
|
|
// media_items.language convention.
|
|
metadata.Language = normalizeLanguageCode(*sidecar.Language)
|
|
}
|
|
if sidecar.PublishedDate != nil {
|
|
if date, err := time.Parse("2006-01-02", strings.TrimSpace(*sidecar.PublishedDate)); err == nil {
|
|
metadata.PublishDate = date
|
|
}
|
|
}
|
|
if metadata.PublishDate.IsZero() {
|
|
// publishedYear tolerates number AND string forms ("2009" from
|
|
// Libation lives as a string).
|
|
if pubYear := parseJSONInt(sidecar.PublishedYear); pubYear > 0 {
|
|
metadata.PublishDate = time.Date(pubYear, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
}
|
|
}
|
|
if sidecar.Publisher != nil {
|
|
metadata.Publisher = strings.TrimSpace(*sidecar.Publisher)
|
|
}
|
|
if sidecar.Description != nil {
|
|
// ABS descriptions are Audible HTML (already sanitized upstream, but
|
|
// untrusted). bluemonday UGC policy keeps the basic formatting tags
|
|
// the UI renders and strips everything else.
|
|
metadata.Description = strings.TrimSpace(sanitizeSidecarDescription(*sidecar.Description))
|
|
}
|
|
if sidecar.ISBN != nil {
|
|
metadata.ISBN = utils.NormalizeISBNSafe(strings.TrimSpace(*sidecar.ISBN))
|
|
}
|
|
if sidecar.ASIN != nil {
|
|
metadata.ASIN = strings.TrimSpace(*sidecar.ASIN)
|
|
}
|
|
// Audiobook editorial facts land in the real columns (A2a):
|
|
// narrators verbatim (display), abridged as the Libation/Audible flag.
|
|
if len(sidecar.Narrators) > 0 {
|
|
metadata.Narrators = sidecar.Narrators
|
|
}
|
|
if sidecar.Abridged != nil {
|
|
metadata.Abridged = *sidecar.Abridged
|
|
}
|
|
|
|
return metadata
|
|
}
|
|
|
|
// sanitizeSidecarDescription normalizes untrusted sidecar HTML to a safe
|
|
// subset. Plain text passes through unchanged.
|
|
func sanitizeSidecarDescription(raw string) string {
|
|
return bluemonday.UGCPolicy().Sanitize(raw)
|
|
}
|
|
|
|
// preserveSeriesSequenceRaw records the un-parseable original sequence text
|
|
// ("1a", "Book 2") inside the alternate_info JSONB so the float column can
|
|
// stay an honest ordering key while no source information is lost.
|
|
func preserveSeriesSequenceRaw(existingJSON, raw string) string {
|
|
entry := map[string]interface{}{}
|
|
if existingJSON != "" {
|
|
_ = json.Unmarshal([]byte(existingJSON), &entry)
|
|
}
|
|
entry["series_sequence_raw"] = raw
|
|
b, err := json.Marshal(entry)
|
|
if err != nil {
|
|
return existingJSON
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// mergeMetadata intelligently merges metadata from multiple sources
|
|
// Priority: metadata.opf (Calibre) → embedded metadata → folder structure → filename
|
|
// For comics: metadata.opf → ComicInfo.xml → folder structure → filename
|
|
func (s *MediaScanner) mergeMetadata(path string, calibreMetadata *MediaMetadata) (*MediaMetadata, error) {
|
|
metadata := calibreMetadata
|
|
if metadata == nil {
|
|
metadata = &MediaMetadata{}
|
|
}
|
|
|
|
ext := strings.ToLower(filepath.Ext(path))
|
|
|
|
// For EPUB files
|
|
if ext == ".epub" || ext == ".kepub" {
|
|
book, err := epub.ReadBook(path)
|
|
if err == nil {
|
|
genreTags := extractGenreTagsFromEPUB(book)
|
|
processGenresAndTags(metadata, genreTags)
|
|
if allText := book.AllChaptersText(); len(allText) > 0 {
|
|
metadata.TotalCharacters = int64(len(allText))
|
|
}
|
|
metadata.ChapterCount = int32(book.ChapterCount())
|
|
}
|
|
isFixed, fixedErr := s.DetectFixedLayoutEPUB(path)
|
|
if fixedErr == nil && isFixed {
|
|
if pageCount, imgErr := countArchiveImages(path); imgErr == nil && pageCount > 0 {
|
|
metadata.PageCount = int32(pageCount)
|
|
}
|
|
}
|
|
|
|
// Gap-fill: sidecar-sourced metadata wins, but blanks are filled from
|
|
// the book's own OPF so a sparse metadata.opf/metadata.json doesn't
|
|
// hide data the file carries. Never overwrites sidecar values.
|
|
if embedded, err := s.extractEPUBMetadata(path); err == nil && embedded != nil {
|
|
if metadata.Title == "" && embedded.Title != "" {
|
|
metadata.Title = embedded.Title
|
|
}
|
|
if metadata.Author == "" && embedded.Author != "" {
|
|
metadata.Author = embedded.Author
|
|
}
|
|
if metadata.Description == "" && embedded.Description != "" {
|
|
metadata.Description = embedded.Description
|
|
}
|
|
if metadata.Publisher == "" && embedded.Publisher != "" {
|
|
metadata.Publisher = embedded.Publisher
|
|
}
|
|
if metadata.Language == "" && embedded.Language != "" {
|
|
metadata.Language = embedded.Language
|
|
}
|
|
if metadata.ISBN == "" && embedded.ISBN != "" {
|
|
metadata.ISBN = embedded.ISBN
|
|
}
|
|
if metadata.ASIN == "" && embedded.ASIN != "" {
|
|
metadata.ASIN = embedded.ASIN
|
|
}
|
|
if metadata.Series == "" && embedded.Series != "" {
|
|
metadata.Series = embedded.Series
|
|
}
|
|
if metadata.SeriesNumber == 0 && embedded.SeriesNumber != 0 {
|
|
metadata.SeriesNumber = embedded.SeriesNumber
|
|
}
|
|
if metadata.ReadingDirection == "" && embedded.ReadingDirection != "" {
|
|
metadata.ReadingDirection = embedded.ReadingDirection
|
|
}
|
|
if metadata.PublishDate.IsZero() && !embedded.PublishDate.IsZero() {
|
|
metadata.PublishDate = embedded.PublishDate
|
|
}
|
|
if len(metadata.Tags) == 0 && len(embedded.Tags) > 0 {
|
|
metadata.Tags = embedded.Tags
|
|
}
|
|
if len(metadata.Contributors) == 0 && len(embedded.Contributors) > 0 {
|
|
metadata.Contributors = embedded.Contributors
|
|
}
|
|
}
|
|
}
|
|
|
|
// For PDFs: fill blanks from the embedded Info dictionary. Same rule as
|
|
// EPUBs - sidecar values win, the file only fills gaps.
|
|
if ext == ".pdf" {
|
|
if embedded, err := s.readPDFInfoDict(path); err == nil && embedded != nil {
|
|
if metadata.Title == "" && embedded.Title != "" {
|
|
metadata.Title = embedded.Title
|
|
}
|
|
if metadata.Author == "" && embedded.Author != "" {
|
|
metadata.Author = embedded.Author
|
|
}
|
|
if metadata.Description == "" && embedded.Description != "" {
|
|
metadata.Description = embedded.Description
|
|
}
|
|
if metadata.Publisher == "" && embedded.Publisher != "" {
|
|
metadata.Publisher = embedded.Publisher
|
|
}
|
|
if len(metadata.Tags) == 0 && len(embedded.Tags) > 0 {
|
|
metadata.Tags = embedded.Tags
|
|
}
|
|
if metadata.PageCount == 0 && embedded.PageCount > 0 {
|
|
metadata.PageCount = embedded.PageCount
|
|
}
|
|
}
|
|
}
|
|
|
|
// For comic archives, try to extract ComicInfo.xml
|
|
if ext == ".cbz" || ext == ".cbr" || ext == ".cb7" || ext == ".cbt" {
|
|
comicInfo, cover, err := extractComicMetadata(path)
|
|
if err != nil {
|
|
fmt.Printf("Warning: failed to extract comic metadata from %s: %v\n", path, err)
|
|
} else if comicInfo != nil {
|
|
// Merge ComicInfo.xml fields (only if not already set from Calibre)
|
|
if metadata.Title == "" && comicInfo.Title != "" {
|
|
metadata.Title = comicInfo.Title
|
|
}
|
|
if metadata.Series == "" && comicInfo.Series != "" {
|
|
metadata.Series = comicInfo.Series
|
|
}
|
|
if metadata.SeriesNumber == 0 && comicInfo.Number > 0 {
|
|
metadata.SeriesNumber = float64(comicInfo.Number)
|
|
}
|
|
if metadata.Publisher == "" && comicInfo.Publisher != "" {
|
|
metadata.Publisher = comicInfo.Publisher
|
|
}
|
|
if metadata.Author == "" && comicInfo.Writer != "" {
|
|
metadata.Author = comicInfo.Writer
|
|
}
|
|
if metadata.Description == "" && comicInfo.Summary != "" {
|
|
metadata.Description = comicInfo.Summary
|
|
}
|
|
|
|
// NEW: Always extract reading direction from ComicInfo.xml
|
|
// (even if metadata.opf exists, since Calibre doesn't support this field)
|
|
metadata.MangaType = normalizeMangaType(comicInfo.Manga)
|
|
metadata.ReadingDirection = determineReadingDirection(comicInfo)
|
|
metadata.Language = comicInfo.LanguageISO
|
|
|
|
// NEW: Extract additional comic-specific fields
|
|
// Series information
|
|
if metadata.SeriesCount == 0 && comicInfo.Count > 0 {
|
|
metadata.SeriesCount = int32(comicInfo.Count)
|
|
}
|
|
if metadata.Volume == 0 && comicInfo.Volume > 0 {
|
|
metadata.Volume = int32(comicInfo.Volume)
|
|
}
|
|
|
|
// Publisher and classification
|
|
if metadata.Imprint == "" && comicInfo.Imprint != "" {
|
|
metadata.Imprint = comicInfo.Imprint
|
|
}
|
|
if metadata.StoryArc == "" && comicInfo.StoryArc != "" {
|
|
metadata.StoryArc = comicInfo.StoryArc
|
|
}
|
|
if metadata.AgeRating == "" && comicInfo.AgeRating != "" {
|
|
metadata.AgeRating = normalizeAgeRating(comicInfo.AgeRating)
|
|
}
|
|
|
|
// NEW: Process genres and tags (universal logic for all formats)
|
|
// Extract genre tags from ComicInfo.xml (Genre + Tags + Characters + Teams + Locations)
|
|
genreTags := extractGenreTagsFromComicInfo(comicInfo)
|
|
processGenresAndTags(metadata, genreTags)
|
|
|
|
// Additional metadata
|
|
if metadata.WebURL == "" && comicInfo.Web != "" {
|
|
metadata.WebURL = comicInfo.Web
|
|
}
|
|
if metadata.MetadataNotes == "" && comicInfo.Notes != "" {
|
|
metadata.MetadataNotes = comicInfo.Notes
|
|
}
|
|
if metadata.ScanInformation == "" && comicInfo.ScanInformation != "" {
|
|
metadata.ScanInformation = comicInfo.ScanInformation
|
|
}
|
|
if metadata.Summary == "" && comicInfo.Summary != "" {
|
|
metadata.Summary = comicInfo.Summary
|
|
}
|
|
|
|
// Boolean fields
|
|
if !metadata.IsBlackAndWhite && strings.ToLower(comicInfo.BlackAndWhite) == "yes" {
|
|
metadata.IsBlackAndWhite = true
|
|
}
|
|
if metadata.CommunityRating == 0 && comicInfo.CommunityRating > 0 {
|
|
metadata.CommunityRating = comicInfo.CommunityRating
|
|
}
|
|
|
|
// Alternate series information (store as JSONB string)
|
|
if metadata.AlternateInfo == "" && (comicInfo.AlternateSeries != "" || comicInfo.AlternateNumber > 0) {
|
|
alternateData := map[string]any{}
|
|
if comicInfo.AlternateSeries != "" {
|
|
alternateData["alternate_series"] = comicInfo.AlternateSeries
|
|
}
|
|
if comicInfo.AlternateNumber > 0 {
|
|
alternateData["alternate_number"] = comicInfo.AlternateNumber
|
|
}
|
|
if comicInfo.AlternateCount > 0 {
|
|
alternateData["alternate_count"] = comicInfo.AlternateCount
|
|
}
|
|
if len(alternateData) > 0 {
|
|
jsonBytes, err := json.Marshal(alternateData)
|
|
if err == nil {
|
|
metadata.AlternateInfo = string(jsonBytes)
|
|
}
|
|
}
|
|
}
|
|
|
|
// REMOVED: Tag enhancement now handled by processGenresAndTags()
|
|
// Characters, Teams, Locations are already processed via extractGenreTagsFromComicInfo()
|
|
|
|
// Extract cover if not already present
|
|
if len(cover) > 0 && metadata.CoverPath == "" {
|
|
coverPath := path + ".cover.jpg"
|
|
if err := os.WriteFile(coverPath, cover, 0644); err == nil {
|
|
metadata.CoverPath = s.getRelativePath(coverPath)
|
|
}
|
|
}
|
|
|
|
fmt.Printf("Merged comic metadata from %s: title=%s, series=%s, issue=%d, manga=%s, direction=%s\n",
|
|
path, comicInfo.Title, comicInfo.Series, comicInfo.Number, comicInfo.Manga, metadata.ReadingDirection)
|
|
}
|
|
|
|
if pageCount, err := countArchiveImages(path); err == nil && pageCount > 0 {
|
|
metadata.PageCount = int32(pageCount)
|
|
}
|
|
}
|
|
|
|
return metadata, nil
|
|
}
|
|
|
|
// containsTag checks if a tag already exists in the tags array
|
|
func containsTag(tags []string, tag string) bool {
|
|
tag = strings.ToLower(tag)
|
|
for _, t := range tags {
|
|
if strings.ToLower(t) == tag {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// normalizeMangaType normalizes ComicInfo.xml Manga field to database enum values
|
|
func normalizeMangaType(manga string) string {
|
|
switch strings.ToLower(strings.ReplaceAll(manga, " ", "")) {
|
|
case "unknown":
|
|
return "unknown"
|
|
case "no":
|
|
return "no"
|
|
case "yes":
|
|
return "yes"
|
|
case "yesandrighttoleft":
|
|
return "yes_and_right_to_left"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
// determineReadingDirection computes reading direction from ComicInfo metadata
|
|
// Uses Manga field + language heuristics + genre tags
|
|
func determineReadingDirection(comicInfo *ComicInfo) string {
|
|
// 1. Check explicit Manga field
|
|
manga := normalizeMangaType(comicInfo.Manga)
|
|
switch manga {
|
|
case "yes_and_right_to_left":
|
|
return "rtl" // Traditional Japanese manga
|
|
case "yes", "no":
|
|
return "ltr" // Manga style but LTR, or Western comic
|
|
}
|
|
|
|
// 2. Language heuristic: Japanese → RTL
|
|
lang := strings.ToLower(comicInfo.LanguageISO)
|
|
if lang == "ja" || lang == "jpn" {
|
|
return "rtl"
|
|
}
|
|
|
|
// 3. Genre heuristic: webtoons/manhwa → vertical
|
|
tags := strings.ToLower(comicInfo.Tags + " " + comicInfo.Genre)
|
|
if strings.Contains(tags, "webtoon") || strings.Contains(tags, "manhwa") {
|
|
return "vertical" // Korean/Chinese webcomics
|
|
}
|
|
if strings.Contains(tags, "manga") {
|
|
return "rtl" // Japanese manga
|
|
}
|
|
|
|
// 4. Default: LTR (Western comics)
|
|
return "ltr"
|
|
}
|
|
|
|
// normalizeAgeRating normalizes age rating from ComicInfo.xml to standard values
|
|
func normalizeAgeRating(rating string) string {
|
|
rating = strings.ToLower(strings.TrimSpace(rating))
|
|
switch rating {
|
|
case "everyone", "e", "all ages":
|
|
return "Everyone"
|
|
case "teen", "t", "13+", "13+up":
|
|
return "Teen"
|
|
case "mature", "m", "17+", "17+up", "adults only":
|
|
return "Mature"
|
|
case "adult", "a", "18+":
|
|
return "Adult"
|
|
default:
|
|
return rating // Return original if unknown
|
|
}
|
|
}
|
|
|
|
// processGenresAndTags ensures ALL genres appear in the tags array without duplication
|
|
// This applies to ALL formats: EPUB, ComicInfo.xml, PDF metadata
|
|
// Strategy: Use existing `genre` column for primary genre, `tags` array for all genres
|
|
func processGenresAndTags(metadata *MediaMetadata, genreTags []string) {
|
|
if metadata.Tags == nil {
|
|
metadata.Tags = []string{}
|
|
}
|
|
|
|
// 1. Set primary genre (first genre tag wins if not already set)
|
|
if metadata.Genre == "" && len(genreTags) > 0 {
|
|
metadata.Genre = genreTags[0]
|
|
}
|
|
|
|
// 2. Ensure ALL genre tags appear in tags array (without duplication)
|
|
for _, genreTag := range genreTags {
|
|
genreTag = strings.TrimSpace(genreTag)
|
|
if genreTag != "" && !containsTag(metadata.Tags, genreTag) {
|
|
metadata.Tags = append(metadata.Tags, genreTag)
|
|
}
|
|
}
|
|
}
|
|
|
|
// extractGenreTagsFromEPUB extracts all <dc:subject> values from EPUB
|
|
// Returns array of genre tags
|
|
func extractGenreTagsFromEPUB(book *epub.Book) []string {
|
|
var genreTags []string
|
|
|
|
// EPUB stores genres in <dc:subject> metadata
|
|
if subjects, err := book.MetadataByKey("subject"); err == nil && len(subjects) > 0 {
|
|
for _, subject := range subjects {
|
|
subject = strings.TrimSpace(subject)
|
|
if subject != "" {
|
|
genreTags = append(genreTags, subject)
|
|
}
|
|
}
|
|
}
|
|
|
|
return genreTags
|
|
}
|
|
|
|
// extractGenreTagsFromComicInfo extracts genres from ComicInfo.xml
|
|
// Genre field + Tags field + Characters + Teams + Locations
|
|
// Returns array of genre tags
|
|
func extractGenreTagsFromComicInfo(comicInfo *ComicInfo) []string {
|
|
var genreTags []string
|
|
|
|
// 1. Add Genre field
|
|
if comicInfo.Genre != "" {
|
|
genreTags = append(genreTags, strings.Split(comicInfo.Genre, ",")...)
|
|
}
|
|
|
|
// 2. Add Tags field (comma-separated)
|
|
if comicInfo.Tags != "" {
|
|
genreTags = append(genreTags, strings.Split(comicInfo.Tags, ",")...)
|
|
}
|
|
|
|
// 3. Add Characters (comma-separated)
|
|
if comicInfo.Characters != "" {
|
|
genreTags = append(genreTags, strings.Split(comicInfo.Characters, ",")...)
|
|
}
|
|
|
|
// 4. Add Teams (comma-separated)
|
|
if comicInfo.Teams != "" {
|
|
genreTags = append(genreTags, strings.Split(comicInfo.Teams, ",")...)
|
|
}
|
|
|
|
// 5. Add Locations (comma-separated)
|
|
if comicInfo.Locations != "" {
|
|
genreTags = append(genreTags, strings.Split(comicInfo.Locations, ",")...)
|
|
}
|
|
|
|
// Trim whitespace from all tags
|
|
for i := range genreTags {
|
|
genreTags[i] = strings.TrimSpace(genreTags[i])
|
|
}
|
|
|
|
return genreTags
|
|
}
|
|
|
|
func (s *MediaScanner) extractMetadata(path string) (*MediaMetadata, error) {
|
|
// Metadata sidecar priority: Calibre metadata.opf, then Audiobookshelf
|
|
// metadata.json, then the media file's own embedded metadata.
|
|
// Try Calibre sidecar first
|
|
calibreMetadata := s.extractCalibreSidecar(path)
|
|
if calibreMetadata != nil {
|
|
fmt.Printf("Using Calibre metadata.opf for %s\n", path)
|
|
|
|
// Try to find cover image for sidecar metadata
|
|
coverPath := findSidecarCover(path)
|
|
if coverPath != "" {
|
|
calibreMetadata.CoverPath = s.getRelativePath(coverPath)
|
|
}
|
|
s.applyEmbeddedCoverFallback(path, calibreMetadata)
|
|
|
|
return s.mergeMetadata(path, calibreMetadata)
|
|
}
|
|
|
|
// Audiobookshelf-style metadata.json sidecar (fields without a DB column
|
|
// - narrators, subtitle, explicit, abridged, chapters - are skipped)
|
|
abMetadata := extractAudiobookshelfSidecar(path)
|
|
if abMetadata != nil {
|
|
fmt.Printf("Using metadata.json sidecar for %s\n", path)
|
|
|
|
coverPath := findSidecarCover(path)
|
|
if coverPath != "" {
|
|
abMetadata.CoverPath = s.getRelativePath(coverPath)
|
|
}
|
|
s.applyEmbeddedCoverFallback(path, abMetadata)
|
|
|
|
return s.mergeMetadata(path, abMetadata)
|
|
}
|
|
|
|
// Fallback to embedded metadata
|
|
ext := strings.ToLower(filepath.Ext(path))
|
|
|
|
switch ext {
|
|
case ".epub", ".kepub":
|
|
metadata := &MediaMetadata{}
|
|
result, err := s.extractEPUBMetadata(path)
|
|
if err == nil {
|
|
return result, nil
|
|
}
|
|
if result != nil {
|
|
metadata = result
|
|
}
|
|
// Enhanced format detection for EPUBs
|
|
isFixedLayout, detectErr := s.DetectFixedLayoutEPUB(path)
|
|
if detectErr == nil && isFixedLayout {
|
|
metadata.FileFormats = []*FormatInfo{{
|
|
FormatType: "fixed_layout",
|
|
FilePath: path,
|
|
MimeType: s.getMimeType(path),
|
|
}}
|
|
if pageCount, imgErr := countArchiveImages(path); imgErr == nil && pageCount > 0 {
|
|
metadata.PageCount = int32(pageCount)
|
|
}
|
|
}
|
|
// Try to extract embedded cover
|
|
coverPath, err := s.extractEPUBCover(path)
|
|
if err != nil {
|
|
fmt.Printf("Warning: failed to extract EPUB cover from %s: %v\n", path, err)
|
|
} else if coverPath != "" {
|
|
metadata.CoverPath = s.getRelativePath(coverPath)
|
|
}
|
|
// If no embedded cover, try sidecar
|
|
if metadata.CoverPath == "" {
|
|
sidecarCover := findSidecarCover(path)
|
|
if sidecarCover != "" {
|
|
metadata.CoverPath = s.getRelativePath(sidecarCover)
|
|
}
|
|
}
|
|
return metadata, nil
|
|
case ".pdf":
|
|
return s.extractPDFMetadata(path)
|
|
case ".cbz", ".cbr", ".cb7", ".cbt":
|
|
metadata, err := s.mergeMetadata(path, nil)
|
|
if err != nil {
|
|
return &MediaMetadata{
|
|
Title: strings.TrimSuffix(filepath.Base(path), ext),
|
|
}, nil
|
|
}
|
|
if metadata.Title == "" {
|
|
metadata.Title = strings.TrimSuffix(filepath.Base(path), ext)
|
|
}
|
|
// If no cover from archive, try sidecar
|
|
if metadata.CoverPath == "" {
|
|
sidecarCover := findSidecarCover(path)
|
|
if sidecarCover != "" {
|
|
metadata.CoverPath = s.getRelativePath(sidecarCover)
|
|
}
|
|
}
|
|
return metadata, nil
|
|
default:
|
|
// Audio containers and other unknown types: NO filename title
|
|
// here — processMediaFile's final fallback applies the filename
|
|
// LAST, after the audio tag-fallback chain has had its turn.
|
|
return &MediaMetadata{}, nil
|
|
}
|
|
}
|
|
|
|
// extractEPUBMetadata extracts metadata from an EPUB by parsing its embedded
|
|
// OPF document directly (container.xml → OPF → Dublin Core elements).
|
|
//
|
|
// It deliberately does NOT use a full-book parser: the previous go-epub
|
|
// implementation parsed every spine chapter and failed the whole call when any
|
|
// single chapter (or the TOC) was malformed, discarding perfectly good OPF
|
|
// metadata and leaving rescans writing blanks. The OPF holds all the metadata
|
|
// we need; chapter damage can no longer affect it.
|
|
func (s *MediaScanner) extractEPUBMetadata(path string) (*MediaMetadata, error) {
|
|
r, err := zip.OpenReader(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open EPUB: %v", err)
|
|
}
|
|
defer func() {
|
|
if err := r.Close(); err != nil {
|
|
fmt.Printf("Warning: failed to close EPUB zip reader for %s: %v\n", path, err)
|
|
}
|
|
}()
|
|
|
|
opfPath := findOPFPathInZip(r.File)
|
|
if opfPath == "" {
|
|
return nil, fmt.Errorf("no OPF document found in EPUB %s", path)
|
|
}
|
|
opfContent, err := readFileFromZip(r.File, opfPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read OPF %s from EPUB: %v", opfPath, err)
|
|
}
|
|
|
|
metadata, err := parseOPFContent(opfContent)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse OPF in EPUB %s: %v", path, err)
|
|
}
|
|
return metadata, nil
|
|
}
|
|
|
|
// DetectFixedLayoutEPUB checks if EPUB has fixed-layout (manga) characteristics
|
|
// by examining the OPF file for rendition metadata and content indicators
|
|
func (s *MediaScanner) DetectFixedLayoutEPUB(epubPath string) (bool, error) {
|
|
// Open EPUB ZIP file
|
|
r, err := zip.OpenReader(epubPath)
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to open EPUB: %w", err)
|
|
}
|
|
defer func() {
|
|
if err := r.Close(); err != nil {
|
|
fmt.Printf("Warning: failed to close EPUB file %s: %v\n", epubPath, err)
|
|
}
|
|
}()
|
|
|
|
// Find and read OPF file
|
|
var opfFile *zip.File
|
|
for _, f := range r.File {
|
|
if strings.HasSuffix(f.Name, ".opf") {
|
|
opfFile = f
|
|
break
|
|
}
|
|
// Also check in META-INF directory
|
|
if strings.Contains(f.Name, "META-INF/") && strings.HasSuffix(f.Name, ".opf") {
|
|
opfFile = f
|
|
break
|
|
}
|
|
}
|
|
|
|
if opfFile == nil {
|
|
return false, fmt.Errorf("OPF file not found in EPUB")
|
|
}
|
|
|
|
// Read OPF content
|
|
rc, err := opfFile.Open()
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to open OPF: %w", err)
|
|
}
|
|
defer func() {
|
|
if err := rc.Close(); err != nil {
|
|
fmt.Printf("Warning: failed to close OPF file reader from %s: %v\n", epubPath, err)
|
|
}
|
|
}()
|
|
|
|
opfContent, err := io.ReadAll(rc)
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to read OPF: %w", err)
|
|
}
|
|
|
|
// Check for fixed-layout indicators
|
|
opfString := string(opfContent)
|
|
|
|
// Check 1: rendition:layout = pre-paginated (EPUB 3 fixed layout)
|
|
if strings.Contains(opfString, `rendition:layout">pre-paginated<`) ||
|
|
strings.Contains(opfString, `rendition:layout="pre-paginated"`) {
|
|
return true, nil
|
|
}
|
|
|
|
// Check 2: RTL page progression (manga indicator)
|
|
if strings.Contains(opfString, `page-progression-direction="rtl"`) {
|
|
return true, nil
|
|
}
|
|
|
|
// Check 3: Image-heavy content (count <img> tags)
|
|
// Threshold of 50 images suggests manga/comic vs novel
|
|
imgCount := strings.Count(opfString, `<img`)
|
|
if imgCount > 50 {
|
|
return true, nil
|
|
}
|
|
|
|
// Check 4: Manga subject tag
|
|
lowerOPF := strings.ToLower(opfString)
|
|
if strings.Contains(lowerOPF, `<dc:subject`) &&
|
|
(strings.Contains(lowerOPF, `manga`) ||
|
|
strings.Contains(lowerOPF, `comic`)) {
|
|
return true, nil
|
|
}
|
|
|
|
return false, nil
|
|
}
|
|
|
|
// moveCandidate is the subset of a cross-library hash-match row the scanner
|
|
// needs to decide whether a file is a move or a deliberate copy.
|
|
type moveCandidate struct {
|
|
ID pgtype.UUID
|
|
LibraryID pgtype.UUID
|
|
FilePath string
|
|
Title string
|
|
Archived bool
|
|
MissingScans int32
|
|
}
|
|
|
|
// selectMoveCandidate returns the first candidate whose file has vanished
|
|
// from its library folders. Candidates arrive pre-ordered (archived first,
|
|
// then most missing scans, then oldest), so the first missing one is the
|
|
// best repoint target. Candidates whose file still exists are deliberate
|
|
// copies in another library and are left untouched. Returns ok=false when
|
|
// every candidate is still on disk.
|
|
func selectMoveCandidate(candidates []moveCandidate, fileExists func(moveCandidate) bool) (moveCandidate, bool) {
|
|
for _, candidate := range candidates {
|
|
if !fileExists(candidate) {
|
|
return candidate, true
|
|
}
|
|
}
|
|
return moveCandidate{}, false
|
|
}
|
|
|
|
// detectCrossLibraryMove repoints an existing row from another same-type
|
|
// library when this file is that row's content at a new location and the old
|
|
// file is gone from disk. Returns true when the file was fully handled as a
|
|
// cross-library move (or rejected as type-incompatible) and must not be
|
|
// imported again by the caller.
|
|
func (s *MediaScanner) detectCrossLibraryMove(ctx context.Context, sha string, libraryID pgtype.UUID, path string, size int64) bool {
|
|
rows, err := s.db.GetMediaItemsBySHA256AndSameLibraryType(ctx, database.GetMediaItemsBySHA256AndSameLibraryTypeParams{
|
|
FileSha256: pgtype.Text{String: sha, Valid: true},
|
|
LibraryID: libraryID,
|
|
})
|
|
if err != nil {
|
|
if !errors.Is(err, pgx.ErrNoRows) {
|
|
fmt.Printf("Warning: cross-library SHA lookup failed for %s: %v\n", path, err)
|
|
}
|
|
return false
|
|
}
|
|
if len(rows) == 0 {
|
|
return false
|
|
}
|
|
|
|
candidates := make([]moveCandidate, 0, len(rows))
|
|
for _, row := range rows {
|
|
candidates = append(candidates, moveCandidate{
|
|
ID: row.ID,
|
|
LibraryID: row.LibraryID,
|
|
FilePath: row.FilePath,
|
|
Title: row.Title,
|
|
Archived: row.ArchivedAt.Valid,
|
|
MissingScans: row.MissingScanCount,
|
|
})
|
|
}
|
|
|
|
candidate, ok := selectMoveCandidate(candidates, func(c moveCandidate) bool {
|
|
return s.mediaItemFileExists(ctx, c.LibraryID, c.FilePath)
|
|
})
|
|
if !ok {
|
|
// Every registered copy of this content still lives in its own
|
|
// library: a deliberate multi-library import, not a move.
|
|
return false
|
|
}
|
|
|
|
return s.repointCrossLibraryMove(ctx, candidate, libraryID, path, size)
|
|
}
|
|
|
|
// mediaItemFileExists resolves a media item's relative path against its own
|
|
// library's folders and reports whether the file is present on disk.
|
|
func (s *MediaScanner) mediaItemFileExists(ctx context.Context, libraryID pgtype.UUID, relativePath string) bool {
|
|
folders, err := s.db.GetLibraryFolders(ctx, libraryID)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
for _, folder := range folders {
|
|
if _, statErr := os.Stat(filepath.Join(folder.FolderPath, relativePath)); statErr == nil {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// repointCrossLibraryMove validates the target library's type rules, then
|
|
// moves the candidate row into the target library at its new path. Reading
|
|
// history, annotations and collections follow automatically because the row
|
|
// keeps its ID. Returns true when the file was handled (moved or rejected)
|
|
// and must not be imported again.
|
|
func (s *MediaScanner) repointCrossLibraryMove(ctx context.Context, candidate moveCandidate, libraryID pgtype.UUID, path string, size int64) bool {
|
|
item, err := s.db.GetMediaItem(ctx, candidate.ID)
|
|
if err != nil {
|
|
fmt.Printf("Warning: failed to load cross-library move candidate %q: %v\n", candidate.Title, err)
|
|
return false
|
|
}
|
|
|
|
targetLib, err := s.db.GetLibraryWithType(ctx, libraryID)
|
|
if err != nil {
|
|
fmt.Printf("Warning: failed to load target library for cross-library move of %q: %v\n", candidate.Title, err)
|
|
return false
|
|
}
|
|
|
|
// Respect the target library's type rules: a reflowable EPUB must not
|
|
// silently land in a manga library, mirroring import validation.
|
|
if issue := s.ValidateMediaItemForLibrary(ctx, item, targetLib); issue != nil {
|
|
fmt.Printf("[MOVE] Rejected cross-library move of %q: %s\n", candidate.Title, *issue)
|
|
s.logger.LogDelete(fmt.Sprintf("[MOVE] Rejected moving item '%s' into library %s: %s", candidate.Title, libraryID, *issue))
|
|
if logErr := s.LogProcessingIssue(ctx, uuid.UUID(candidate.ID.Bytes), uuid.UUID(libraryID.Bytes),
|
|
"library_type_mismatch", *issue, "warning"); logErr != nil {
|
|
fmt.Printf("Warning: failed to record processing issue for %q: %v\n", candidate.Title, logErr)
|
|
}
|
|
return true
|
|
}
|
|
|
|
if err := s.db.MoveMediaItemToLibrary(ctx, database.MoveMediaItemToLibraryParams{
|
|
ID: candidate.ID,
|
|
LibraryID: libraryID,
|
|
FilePath: s.getRelativePath(path),
|
|
FileSize: pgtype.Int8{Int64: size, Valid: true},
|
|
}); err != nil {
|
|
fmt.Printf("Warning: failed to repoint cross-library move %q -> %s: %v\n", candidate.FilePath, path, err)
|
|
return false
|
|
}
|
|
|
|
fmt.Printf("[MOVE] Cross-library move: %q -> %s (history preserved)\n", candidate.FilePath, path)
|
|
s.logger.LogDelete(fmt.Sprintf("[MOVE] Repointed item '%s' from %q to %s (library changed)", candidate.Title, candidate.FilePath, path))
|
|
return true
|
|
}
|
|
|
|
// ValidateMediaItemForLibrary checks if item matches library type expectations
|
|
// and returns issue description if validation fails, nil if valid
|
|
func (s *MediaScanner) ValidateMediaItemForLibrary(
|
|
ctx context.Context,
|
|
mediaItem database.MediaItems,
|
|
library database.GetLibraryWithTypeRow,
|
|
) *string {
|
|
// Get library type from the joined query result
|
|
libraryTypeName := library.TypeName
|
|
|
|
// Manga library validation
|
|
if libraryTypeName == "manga" {
|
|
// Must be fixed-layout or comic archive
|
|
if mediaItem.FormatGroup != "fixed_layout" &&
|
|
mediaItem.FormatGroup != "comic_archive" {
|
|
str := fmt.Sprintf(
|
|
"EPUB file '%s' is reflowable (text-based), not fixed-layout (image-based). "+
|
|
"Manga library only accepts fixed-layout EPUBs, CBZ, CBR, or image files. "+
|
|
"Consider moving this file to an ebooks library.",
|
|
mediaItem.Title,
|
|
)
|
|
return &str
|
|
}
|
|
|
|
// Set manga-specific flags for fixed-layout EPUBs
|
|
if mediaItem.FormatGroup == "fixed_layout" {
|
|
// Default manga type if not set
|
|
if !mediaItem.MangaType.Valid ||
|
|
mediaItem.MangaType.String == "unknown" ||
|
|
mediaItem.MangaType.String == "" {
|
|
mediaItem.MangaType = pgtype.Text{String: "yes", Valid: true}
|
|
}
|
|
// Default reading direction for manga
|
|
if !mediaItem.ReadingDirection.Valid ||
|
|
mediaItem.ReadingDirection.String == "auto" ||
|
|
mediaItem.ReadingDirection.String == "" {
|
|
mediaItem.ReadingDirection = pgtype.Text{String: "rtl", Valid: true}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Comics library validation
|
|
if libraryTypeName == "comics" {
|
|
// Accept comic archives and fixed-layout
|
|
if mediaItem.FormatGroup != "comic_archive" &&
|
|
mediaItem.FormatGroup != "fixed_layout" {
|
|
str := fmt.Sprintf(
|
|
"File '%s' is not a comic archive format. "+
|
|
"Comics library only accepts CBZ, CBR, CB7, CBT, PDF, or fixed-layout EPUBs.",
|
|
mediaItem.Title,
|
|
)
|
|
return &str
|
|
}
|
|
}
|
|
|
|
// Ebooks library validation
|
|
if libraryTypeName == "ebooks" {
|
|
// Flag manga for potential reorganization (info level)
|
|
if mediaItem.FormatGroup == "fixed_layout" &&
|
|
(!mediaItem.MangaType.Valid || mediaItem.MangaType.String == "yes" || mediaItem.MangaType.String == "yes_and_right_to_left") {
|
|
str := fmt.Sprintf(
|
|
"File '%s' appears to be manga (fixed-layout with images). "+
|
|
"Consider moving to a manga or comics library for better organization.",
|
|
mediaItem.Title,
|
|
)
|
|
return &str
|
|
}
|
|
}
|
|
|
|
return nil // No issues
|
|
}
|
|
|
|
// LogProcessingIssue records items that can't be processed properly
|
|
// (item-backed: the media_items row exists). Row-less import failures
|
|
// use LogRowlessProcessingIssue.
|
|
func (s *MediaScanner) LogProcessingIssue(
|
|
ctx context.Context,
|
|
mediaItemID uuid.UUID,
|
|
libraryID uuid.UUID,
|
|
issueType string,
|
|
description string,
|
|
severity string,
|
|
) error {
|
|
_, err := s.db.CreateProcessingIssue(ctx, database.CreateProcessingIssueParams{
|
|
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
|
|
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
|
|
IssueType: issueType,
|
|
IssueDescription: description,
|
|
Severity: severity,
|
|
// Item-backed rows carry no file_path of their own — the item
|
|
// row already has it; the list COALESCEs.
|
|
FilePath: pgtype.Text{},
|
|
})
|
|
return err
|
|
}
|
|
|
|
// LogRowlessProcessingIssue records an import failure for a file whose
|
|
// media_items row was never created (faceted plan Phase 11): keyed
|
|
// row-less by (library, file_path). The file's next SUCCESSFUL import
|
|
// resolves the issue (ResolveOpenProcessingIssuesForPath).
|
|
func (s *MediaScanner) LogRowlessProcessingIssue(
|
|
ctx context.Context,
|
|
libraryID uuid.UUID,
|
|
filePath string,
|
|
issueType string,
|
|
description string,
|
|
severity string,
|
|
) error {
|
|
_, err := s.db.CreateProcessingIssue(ctx, database.CreateProcessingIssueParams{
|
|
MediaItemID: pgtype.UUID{},
|
|
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
|
|
IssueType: issueType,
|
|
IssueDescription: description,
|
|
Severity: severity,
|
|
FilePath: pgtype.Text{String: filePath, Valid: filePath != ""},
|
|
})
|
|
return err
|
|
}
|
|
|
|
// resolveIssuesForItem closes the item's open ledger rows after a
|
|
// successful (re)extraction — the page self-heals (Phase 11).
|
|
func (s *MediaScanner) resolveIssuesForItem(ctx context.Context, itemID pgtype.UUID) {
|
|
if !itemID.Valid {
|
|
return
|
|
}
|
|
if _, err := s.db.ResolveOpenProcessingIssuesForItem(ctx, itemID); err != nil {
|
|
fmt.Printf("Warning: failed to auto-resolve issues for item %s: %v\n", itemID, err)
|
|
}
|
|
}
|
|
|
|
// resolveIssuesForPath closes row-less import-failure issues keyed to
|
|
// (library, path) after the file finally imports (Phase 11).
|
|
func (s *MediaScanner) resolveIssuesForPath(ctx context.Context, libraryID pgtype.UUID, relativePath string) {
|
|
if !libraryID.Valid || relativePath == "" {
|
|
return
|
|
}
|
|
if _, err := s.db.ResolveOpenProcessingIssuesForPath(ctx, database.ResolveOpenProcessingIssuesForPathParams{
|
|
LibraryID: libraryID,
|
|
FilePath: pgtype.Text{String: relativePath, Valid: true},
|
|
}); err != nil {
|
|
fmt.Printf("Warning: failed to auto-resolve import issues for %s: %v\n", relativePath, err)
|
|
}
|
|
}
|
|
|
|
// parseCalibreMetadataOPF parses a Calibre metadata.opf sidecar file.
|
|
func (s *MediaScanner) parseCalibreMetadataOPF(opfPath string) (*MediaMetadata, error) {
|
|
// Open file
|
|
file, err := os.Open(opfPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open metadata.opf: %v", err)
|
|
}
|
|
defer func() {
|
|
if err := file.Close(); err != nil {
|
|
fmt.Printf("Warning: failed to close metadata.opf: %v\n", err)
|
|
}
|
|
}()
|
|
content, err := io.ReadAll(file)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read metadata.opf: %v", err)
|
|
}
|
|
return parseOPFContent(content)
|
|
}
|
|
|
|
// parseOPFContent parses an OPF document (Dublin Core metadata) into
|
|
// MediaMetadata. Used for both Calibre metadata.opf sidecars and the OPF
|
|
// embedded inside an EPUB - the dc:* vocabulary is identical. Parsing is
|
|
// attribute-order agnostic and namespace aware (see media_scanner_opf.go);
|
|
// title selection and series detection follow Calibre's behavior.
|
|
func parseOPFContent(content []byte) (*MediaMetadata, error) {
|
|
opf, err := parseOPFXML(content)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse OPF XML: %v", err)
|
|
}
|
|
md := opf.Metadata
|
|
|
|
// Map to MediaMetadata struct
|
|
metadata := &MediaMetadata{}
|
|
// Title: EPUB3 title-type main selection with Calibre-style subtitle join
|
|
if title := opf.selectTitle(); title != "" {
|
|
metadata.Title = title
|
|
}
|
|
// Reading direction from the OPF spine's page-progression-direction.
|
|
// Sidecar OPFs are metadata-only documents without a spine, so this is a
|
|
// no-op for them and only fires on real books.
|
|
if dir := opf.pageProgressionDirection(); dir != "" {
|
|
metadata.ReadingDirection = dir
|
|
}
|
|
// Author (first creator)
|
|
if len(md.Creators) > 0 {
|
|
metadata.Author = md.Creators[0]
|
|
}
|
|
// Tags (all subjects; one element = one tag, commas are legal inside
|
|
// subject headings like "Holmes, Sherlock (Fictitious character)").
|
|
// Genre mirrors the sidecar path's processGenresAndTags: first subject.
|
|
if len(md.Subjects) > 0 {
|
|
metadata.Tags = utils.NormalizeTags(md.Subjects)
|
|
if len(metadata.Tags) > 0 {
|
|
metadata.Genre = metadata.Tags[0]
|
|
}
|
|
}
|
|
// Description
|
|
if len(md.Descriptions) > 0 {
|
|
metadata.Description = md.Descriptions[0]
|
|
}
|
|
// Publisher
|
|
if len(md.Publishers) > 0 {
|
|
metadata.Publisher = md.Publishers[0]
|
|
}
|
|
// Language
|
|
if len(md.Languages) > 0 && md.Languages[0] != "" {
|
|
metadata.Language = md.Languages[0]
|
|
}
|
|
// Publish date
|
|
if len(md.Dates) > 0 {
|
|
if date, err := time.Parse("2006-01-02T15:04:05Z07:00", md.Dates[0]); err == nil {
|
|
metadata.PublishDate = date
|
|
} else if date, err := time.Parse("2006-01-02", md.Dates[0]); err == nil {
|
|
metadata.PublishDate = date
|
|
} else {
|
|
// Try alternative date formats
|
|
if date, err := time.Parse("2006", md.Dates[0]); err == nil {
|
|
metadata.PublishDate = date
|
|
}
|
|
}
|
|
}
|
|
// Identifiers: opf:scheme attribute first, then URN-prefixed values
|
|
// (urn:isbn:...), then bare values that normalize to a valid ISBN.
|
|
for _, id := range md.Identifiers {
|
|
value := strings.TrimSpace(id.Value)
|
|
scheme := strings.ToUpper(strings.TrimSpace(id.Scheme))
|
|
if scheme == "" {
|
|
if lower := strings.ToLower(value); strings.HasPrefix(lower, "urn:") {
|
|
rest := value[4:]
|
|
if prefix, val, ok := strings.Cut(rest, ":"); ok {
|
|
scheme = strings.ToUpper(prefix)
|
|
value = strings.TrimSpace(val)
|
|
}
|
|
}
|
|
}
|
|
switch scheme {
|
|
case "ISBN":
|
|
metadata.ISBN = utils.NormalizeISBNSafe(value)
|
|
case "ASIN":
|
|
metadata.ASIN = value
|
|
case "UUID", "CALIBRE":
|
|
// Store UUID in hash info, not metadata
|
|
// Will be extracted by extractHashInfo()
|
|
default:
|
|
// EPUB3 identifiers often carry no opf:scheme attribute; accept a
|
|
// bare value only when it is ISBN-shaped (rejects the URIs and
|
|
// UUIDs that commonly share the identifier list).
|
|
if metadata.ISBN == "" && scheme == "" && isISBNLike(value) {
|
|
if normalized, err := utils.NormalizeISBN(value); err == nil {
|
|
metadata.ISBN = normalized
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Contributors
|
|
if len(md.Contributors) > 0 {
|
|
metadata.Contributors = utils.NormalizeContributors(md.Contributors)
|
|
}
|
|
// Series: EPUB3 belongs-to-collection, then calibre:series metas
|
|
if series, index := opf.readSeries(); series != "" {
|
|
metadata.Series = series
|
|
if index > 0 {
|
|
metadata.SeriesNumber = index
|
|
}
|
|
}
|
|
// Calibre-specific meta tags (filter by name attribute)
|
|
for _, meta := range md.Metas {
|
|
switch meta.Name {
|
|
case "calibre:rating":
|
|
// Not imported (ratings are per-user in Bookhoard)
|
|
case "calibre:title_sort":
|
|
// Not imported (Bookhoard has its own sorting logic)
|
|
case "calibre:timestamp":
|
|
// Could be used for created_at, but skipping for now
|
|
}
|
|
}
|
|
return metadata, nil
|
|
}
|
|
|
|
// findOPFPathInZip locates the OPF document inside an EPUB by reading
|
|
// META-INF/container.xml (string-scraped; we only need the rootfile
|
|
// full-path attribute). Returns "" when absent.
|
|
func findOPFPathInZip(files []*zip.File) string {
|
|
for _, f := range files {
|
|
if f.Name != "META-INF/container.xml" {
|
|
continue
|
|
}
|
|
rc, err := f.Open()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
content, readErr := io.ReadAll(rc)
|
|
if closeErr := rc.Close(); closeErr != nil {
|
|
fmt.Printf("Warning: failed to close META-INF/container.xml reader: %v\n", closeErr)
|
|
}
|
|
if readErr != nil {
|
|
return ""
|
|
}
|
|
opfStart := bytes.Index(content, []byte("<rootfile "))
|
|
if opfStart == -1 {
|
|
return ""
|
|
}
|
|
opfStartAttr := bytes.Index(content[opfStart:], []byte("full-path="))
|
|
if opfStartAttr == -1 {
|
|
return ""
|
|
}
|
|
opfStartAttr += len("full-path=")
|
|
quote := content[opfStart+opfStartAttr]
|
|
opfStartQuote := opfStart + opfStartAttr + 1
|
|
opfEndQuote := bytes.Index(content[opfStartQuote:], []byte{quote})
|
|
if opfEndQuote == -1 {
|
|
return ""
|
|
}
|
|
return string(content[opfStartQuote : opfStartQuote+opfEndQuote])
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// extractEPUBCover extracts the cover image from an EPUB file.
|
|
// It looks for:
|
|
// 1. An item with properties="cover-image" in the manifest
|
|
// 2. A meta tag with name="cover" pointing to an image
|
|
// 3. Common cover image paths like cover.jpg, cover.jpeg, cover.png
|
|
// Returns the path to the saved cover image, or empty string if no cover found.
|
|
func (s *MediaScanner) extractEPUBCover(epubPath string) (string, error) {
|
|
// Open the EPUB as a zip file to extract images
|
|
r, err := zip.OpenReader(epubPath)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to open EPUB as zip: %v", err)
|
|
}
|
|
defer func() {
|
|
if err := r.Close(); err != nil {
|
|
fmt.Printf("Warning: failed to close EPUB zip reader for %s: %v\n", epubPath, err)
|
|
}
|
|
}()
|
|
|
|
// Try to find cover image from OPF metadata
|
|
coverImageName := ""
|
|
|
|
opfPath := findOPFPathInZip(r.File)
|
|
|
|
if opfPath == "" {
|
|
// No OPF found, try common cover image paths
|
|
coverImageName = findCoverImageInZip(r.File)
|
|
} else {
|
|
// Read OPF to find cover reference
|
|
opfContent, err := readFileFromZip(r.File, opfPath)
|
|
if err != nil {
|
|
// Fall back to common paths
|
|
coverImageName = findCoverImageInZip(r.File)
|
|
} else {
|
|
coverImageName = findCoverInOPF(opfContent, r.File, opfPath)
|
|
}
|
|
}
|
|
|
|
if coverImageName == "" {
|
|
return "", nil // No cover found
|
|
}
|
|
|
|
// Extract the cover image
|
|
coverImage, err := extractImageFromZip(r.File, coverImageName, opfPath)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to extract cover image: %v", err)
|
|
}
|
|
if len(coverImage) == 0 {
|
|
return "", nil
|
|
}
|
|
|
|
// Save cover to disk (same pattern as comics: {book_path}.cover.jpg)
|
|
coverPath := epubPath + ".cover.jpg"
|
|
if err := os.WriteFile(coverPath, coverImage, 0644); err != nil {
|
|
return "", fmt.Errorf("failed to write cover file: %v", err)
|
|
}
|
|
|
|
return coverPath, nil
|
|
}
|
|
|
|
// findCoverImageInZip searches for common cover image filenames in the zip
|
|
func findCoverImageInZip(files []*zip.File) string {
|
|
coverNames := []string{"cover.jpg", "cover.jpeg", "cover.png", "cover.webp",
|
|
"Cover.jpg", "Cover.jpeg", "Cover.png", "Cover.webp",
|
|
"images/cover.jpg", "Images/cover.jpg", "OEBPS/images/cover.jpg"}
|
|
|
|
for _, name := range coverNames {
|
|
for _, f := range files {
|
|
if strings.EqualFold(f.Name, name) {
|
|
return f.Name
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// findCoverInOPF parses OPF content to find cover image reference
|
|
// findCoverInOPF locates the cover image for an EPUB, following Calibre's
|
|
// read_raster_cover resolution order (see media_scanner_opf.go):
|
|
// 1. manifest item with properties="cover-image"
|
|
// 2. <meta name="cover"> resolved through the manifest
|
|
// 3. the first spine item being a raster image itself (store manga)
|
|
// 4. NEW: the cover page (guide type="cover" or first spine item) mined for
|
|
// <img src> / SVG <image xlink:href> - covers books that declare no
|
|
// raster cover at all, e.g. classic EPUB2/Adobe cover.xhtml wrappers
|
|
// 5. filename guessing in the zip (pre-existing fallback)
|
|
//
|
|
// XML parsing is attribute-order agnostic; if the OPF is too malformed for
|
|
// encoding/xml, the legacy regex chain runs as a compatibility fallback.
|
|
func findCoverInOPF(opfContent []byte, files []*zip.File, opfPath string) string {
|
|
opf, err := parseOPFXML(opfContent)
|
|
if err != nil {
|
|
return findCoverInOPFLegacy(opfContent, files, opfPath)
|
|
}
|
|
|
|
if href := opf.findRasterCoverInOPF(); href != "" {
|
|
return resolveOPFPath(opfPath, href)
|
|
}
|
|
|
|
// Cover-page fallback: Calibre renders the page; we extract the image it
|
|
// references (the practical case - the page wraps a raster in img/SVG).
|
|
if pageHref := opf.coverPageHref(); pageHref != "" {
|
|
if pageContent, err := readFileFromZip(files, resolveOPFPath(opfPath, pageHref)); err == nil {
|
|
if imgRef := findImageReferenceInPage(pageContent); imgRef != "" {
|
|
imgPath := resolveOPFPath(resolveOPFPath(opfPath, pageHref), imgRef)
|
|
if zipHasFile(files, imgPath) {
|
|
return imgPath
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return findCoverImageInZip(files)
|
|
}
|
|
|
|
// findCoverInOPFLegacy is the pre-XML cover lookup, kept solely as a
|
|
// fallback for OPFs too malformed for a real XML parse.
|
|
func findCoverInOPFLegacy(opfContent []byte, files []*zip.File, opfPath string) string {
|
|
contentStr := string(opfContent)
|
|
|
|
// Look for item with properties="cover-image"
|
|
coverImageRE := regexp.MustCompile(`<item[^>]*properties="[^"]*cover-image[^"]*"[^>]*id="([^"]+)"`)
|
|
matches := coverImageRE.FindStringSubmatch(contentStr)
|
|
if len(matches) > 1 {
|
|
coverID := matches[1]
|
|
// Find the href for this ID
|
|
hrefRE := regexp.MustCompile(fmt.Sprintf(`<item[^>]*id="%s"[^>]*href="([^"]+)"`, coverID))
|
|
hrefMatches := hrefRE.FindStringSubmatch(contentStr)
|
|
if len(hrefMatches) > 1 {
|
|
return resolveOPFPath(opfPath, hrefMatches[1])
|
|
}
|
|
}
|
|
|
|
// Look for meta name="cover"
|
|
metaCoverRE := regexp.MustCompile(`<meta[^>]*name="cover"[^>]*content="([^"]+)"`)
|
|
metaMatches := metaCoverRE.FindStringSubmatch(contentStr)
|
|
if len(metaMatches) > 1 {
|
|
coverContent := metaMatches[1]
|
|
// Could be "image-id" format
|
|
if coverID, found := strings.CutPrefix(coverContent, "image-"); found {
|
|
hrefRE := regexp.MustCompile(fmt.Sprintf(`<item[^>]*id="%s"[^>]*href="([^"]+)"`, coverID))
|
|
hrefMatches := hrefRE.FindStringSubmatch(contentStr)
|
|
if len(hrefMatches) > 1 {
|
|
return resolveOPFPath(opfPath, hrefMatches[1])
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fall back to searching common paths
|
|
return findCoverImageInZip(files)
|
|
}
|
|
|
|
// zipHasFile reports whether the zip contains an entry with exactly this name.
|
|
func zipHasFile(files []*zip.File, name string) bool {
|
|
name = filepath.ToSlash(name)
|
|
for _, f := range files {
|
|
if filepath.ToSlash(f.Name) == name {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// resolveOPFPath resolves an OPF-relative href against the OPF document's own
|
|
// path inside the zip. Hrefs are URL-decoded and normalized with posix path
|
|
// semantics ("../" walks up), matching Calibre's
|
|
// posixpath.normpath(posixpath.join(base, href)).
|
|
func resolveOPFPath(opfPath, href string) string {
|
|
if unescaped, err := url.PathUnescape(href); err == nil {
|
|
href = unescaped
|
|
}
|
|
href = strings.TrimPrefix(filepath.ToSlash(href), "/")
|
|
base := ""
|
|
if dir := path.Dir(filepath.ToSlash(opfPath)); dir != "." {
|
|
base = dir
|
|
}
|
|
if base == "" {
|
|
return path.Clean(href)
|
|
}
|
|
return path.Clean(path.Join(base, href))
|
|
}
|
|
|
|
// readFileFromZip reads a file from the zip by name
|
|
func readFileFromZip(files []*zip.File, name string) ([]byte, error) {
|
|
// Normalize the name for comparison
|
|
name = filepath.ToSlash(name)
|
|
for _, f := range files {
|
|
fName := filepath.ToSlash(f.Name)
|
|
if fName == name {
|
|
rc, err := f.Open()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() {
|
|
if err := rc.Close(); err != nil {
|
|
fmt.Printf("Warning: failed to close zip file reader for %s: %v\n", name, err)
|
|
}
|
|
}()
|
|
return io.ReadAll(rc)
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("file not found: %s", name)
|
|
}
|
|
|
|
// extractImageFromZip extracts an image file and returns its contents
|
|
func extractImageFromZip(files []*zip.File, imagePath, opfDir string) ([]byte, error) {
|
|
// Try direct match first
|
|
for _, f := range files {
|
|
if strings.EqualFold(f.Name, imagePath) {
|
|
rc, err := f.Open()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() {
|
|
if err := rc.Close(); err != nil {
|
|
fmt.Printf("Warning: failed to close zip image reader for %s: %v\n", imagePath, err)
|
|
}
|
|
}()
|
|
return io.ReadAll(rc)
|
|
}
|
|
}
|
|
|
|
// Try resolved path
|
|
resolvedPath := resolveOPFPath(opfDir, imagePath)
|
|
for _, f := range files {
|
|
if strings.EqualFold(f.Name, resolvedPath) {
|
|
rc, err := f.Open()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() {
|
|
if err := rc.Close(); err != nil {
|
|
fmt.Printf("Warning: failed to close zip image reader for resolved path %s: %v\n", resolvedPath, err)
|
|
}
|
|
}()
|
|
return io.ReadAll(rc)
|
|
}
|
|
}
|
|
|
|
return nil, fmt.Errorf("image not found: %s", imagePath)
|
|
}
|
|
|
|
// findSidecarCover looks for cover images in the same directory as the media file.
|
|
// It checks for common cover filename patterns in priority order:
|
|
// 1. cover.jpg, cover.jpeg, cover.png, cover.webp
|
|
// 2. folder.jpg, folder.jpeg, folder.png, folder.webp
|
|
// 3. {basename}.jpg, {basename}.jpeg, etc. (same name as media file)
|
|
// 4. .folder.jpg (hidden file)
|
|
// Returns the full path to the cover file, or empty string if not found.
|
|
func findSidecarCover(mediaPath string) string {
|
|
dir := filepath.Dir(mediaPath)
|
|
baseName := strings.TrimSuffix(filepath.Base(mediaPath), filepath.Ext(mediaPath))
|
|
|
|
// Priority order for cover filenames
|
|
coverPatterns := []string{
|
|
"cover.jpg",
|
|
"cover.jpeg",
|
|
"cover.png",
|
|
"cover.webp",
|
|
"folder.jpg",
|
|
"folder.jpeg",
|
|
"folder.png",
|
|
"folder.webp",
|
|
".folder.jpg",
|
|
".folder.jpeg",
|
|
".folder.png",
|
|
}
|
|
|
|
// First, check exact match cover/folder names
|
|
for _, coverName := range coverPatterns {
|
|
coverPath := filepath.Join(dir, coverName)
|
|
if _, err := os.Stat(coverPath); err == nil {
|
|
return coverPath
|
|
}
|
|
}
|
|
|
|
// Second, check for {basename}.{ext} pattern
|
|
extensions := []string{".jpg", ".jpeg", ".png", ".webp"}
|
|
for _, ext := range extensions {
|
|
coverPath := filepath.Join(dir, baseName+ext)
|
|
if _, err := os.Stat(coverPath); err == nil {
|
|
return coverPath
|
|
}
|
|
// Also check uppercase extension
|
|
coverPathUpper := filepath.Join(dir, baseName+strings.ToUpper(ext))
|
|
if _, err := os.Stat(coverPathUpper); err == nil {
|
|
return coverPathUpper
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// readPDFInfoDict reads a PDF's embedded Info dictionary into MediaMetadata,
|
|
// using the same field conventions as extractPDFMetadata (creator falls back
|
|
// to author, subject maps to description, producer to publisher, keywords to
|
|
// tags). No cover or filename logic - purely the document's own metadata, for
|
|
// filling sidecar gaps in mergeMetadata.
|
|
func (s *MediaScanner) readPDFInfoDict(path string) (*MediaMetadata, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() {
|
|
if err := f.Close(); err != nil {
|
|
fmt.Printf("Warning: failed to close PDF file %s: %v\n", path, err)
|
|
}
|
|
}()
|
|
|
|
pdfInfo, err := pdfcpuapi.PDFInfo(f, filepath.Base(path), nil, false, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
metadata := &MediaMetadata{}
|
|
if pdfInfo.Title != "" {
|
|
metadata.Title = pdfInfo.Title
|
|
}
|
|
if pdfInfo.Author != "" {
|
|
metadata.Author = pdfInfo.Author
|
|
} else if pdfInfo.Creator != "" {
|
|
metadata.Author = pdfInfo.Creator
|
|
}
|
|
if pdfInfo.Subject != "" {
|
|
metadata.Description = pdfInfo.Subject
|
|
}
|
|
if pdfInfo.Producer != "" {
|
|
metadata.Publisher = pdfInfo.Producer
|
|
}
|
|
if len(pdfInfo.Keywords) > 0 {
|
|
metadata.Tags = utils.NormalizeTags(pdfInfo.Keywords)
|
|
}
|
|
if pdfInfo.PageCount > 0 {
|
|
metadata.PageCount = int32(pdfInfo.PageCount)
|
|
}
|
|
return metadata, nil
|
|
}
|
|
|
|
func (s *MediaScanner) extractPDFMetadata(path string) (*MediaMetadata, error) {
|
|
metadata := &MediaMetadata{}
|
|
|
|
// Open PDF file for reading metadata
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
fmt.Printf("Warning: failed to open PDF file %s: %v\n", path, err)
|
|
metadata.Title = strings.TrimSuffix(filepath.Base(path), ".pdf")
|
|
return metadata, nil
|
|
}
|
|
defer func() {
|
|
if err := f.Close(); err != nil {
|
|
fmt.Printf("Warning: failed to close PDF file %s: %v\n", path, err)
|
|
}
|
|
}()
|
|
|
|
// Use pdfcpu API to read PDF metadata
|
|
// Configuration: nil = default (lenient mode)
|
|
pdfInfo, err := pdfcpuapi.PDFInfo(f, filepath.Base(path), nil, false, nil)
|
|
if err != nil {
|
|
fmt.Printf("Warning: failed to read PDF info from %s: %v\n", path, err)
|
|
// Fall back to filename as title
|
|
metadata.Title = strings.TrimSuffix(filepath.Base(path), ".pdf")
|
|
return metadata, nil
|
|
}
|
|
|
|
// Extract title
|
|
if pdfInfo.Title != "" {
|
|
metadata.Title = pdfInfo.Title
|
|
} else {
|
|
metadata.Title = strings.TrimSuffix(filepath.Base(path), ".pdf")
|
|
}
|
|
|
|
// Extract author
|
|
if pdfInfo.Author != "" {
|
|
metadata.Author = pdfInfo.Author
|
|
}
|
|
|
|
// Extract subject (use as description)
|
|
if pdfInfo.Subject != "" {
|
|
metadata.Description = pdfInfo.Subject
|
|
}
|
|
|
|
// Extract creator (use as author fallback)
|
|
if pdfInfo.Creator != "" && metadata.Author == "" {
|
|
metadata.Author = pdfInfo.Creator
|
|
}
|
|
|
|
// Extract producer (use as publisher)
|
|
if pdfInfo.Producer != "" {
|
|
metadata.Publisher = pdfInfo.Producer
|
|
}
|
|
|
|
metadata.PageCount = int32(pdfInfo.PageCount)
|
|
|
|
// Try to extract cover image
|
|
coverPath, err := s.extractPDFCover(path)
|
|
if err != nil {
|
|
fmt.Printf("Warning: failed to extract PDF cover from %s: %v\n", path, err)
|
|
} else if coverPath != "" {
|
|
metadata.CoverPath = s.getRelativePath(coverPath)
|
|
}
|
|
|
|
// If no embedded cover, try sidecar
|
|
if metadata.CoverPath == "" {
|
|
sidecarCover := findSidecarCover(path)
|
|
if sidecarCover != "" {
|
|
metadata.CoverPath = s.getRelativePath(sidecarCover)
|
|
}
|
|
}
|
|
|
|
return metadata, nil
|
|
}
|
|
|
|
// extractPDFCover extracts a cover image from a PDF file.
|
|
// It uses pdfcpu API to extract images from the first page.
|
|
// Returns the path to the saved cover, or empty string if no cover found.
|
|
func (s *MediaScanner) extractPDFCover(pdfPath string) (string, error) {
|
|
// Create a temporary directory for extracted images
|
|
tmpDir, err := os.MkdirTemp("", "pdf-cover-")
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create temp dir: %v", err)
|
|
}
|
|
defer func() {
|
|
if err := os.RemoveAll(tmpDir); err != nil {
|
|
fmt.Printf("Warning: failed to remove temp directory %s: %v\n", tmpDir, err)
|
|
}
|
|
}()
|
|
|
|
// Use pdfcpu API to extract images from first page
|
|
// ExtractImagesFile(inFile, outDir string, selectedPages []string, conf *model.Configuration) error
|
|
err = pdfcpuapi.ExtractImagesFile(pdfPath, tmpDir, []string{"1"}, nil)
|
|
if err == nil {
|
|
// Check for extracted images in the temp directory
|
|
entries, err := os.ReadDir(tmpDir)
|
|
if err == nil && len(entries) > 0 {
|
|
// Find the largest image (likely the cover)
|
|
var largestImage string
|
|
var largestSize int64
|
|
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
continue
|
|
}
|
|
info, err := entry.Info()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
// Skip very small files (likely thumbnails or icons)
|
|
if info.Size() < 1000 {
|
|
continue
|
|
}
|
|
if info.Size() > largestSize {
|
|
largestImage = filepath.Join(tmpDir, entry.Name())
|
|
largestSize = info.Size()
|
|
}
|
|
}
|
|
|
|
if largestImage != "" {
|
|
// Read the image
|
|
imageData, err := os.ReadFile(largestImage)
|
|
if err == nil && len(imageData) > 0 {
|
|
// Save cover to disk (same pattern as comics: {pdf_path}.cover.jpg)
|
|
coverPath := pdfPath + ".cover.jpg"
|
|
if err := os.WriteFile(coverPath, imageData, 0644); err != nil {
|
|
return "", fmt.Errorf("failed to write cover file: %v", err)
|
|
}
|
|
|
|
return coverPath, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// No embedded raster cover found (e.g. vector/text first page) - fall back
|
|
// to rendering the first page with pdftoppm (poppler-utils).
|
|
return s.renderPDFCoverPage(pdfPath), nil
|
|
}
|
|
|
|
// renderPDFCoverPage renders the first page of a PDF file to a JPEG image
|
|
// using pdftoppm. It saves the cover next to the PDF ({pdf_path}.cover.jpg).
|
|
// Returns the path to the saved cover, or empty string if rendering failed.
|
|
func (s *MediaScanner) renderPDFCoverPage(pdfPath string) string {
|
|
if _, err := exec.LookPath("pdftoppm"); err != nil {
|
|
fmt.Printf("Warning: pdftoppm not available, skipping PDF cover render for %s\n", pdfPath)
|
|
return ""
|
|
}
|
|
|
|
tmpDir, err := os.MkdirTemp("", "pdf-render-")
|
|
if err != nil {
|
|
fmt.Printf("Warning: failed to create temp dir for PDF cover render %s: %v\n", pdfPath, err)
|
|
return ""
|
|
}
|
|
defer func() {
|
|
if err := os.RemoveAll(tmpDir); err != nil {
|
|
fmt.Printf("Warning: failed to remove temp directory %s: %v\n", tmpDir, err)
|
|
}
|
|
}()
|
|
|
|
outPrefix := filepath.Join(tmpDir, "cover")
|
|
// -cropbox renders the CropBox (the viewer-visible region, matching pdf.js)
|
|
// rather than the MediaBox; poppler falls back to the MediaBox when no
|
|
// CropBox is defined. This matters for PDFs whose page 1 is a full print
|
|
// cover wrap (back + spine + front) with a CropBox covering just the front.
|
|
cmd := exec.Command("pdftoppm", "-jpeg", "-f", "1", "-l", "1", "-singlefile", "-cropbox", "-r", "150", pdfPath, outPrefix)
|
|
if output, err := cmd.CombinedOutput(); err != nil {
|
|
fmt.Printf("Warning: failed to render PDF cover from %s: %v, output: %s\n", pdfPath, err, string(output))
|
|
return ""
|
|
}
|
|
|
|
imageData, err := os.ReadFile(outPrefix + ".jpg")
|
|
if err != nil || len(imageData) < 1000 {
|
|
fmt.Printf("Warning: PDF cover render produced no usable image for %s\n", pdfPath)
|
|
return ""
|
|
}
|
|
|
|
// Save cover to disk (same pattern as comics: {pdf_path}.cover.jpg)
|
|
coverPath := pdfPath + ".cover.jpg"
|
|
if err := os.WriteFile(coverPath, imageData, 0644); err != nil {
|
|
fmt.Printf("Warning: failed to write rendered PDF cover for %s: %v\n", pdfPath, err)
|
|
return ""
|
|
}
|
|
|
|
return coverPath
|
|
}
|
|
|
|
// ComicInfo represents metadata from ComicInfo.xml
|
|
type ComicInfo struct {
|
|
XMLName xml.Name `xml:"ComicInfo"`
|
|
Title string `xml:"Title"`
|
|
Series string `xml:"Series"`
|
|
Number int `xml:"Number"`
|
|
Volume int `xml:"Volume"`
|
|
Publisher string `xml:"Publisher"`
|
|
Year int `xml:"Year"`
|
|
Month int `xml:"Month"`
|
|
Day int `xml:"Day"`
|
|
Writer string `xml:"Writer"`
|
|
Penciller string `xml:"Penciller"`
|
|
Inker string `xml:"Inker"`
|
|
Colorist string `xml:"Colorist"`
|
|
Letterer string `xml:"Letterer"`
|
|
CoverArtist string `xml:"CoverArtist"`
|
|
Genre string `xml:"Genre"`
|
|
Tags string `xml:"Tags"`
|
|
Web string `xml:"Web"`
|
|
Notes string `xml:"Notes"`
|
|
|
|
// NEW: Reading direction fields from ComicInfo.xml v2.0
|
|
Manga string `xml:"Manga"` // Unknown, No, Yes, YesAndRightToLeft
|
|
LanguageISO string `xml:"LanguageISO"` // ISO 639-1 language code for heuristics
|
|
|
|
// NEW: Additional comic-specific fields (19 total fields from ComicInfo.xml)
|
|
Count int `xml:"Count"` // Total issues in series
|
|
AlternateSeries string `xml:"AlternateSeries"`
|
|
AlternateNumber int `xml:"AlternateNumber"`
|
|
AlternateCount int `xml:"AlternateCount"`
|
|
Summary string `xml:"Summary"`
|
|
Imprint string `xml:"Imprint"`
|
|
StoryArc string `xml:"StoryArc"`
|
|
SeriesGroup string `xml:"SeriesGroup"`
|
|
AgeRating string `xml:"AgeRating"`
|
|
CommunityRating float64 `xml:"CommunityRating"`
|
|
MainCharacterOrTeam string `xml:"MainCharacterOrTeam"`
|
|
Review string `xml:"Review"`
|
|
BlackAndWhite string `xml:"BlackAndWhite"` // "Yes" or "No"
|
|
ScanInformation string `xml:"ScanInformation"`
|
|
Characters string `xml:"Characters"`
|
|
Teams string `xml:"Teams"`
|
|
Locations string `xml:"Locations"`
|
|
}
|
|
|
|
// extractComicMetadata extracts metadata from comic archive (supports .cbz, .cbr, .cb7, .cbt)
|
|
func extractComicMetadata(filePath string) (*ComicInfo, []byte, error) {
|
|
ext := strings.ToLower(filepath.Ext(filePath))
|
|
|
|
switch ext {
|
|
case ".cbz":
|
|
return extractZipMetadata(filePath)
|
|
case ".cbr":
|
|
return extractRarMetadata(filePath)
|
|
case ".cb7":
|
|
return extract7ZipMetadata(filePath)
|
|
case ".cbt":
|
|
return extractTarMetadata(filePath)
|
|
default:
|
|
return nil, nil, fmt.Errorf("unsupported comic format: %s", ext)
|
|
}
|
|
}
|
|
|
|
// archiveFile represents a file in an archive for uniform handling
|
|
type archiveFile interface {
|
|
Name() string
|
|
Open() (io.ReadCloser, error)
|
|
}
|
|
|
|
// extractMetadataFromArchive extracts ComicInfo.xml and cover image from any archive format
|
|
func extractMetadataFromArchive(files []archiveFile) (*ComicInfo, []byte, error) {
|
|
var comicInfo *ComicInfo
|
|
var coverImage []byte
|
|
|
|
for _, f := range files {
|
|
if f.Name() == "ComicInfo.xml" {
|
|
rc, err := f.Open()
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to open ComicInfo.xml: %w", err)
|
|
}
|
|
|
|
data, err := io.ReadAll(rc)
|
|
if closeErr := rc.Close(); closeErr != nil {
|
|
fmt.Printf("Warning: failed to close ComicInfo.xml reader: %v\n", closeErr)
|
|
}
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to read ComicInfo.xml: %w", err)
|
|
}
|
|
|
|
comicInfo = &ComicInfo{}
|
|
if err := xml.Unmarshal(data, comicInfo); err != nil {
|
|
return nil, nil, fmt.Errorf("failed to parse ComicInfo.xml: %w", err)
|
|
}
|
|
}
|
|
|
|
if coverImage == nil && isImageFile(f.Name()) {
|
|
if !strings.Contains(filepath.Dir(f.Name()), string(filepath.Separator)) ||
|
|
filepath.Dir(f.Name()) == "." {
|
|
rc, err := f.Open()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
coverImage, err = io.ReadAll(rc)
|
|
if closeErr := rc.Close(); closeErr != nil {
|
|
fmt.Printf("Warning: failed to close cover image reader for %s: %v\n", f.Name(), closeErr)
|
|
}
|
|
if err == nil {
|
|
_, _, err = image.Decode(bytes.NewReader(coverImage))
|
|
if err != nil {
|
|
coverImage = nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if comicInfo == nil {
|
|
comicInfo = &ComicInfo{}
|
|
}
|
|
|
|
return comicInfo, coverImage, nil
|
|
}
|
|
|
|
// extractZipMetadata extracts metadata from ZIP archives (.cbz)
|
|
func extractZipMetadata(filePath string) (*ComicInfo, []byte, error) {
|
|
r, err := zip.OpenReader(filePath)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to open ZIP archive: %w", err)
|
|
}
|
|
defer func() {
|
|
if err := r.Close(); err != nil {
|
|
fmt.Printf("Warning: failed to close ZIP archive reader for %s: %v\n", filePath, err)
|
|
}
|
|
}()
|
|
|
|
files := make([]archiveFile, 0, len(r.File))
|
|
for _, f := range r.File {
|
|
files = append(files, &zipFileAdapter{f})
|
|
}
|
|
|
|
comicInfo, coverImage, err := extractMetadataFromArchive(files)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
if comicInfo.Title == "" {
|
|
basename := filepath.Base(filePath)
|
|
comicInfo.Title = strings.TrimSuffix(basename, filepath.Ext(basename))
|
|
}
|
|
|
|
return comicInfo, coverImage, nil
|
|
}
|
|
|
|
// zipFileAdapter adapts zip.File to archiveFile interface
|
|
type zipFileAdapter struct {
|
|
*zip.File
|
|
}
|
|
|
|
func (z *zipFileAdapter) Name() string {
|
|
return z.File.Name
|
|
}
|
|
|
|
func (z *zipFileAdapter) Open() (io.ReadCloser, error) {
|
|
return z.File.Open()
|
|
}
|
|
|
|
// extractRarMetadata extracts metadata from RAR archives (.cbr)
|
|
func extractRarMetadata(filePath string) (*ComicInfo, []byte, error) {
|
|
r, err := rardecode.OpenReader(filePath, "")
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to open RAR archive: %w", err)
|
|
}
|
|
defer func() {
|
|
if err := r.Close(); err != nil {
|
|
fmt.Printf("Warning: failed to close RAR archive reader for %s: %v\n", filePath, err)
|
|
}
|
|
}()
|
|
|
|
files := make([]archiveFile, 0, 100)
|
|
for {
|
|
header, err := r.Next()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to read RAR entry: %w", err)
|
|
}
|
|
|
|
data, err := io.ReadAll(r)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
files = append(files, &rarFileAdapter{
|
|
name: header.Name,
|
|
data: data,
|
|
})
|
|
}
|
|
|
|
comicInfo, coverImage, err := extractMetadataFromArchive(files)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
if comicInfo.Title == "" {
|
|
basename := filepath.Base(filePath)
|
|
comicInfo.Title = strings.TrimSuffix(basename, filepath.Ext(basename))
|
|
}
|
|
|
|
return comicInfo, coverImage, nil
|
|
}
|
|
|
|
// rarFileAdapter stores RAR file data in memory
|
|
type rarFileAdapter struct {
|
|
name string
|
|
data []byte
|
|
}
|
|
|
|
func (r *rarFileAdapter) Name() string {
|
|
return r.name
|
|
}
|
|
|
|
func (r *rarFileAdapter) Open() (io.ReadCloser, error) {
|
|
return io.NopCloser(bytes.NewReader(r.data)), nil
|
|
}
|
|
|
|
// extract7ZipMetadata extracts metadata from 7-Zip archives (.cb7)
|
|
func extract7ZipMetadata(filePath string) (*ComicInfo, []byte, error) {
|
|
r, err := sevenzip.OpenReader(filePath)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to open 7-Zip archive: %w", err)
|
|
}
|
|
defer func() {
|
|
if err := r.Close(); err != nil {
|
|
fmt.Printf("Warning: failed to close 7-Zip archive reader for %s: %v\n", filePath, err)
|
|
}
|
|
}()
|
|
|
|
files := make([]archiveFile, 0, len(r.File))
|
|
for _, f := range r.File {
|
|
files = append(files, &sevenZipFileAdapter{file: f})
|
|
}
|
|
|
|
comicInfo, coverImage, err := extractMetadataFromArchive(files)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
if comicInfo.Title == "" {
|
|
basename := filepath.Base(filePath)
|
|
comicInfo.Title = strings.TrimSuffix(basename, filepath.Ext(basename))
|
|
}
|
|
|
|
return comicInfo, coverImage, nil
|
|
}
|
|
|
|
// sevenZipFileAdapter adapts sevenzip.File to archiveFile interface
|
|
type sevenZipFileAdapter struct {
|
|
file *sevenzip.File
|
|
}
|
|
|
|
func (s *sevenZipFileAdapter) Name() string {
|
|
return s.file.Name
|
|
}
|
|
|
|
func (s *sevenZipFileAdapter) Open() (io.ReadCloser, error) {
|
|
return s.file.Open()
|
|
}
|
|
|
|
// extractTarMetadata extracts metadata from TAR archives (.cbt)
|
|
func extractTarMetadata(filePath string) (*ComicInfo, []byte, error) {
|
|
f, err := os.Open(filePath)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to open TAR archive: %w", err)
|
|
}
|
|
defer func() {
|
|
if err := f.Close(); err != nil {
|
|
fmt.Printf("Warning: failed to close TAR archive file %s: %v\n", filePath, err)
|
|
}
|
|
}()
|
|
|
|
var tarReader *tar.Reader
|
|
if strings.HasSuffix(strings.ToLower(filePath), ".tar.gz") ||
|
|
strings.HasSuffix(strings.ToLower(filePath), ".tgz") {
|
|
gzReader, err := gzip.NewReader(f)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to create gzip reader: %w", err)
|
|
}
|
|
defer func() {
|
|
if err := gzReader.Close(); err != nil {
|
|
fmt.Printf("Warning: failed to close gzip reader for %s: %v\n", filePath, err)
|
|
}
|
|
}()
|
|
tarReader = tar.NewReader(gzReader)
|
|
} else if strings.HasSuffix(strings.ToLower(filePath), ".tar.bz2") ||
|
|
strings.HasSuffix(strings.ToLower(filePath), ".tbz2") {
|
|
bz2Reader := bzip2.NewReader(f)
|
|
tarReader = tar.NewReader(bz2Reader)
|
|
} else {
|
|
tarReader = tar.NewReader(f)
|
|
}
|
|
|
|
files := make([]archiveFile, 0, 100)
|
|
for {
|
|
header, err := tarReader.Next()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to read TAR entry: %w", err)
|
|
}
|
|
|
|
if header.Typeflag == tar.TypeReg {
|
|
data, err := io.ReadAll(tarReader)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
files = append(files, &tarFileAdapter{
|
|
name: header.Name,
|
|
data: data,
|
|
})
|
|
}
|
|
}
|
|
|
|
comicInfo, coverImage, err := extractMetadataFromArchive(files)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
if comicInfo.Title == "" {
|
|
basename := filepath.Base(filePath)
|
|
comicInfo.Title = strings.TrimSuffix(basename, filepath.Ext(basename))
|
|
}
|
|
|
|
return comicInfo, coverImage, nil
|
|
}
|
|
|
|
// tarFileAdapter stores TAR file data in memory
|
|
type tarFileAdapter struct {
|
|
name string
|
|
data []byte
|
|
}
|
|
|
|
func (t *tarFileAdapter) Name() string {
|
|
return t.name
|
|
}
|
|
|
|
func (t *tarFileAdapter) Open() (io.ReadCloser, error) {
|
|
return io.NopCloser(bytes.NewReader(t.data)), nil
|
|
}
|
|
|
|
// isImageFile checks if a file is an image based on extension
|
|
func isImageFile(filename string) bool {
|
|
ext := strings.ToLower(filepath.Ext(filename))
|
|
switch ext {
|
|
case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".avif", ".tiff", ".tif":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// countArchiveImages counts image files in a comic archive
|
|
func countArchiveImages(filePath string) (int, error) {
|
|
ext := strings.ToLower(filepath.Ext(filePath))
|
|
count := 0
|
|
|
|
switch ext {
|
|
case ".cbz", ".epub":
|
|
r, err := zip.OpenReader(filePath)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer r.Close()
|
|
for _, f := range r.File {
|
|
if !f.FileInfo().IsDir() && isImageFile(f.Name) {
|
|
count++
|
|
}
|
|
}
|
|
case ".cbr":
|
|
r, err := rardecode.OpenReader(filePath, "")
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer r.Close()
|
|
for {
|
|
header, err := r.Next()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
break
|
|
}
|
|
if !header.IsDir && isImageFile(header.Name) {
|
|
count++
|
|
}
|
|
}
|
|
case ".cb7":
|
|
sz, err := sevenzip.OpenReader(filePath)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer sz.Close()
|
|
for _, f := range sz.File {
|
|
if !f.FileInfo().IsDir() && isImageFile(f.Name) {
|
|
count++
|
|
}
|
|
}
|
|
case ".cbt":
|
|
f, err := os.Open(filePath)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer f.Close()
|
|
tr := tar.NewReader(f)
|
|
for {
|
|
header, err := tr.Next()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
break
|
|
}
|
|
if !header.FileInfo().IsDir() && isImageFile(header.Name) {
|
|
count++
|
|
}
|
|
}
|
|
}
|
|
|
|
return count, nil
|
|
}
|
|
|
|
// updateMediaItem re-extracts metadata for an existing media item and writes
|
|
// it back. Fields listed in the item's metadata_overrides set are preserved
|
|
// from the existing row so rescans never clobber user customizations; only
|
|
// reset-to-scanned-defaults (RescanMediaItem with reset) clears them.
|
|
func (s *MediaScanner) updateMediaItem(ctx context.Context, existing database.MediaItems, path string) error {
|
|
return s.updateMediaItemWithMetadata(ctx, existing, path, nil)
|
|
}
|
|
|
|
// updateMediaItemWithMetadata is updateMediaItem with an optional
|
|
// pre-extracted metadata set. Multi-file audiobooks arrive through
|
|
// RescanMediaItem with book-level metadata (sidecars, folder structure,
|
|
// first-track tags) because the generic per-file extraction would read
|
|
// the book ROOT/CONTAINER as if it were the book file.
|
|
func (s *MediaScanner) updateMediaItemWithMetadata(ctx context.Context, existing database.MediaItems, path string, preExtracted *MediaMetadata) error {
|
|
var metadata *MediaMetadata
|
|
if preExtracted != nil {
|
|
metadata = preExtracted
|
|
} else {
|
|
// Re-extract metadata for the update
|
|
extracted, err := s.extractMetadata(path)
|
|
if err != nil {
|
|
fmt.Printf("Warning: failed to extract metadata for force rescan %s: %v\n", path, err)
|
|
metadata = &MediaMetadata{}
|
|
} else if extracted == nil {
|
|
metadata = &MediaMetadata{}
|
|
} else {
|
|
metadata = extracted
|
|
}
|
|
}
|
|
|
|
// Folder-structure fallback, mirroring the CREATE path
|
|
// (processMediaFile): the update path skipped it, so a force rescan
|
|
// could clear series/author that a sidecar didn't carry (Libation
|
|
// writes "series": null while the folder layout knows the series).
|
|
for _, folder := range s.folders {
|
|
if folderContains(folder, path) {
|
|
if folderMetadata := s.extractFolderStructureMetadata(path, folder); folderMetadata != nil {
|
|
if metadata.Title == "" && folderMetadata.Title != "" {
|
|
metadata.Title = folderMetadata.Title
|
|
}
|
|
if metadata.Author == "" && folderMetadata.Author != "" {
|
|
metadata.Author = folderMetadata.Author
|
|
}
|
|
if metadata.Series == "" && folderMetadata.Series != "" {
|
|
metadata.Series = folderMetadata.Series
|
|
}
|
|
if metadata.SeriesNumber == 0 && folderMetadata.SeriesNumber > 0 {
|
|
metadata.SeriesNumber = folderMetadata.SeriesNumber
|
|
}
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
// Audiobooks (single-file): re-derive the audio facts + tag fallbacks
|
|
// AFTER the folder-structure pass — the precedence chain (sidecars →
|
|
// folder structure → tags → filename) must hold on rescans too.
|
|
// Multi-file books arrive with preExtracted metadata already carrying
|
|
// their enriched payload.
|
|
// Rescans must RE-DERIVE audio facts, never preserve stale ones
|
|
// (gotcha 39's principle applied to audio_metadata).
|
|
if preExtracted == nil && isAudioFile(path) {
|
|
if metadata.AudioMeta == nil {
|
|
metadata.AudioMeta = readSidecarAudio(path)
|
|
}
|
|
s.enrichAudioMetadata(path, metadata)
|
|
if metadata.CoverPath == "" {
|
|
s.applyEmbeddedCoverFallback(path, metadata)
|
|
}
|
|
}
|
|
|
|
// Final filename fallback, mirroring processMediaFile's create path:
|
|
// extractMetadata's default branch no longer pre-fills the filename
|
|
// as title (embedded tags must outrank it), so the update path needs
|
|
// the same last rung — a sidecar-less, tag-less book at a library
|
|
// root (no folder structure to match) keeps its filename-derived
|
|
// title instead of being blanked. Guarded to single-file updates:
|
|
// multi-file representatives are sentinels/containers whose names
|
|
// must never leak as titles.
|
|
if preExtracted == nil && metadata.Title == "" {
|
|
metadata.Title = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
|
}
|
|
|
|
// Normalize metadata fields
|
|
metadata.Contributors = utils.NormalizeContributors(metadata.Contributors)
|
|
metadata.Tags = utils.NormalizeTags(metadata.Tags)
|
|
contributorsSearch := utils.NormalizeContributorsSearch(metadata.Contributors)
|
|
tagsSearch := utils.NormalizeTagsSearch(metadata.Tags)
|
|
|
|
// Call the database update - only update fields available in MediaMetadata
|
|
var alternateInfoBytes []byte
|
|
if metadata.AlternateInfo != "" {
|
|
alternateInfoBytes = []byte(metadata.AlternateInfo)
|
|
}
|
|
// A re-extraction that produced no audio payload must never wipe
|
|
// scan-derived audio_metadata (tracks/chapters/duration): the
|
|
// ev-gated generic pass runs for multi-file books too, where
|
|
// extractMetadata leaves AudioMeta nil — writing NULL here deleted
|
|
// every folder book's payload and broke playback (2026-10-21
|
|
// incident: all multi-file audiobooks showed as 1 second long). The
|
|
// audio chain (UpdateMediaItemAudioBookPayload) owns this column;
|
|
// without fresh data from THAT chain, keep what the row has.
|
|
audioMetadata := marshalAudioMetadata(metadata.AudioMeta)
|
|
if audioMetadata == nil {
|
|
audioMetadata = existing.AudioMetadata
|
|
}
|
|
params := database.UpdateMediaItemParams{
|
|
ID: existing.ID,
|
|
Title: metadata.Title,
|
|
Subtitle: pgtype.Text{String: metadata.Subtitle, Valid: metadata.Subtitle != ""},
|
|
Authors: nilToEmpty(utils.SingleOrSlice(metadata.Author, metadata.Authors)),
|
|
AuthorsSearch: utils.NormalizeTagsSearch(utils.SingleOrSlice(metadata.Author, metadata.Authors)),
|
|
Isbn: pgtype.Text{String: utils.NormalizeISBNSafe(metadata.ISBN), Valid: metadata.ISBN != ""},
|
|
Description: pgtype.Text{String: metadata.Description, Valid: metadata.Description != ""},
|
|
CoverImagePath: pgtype.Text{String: s.getRelativePath(metadata.CoverPath), Valid: metadata.CoverPath != ""},
|
|
Series: pgtype.Text{String: metadata.Series, Valid: metadata.Series != ""},
|
|
SeriesNumber: pgtype.Float8{Float64: metadata.SeriesNumber, Valid: metadata.SeriesNumber > 0},
|
|
Narrators: nilToEmpty(metadata.Narrators),
|
|
NarratorsSearch: utils.NormalizeNarratorsSearch(metadata.Narrators),
|
|
Abridged: metadata.Abridged,
|
|
Explicit: metadata.Explicit,
|
|
Tags: metadata.Tags,
|
|
TagsSearch: tagsSearch,
|
|
Asin: pgtype.Text{String: metadata.ASIN, Valid: metadata.ASIN != ""},
|
|
DatePublished: pgtype.Date{Time: metadata.PublishDate, Valid: !metadata.PublishDate.IsZero()},
|
|
Publishers: nilToEmpty(utils.SingleOrSlice(metadata.Publisher, metadata.Publishers)),
|
|
PublishersSearch: utils.NormalizeTagsSearch(utils.SingleOrSlice(metadata.Publisher, metadata.Publishers)),
|
|
Contributors: metadata.Contributors,
|
|
ContributorsSearch: contributorsSearch,
|
|
Language: pgtype.Text{String: metadata.Language, Valid: metadata.Language != ""},
|
|
Genre: pgtype.Text{String: metadata.Genre, Valid: metadata.Genre != ""},
|
|
PageCount: pgtype.Int4{Int32: metadata.PageCount, Valid: metadata.PageCount > 0},
|
|
MangaType: pgtype.Text{String: metadata.MangaType, Valid: metadata.MangaType != ""},
|
|
ReadingDirection: pgtype.Text{String: metadata.ReadingDirection, Valid: metadata.ReadingDirection != ""},
|
|
SeriesCount: pgtype.Int4{Int32: metadata.SeriesCount, Valid: metadata.SeriesCount > 0},
|
|
Volume: pgtype.Int4{Int32: metadata.Volume, Valid: metadata.Volume > 0},
|
|
Imprint: pgtype.Text{String: metadata.Imprint, Valid: metadata.Imprint != ""},
|
|
AgeRating: pgtype.Text{String: metadata.AgeRating, Valid: metadata.AgeRating != ""},
|
|
WebUrl: pgtype.Text{String: metadata.WebURL, Valid: metadata.WebURL != ""},
|
|
MetadataNotes: pgtype.Text{String: metadata.MetadataNotes, Valid: metadata.MetadataNotes != ""},
|
|
CommunityRating: pgtype.Float8{Float64: metadata.CommunityRating, Valid: metadata.CommunityRating > 0},
|
|
StoryArc: pgtype.Text{String: metadata.StoryArc, Valid: metadata.StoryArc != ""},
|
|
IsBlackAndWhite: pgtype.Bool{Bool: metadata.IsBlackAndWhite, Valid: metadata.IsBlackAndWhite},
|
|
AlternateInfo: alternateInfoBytes,
|
|
ScanInformation: pgtype.Text{String: metadata.ScanInformation, Valid: metadata.ScanInformation != ""},
|
|
Summary: pgtype.Text{String: metadata.Summary, Valid: metadata.Summary != ""},
|
|
AudioMetadata: audioMetadata,
|
|
}
|
|
|
|
// Keep user-customized fields, and keep the override set itself intact.
|
|
// MergeOverrides guarantees a non-nil slice: a nil []string would encode
|
|
// as SQL NULL and violate metadata_overrides' NOT NULL constraint.
|
|
utils.ApplyMetadataOverrides(¶ms, existing)
|
|
params.MetadataOverrides = utils.MergeOverrides(existing.MetadataOverrides)
|
|
|
|
if _, err := s.db.UpdateMediaItem(ctx, params); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Format flags are file-derived facts, not user metadata: refresh them on
|
|
// every update so a force rescan (the admin's backfill tool) can
|
|
// reclassify rows created before a classifyFormatGroup arm existed.
|
|
// Mirrors the creation path above. Multi-file audiobooks keep the
|
|
// "audiobook" group: their representative path is a directory or a zip
|
|
// container, which no classify arm could name.
|
|
formatGroup, isReflowable, hasFixedLayout := "audiobook", false, false
|
|
if preExtracted == nil || preExtracted.AudioMeta == nil || preExtracted.AudioMeta.Layout == "" {
|
|
ext := strings.ToLower(filepath.Ext(path))
|
|
var epubIsFixedLayout bool
|
|
if ext == ".epub" {
|
|
isFixed, fixedErr := s.DetectFixedLayoutEPUB(path)
|
|
epubIsFixedLayout = fixedErr == nil && isFixed
|
|
}
|
|
formatGroup, isReflowable, hasFixedLayout = classifyFormatGroup(ext, epubIsFixedLayout)
|
|
}
|
|
mimeType := s.getMimeType(path)
|
|
if err := s.db.UpdateMediaItemFormatGroup(ctx, database.UpdateMediaItemFormatGroupParams{
|
|
ID: existing.ID,
|
|
FormatGroup: formatGroup,
|
|
FormatMimetype: pgtype.Text{String: mimeType, Valid: mimeType != ""},
|
|
IsReflowable: pgtype.Bool{Bool: isReflowable, Valid: true},
|
|
HasFixedLayout: pgtype.Bool{Bool: hasFixedLayout, Valid: true},
|
|
TotalCharacters: pgtype.Int8{Int64: metadata.TotalCharacters, Valid: metadata.TotalCharacters > 0},
|
|
ChapterCount: pgtype.Int4{Int32: metadata.ChapterCount, Valid: metadata.ChapterCount > 0},
|
|
}); err != nil {
|
|
fmt.Printf("Warning: failed to update format info for %s: %v\n", path, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RescanMediaItem re-extracts metadata for a single media item and updates it.
|
|
// It is the per-book rescan used by the Edit Metadata dialog and backfills
|
|
// covers for items imported before the PDF render fallback existed.
|
|
// With resetOverrides, user customizations are discarded first: the item is
|
|
// returned to pure scanned defaults (the "Reset to Scanned" action).
|
|
func (s *MediaScanner) RescanMediaItem(ctx context.Context, mediaItemID pgtype.UUID, resetOverrides bool) error {
|
|
item, err := s.db.GetMediaItem(ctx, mediaItemID)
|
|
if err != nil {
|
|
return fmt.Errorf("media item not found: %w", err)
|
|
}
|
|
|
|
if resetOverrides {
|
|
if err := s.db.ClearMediaItemMetadataOverrides(ctx, mediaItemID); err != nil {
|
|
return fmt.Errorf("failed to clear metadata overrides: %w", err)
|
|
}
|
|
// Empty - not nil: pgx encodes a nil []string parameter as SQL NULL,
|
|
// which would violate the column's NOT NULL constraint when
|
|
// updateMediaItem writes the row back.
|
|
item.MetadataOverrides = []string{}
|
|
}
|
|
|
|
folders, err := s.db.GetLibraryFolders(ctx, item.LibraryID)
|
|
if err != nil || len(folders) == 0 {
|
|
return fmt.Errorf("no library folders found for library")
|
|
}
|
|
|
|
folderPaths := make([]string, 0, len(folders))
|
|
for _, folder := range folders {
|
|
folderPaths = append(folderPaths, folder.FolderPath)
|
|
}
|
|
s.folders = folderPaths
|
|
|
|
var fullPath string
|
|
for _, folder := range folders {
|
|
candidate := filepath.Join(folder.FolderPath, item.FilePath)
|
|
if _, err := os.Stat(candidate); err == nil {
|
|
fullPath = candidate
|
|
break
|
|
}
|
|
}
|
|
if fullPath == "" {
|
|
return fmt.Errorf("media file not found on disk: %s", item.FilePath)
|
|
}
|
|
|
|
if _, err := os.Stat(fullPath); err != nil {
|
|
return fmt.Errorf("failed to stat media file: %w", err)
|
|
}
|
|
|
|
// Multi-file audiobooks (folder/zip layout) must NOT go through the
|
|
// generic per-file updater: it would read the book ROOT directory (or
|
|
// the zip container) as if it were the book file — deriving a filename
|
|
// title and classifying format_group "unknown", which drops the item
|
|
// out of the audio reader. The book-level path rebuilds the track
|
|
// manifest and re-derives metadata from sidecars/tags instead.
|
|
if item.FormatGroup == "audiobook" && audioLayoutOf(item.AudioMetadata) != "" {
|
|
return s.rescanMultiFileAudioBook(ctx, item, fullPath)
|
|
}
|
|
|
|
if err := s.updateMediaItem(ctx, item, fullPath); err != nil {
|
|
return fmt.Errorf("failed to update media item: %w", err)
|
|
}
|
|
s.recomputeHashInfo(ctx, mediaItemID, item.LibraryID, fullPath)
|
|
|
|
return nil
|
|
}
|
|
|
|
// loadScanState bulk-loads one library's scan state into the per-run
|
|
// map (faceted plan Part 2 §2). One query replaces the per-file
|
|
// GetMediaItemByFilePath flood for this scan.
|
|
func (s *MediaScanner) loadScanState(ctx context.Context, libraryID pgtype.UUID) error {
|
|
rows, err := s.db.GetScanStateByLibrary(ctx, libraryID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.scanStateMu.Lock()
|
|
defer s.scanStateMu.Unlock()
|
|
if s.scanState == nil {
|
|
s.scanState = make(map[string]*scanEntry, len(rows))
|
|
}
|
|
for _, row := range rows {
|
|
entry := &scanEntry{
|
|
ID: row.ID,
|
|
FilePath: row.FilePath,
|
|
FileSize: row.FileSize.Int64,
|
|
Archived: row.ArchivedAt.Valid,
|
|
MissingCount: row.MissingScanCount,
|
|
FileSha256: row.FileSha256.String,
|
|
Manifest: row.Manifest,
|
|
}
|
|
s.scanState[scanStateKey(libraryID, row.FilePath)] = entry
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// scanStateKey composites library + relative path — multi-folder
|
|
// libraries make a bare relative path ambiguous.
|
|
func scanStateKey(libraryID pgtype.UUID, relPath string) string {
|
|
return libraryID.String() + "\x00" + relPath
|
|
}
|
|
|
|
// scanStateLookup returns the bulk-loaded entry for an absolute file
|
|
// path, ok=false when the map is nil (non-bulk scan paths), the library
|
|
// is unknown, or the item is new. Caller resolves the library first
|
|
// (same precedence the per-file lookup used).
|
|
func (s *MediaScanner) scanStateLookup(libraryID pgtype.UUID, path string) (*scanEntry, bool) {
|
|
s.scanStateMu.RLock()
|
|
scanState := s.scanState
|
|
s.scanStateMu.RUnlock()
|
|
if scanState == nil || !libraryID.Valid {
|
|
return nil, false
|
|
}
|
|
relPath := s.getRelativePath(path)
|
|
entry, ok := scanState[scanStateKey(libraryID, relPath)]
|
|
return entry, ok
|
|
}
|
|
|
|
// scanStateUpsert writes a known-good entry back into the per-run map
|
|
// (write-through: later passes in the same run read post-update state).
|
|
func (s *MediaScanner) scanStateUpsert(libraryID pgtype.UUID, entry *scanEntry) {
|
|
s.scanStateMu.Lock()
|
|
defer s.scanStateMu.Unlock()
|
|
if s.scanState == nil {
|
|
return
|
|
}
|
|
s.scanState[scanStateKey(libraryID, entry.FilePath)] = entry
|
|
}
|
|
|
|
func (s *MediaScanner) getMediaItemByFilePath(ctx context.Context, filePath string, libraryID pgtype.UUID) (database.MediaItems, error) {
|
|
return s.db.GetMediaItemByFilePath(ctx, database.GetMediaItemByFilePathParams{
|
|
FilePath: s.getRelativePath(filePath),
|
|
LibraryID: libraryID,
|
|
})
|
|
}
|
|
|
|
// recomputeHashInfo recomputes the file's hash identifiers and stores them on
|
|
// the media item (plus its per-format row). Called on force rescan, on file
|
|
// size change, and when an unchanged item is found with no stored hash, so
|
|
// items imported before hashing existed are backfilled by ordinary scans.
|
|
// After storing, it records a hash conflict if the same content now exists at
|
|
// more than one path in the library.
|
|
func (s *MediaScanner) recomputeHashInfo(ctx context.Context, mediaItemID pgtype.UUID, libraryID pgtype.UUID, path string) {
|
|
hashInfo, formatInfo, err := s.extractHashInfo(path)
|
|
if err != nil {
|
|
fmt.Printf("Warning: failed to extract hash info from %s: %v\n", path, err)
|
|
return
|
|
}
|
|
if hashInfo == nil || hashInfo.FileSHA256 == "" {
|
|
return
|
|
}
|
|
|
|
_, err = s.db.UpdateMediaItemIdentifiers(ctx, database.UpdateMediaItemIdentifiersParams{
|
|
ID: mediaItemID,
|
|
FileSha256: pgtype.Text{String: hashInfo.FileSHA256, Valid: true},
|
|
OpfIdentifier: pgtype.Text{String: hashInfo.OPFIdentifier, Valid: hashInfo.OPFIdentifier != ""},
|
|
OpfUuid: pgtype.Text{String: hashInfo.OPFUUID, Valid: hashInfo.OPFUUID != ""},
|
|
HashConfidence: pgtype.Text{String: hashInfo.HashConfidence, Valid: hashInfo.HashConfidence != ""},
|
|
})
|
|
if err != nil {
|
|
fmt.Printf("Warning: failed to update hash identifiers for %s: %v\n", path, err)
|
|
return
|
|
}
|
|
|
|
if formatInfo != nil {
|
|
_, _ = s.db.CreateMediaItemFormat(ctx, database.CreateMediaItemFormatParams{
|
|
MediaItemID: mediaItemID,
|
|
FormatType: formatInfo.FormatType,
|
|
FilePath: pgtype.Text{String: s.getRelativePath(formatInfo.FilePath), Valid: true},
|
|
FileSha256: pgtype.Text{String: formatInfo.FileSHA256, Valid: true},
|
|
FileSizeBytes: pgtype.Int8{Int64: formatInfo.FileSizeBytes, Valid: true},
|
|
MimeType: pgtype.Text{String: formatInfo.MimeType, Valid: true},
|
|
})
|
|
}
|
|
|
|
s.recordHashConflictIfAny(ctx, libraryID, hashInfo.FileSHA256)
|
|
}
|
|
|
|
// recordHashConflictIfAny flags a pending hash conflict when the given content
|
|
// hash is now shared by more than one media item in the same library. The
|
|
// upsert is a no-op for already-tracked (including resolved) groups.
|
|
func (s *MediaScanner) recordHashConflictIfAny(ctx context.Context, libraryID pgtype.UUID, fileSHA256 string) {
|
|
items, err := s.db.ListMediaItemsBySHA256AndLibrary(ctx, database.ListMediaItemsBySHA256AndLibraryParams{
|
|
FileSha256: pgtype.Text{String: fileSHA256, Valid: true},
|
|
LibraryID: libraryID,
|
|
})
|
|
if err != nil {
|
|
return
|
|
}
|
|
if len(items) > 1 {
|
|
fmt.Printf("Hash conflict: %d media items share SHA-256 %s in one library\n", len(items), fileSHA256)
|
|
_ = s.db.CreateHashConflict(ctx, database.CreateHashConflictParams{
|
|
LibraryID: libraryID,
|
|
FileSha256: fileSHA256,
|
|
})
|
|
}
|
|
}
|
|
|
|
func (s *MediaScanner) getMimeType(path string) string {
|
|
ext := strings.ToLower(filepath.Ext(path))
|
|
if mime, ok := MimeTypes[ext]; ok {
|
|
return mime
|
|
}
|
|
return "application/octet-stream"
|
|
}
|
|
|
|
func (s *MediaScanner) WatchChanges(ctx context.Context) error {
|
|
if !s.watching.CompareAndSwap(false, true) {
|
|
return fmt.Errorf("already watching")
|
|
}
|
|
|
|
go func() {
|
|
<-ctx.Done()
|
|
s.watching.Store(false)
|
|
}()
|
|
|
|
go s.performInitialScan(ctx)
|
|
|
|
go s.processDirtyDirectories(ctx)
|
|
|
|
go s.startBackupScan(ctx)
|
|
|
|
go func() {
|
|
// The event loop only runs if a real watcher was set up (watch=true).
|
|
// If watching with no watcher (e.g. inotify unavailable through a Docker
|
|
// bind mount), polling via startBackupScan above still handles detection.
|
|
if s.watcher == nil {
|
|
fmt.Printf("[WATCHER] No inotify watcher configured; relying on periodic polling for change detection\n")
|
|
return
|
|
}
|
|
fmt.Printf("[WATCHER] Event loop started for %d folders\n", len(s.folders))
|
|
for {
|
|
select {
|
|
case event, ok := <-s.watcher.Events:
|
|
if !ok {
|
|
fmt.Printf("[WATCHER] Event channel closed\n")
|
|
return
|
|
}
|
|
|
|
if event.Has(fsnotify.Create) {
|
|
if info, err := os.Stat(event.Name); err == nil && info.IsDir() {
|
|
if err := s.watcher.Add(event.Name); err != nil {
|
|
fmt.Printf("[WATCHER] Warning: failed to watch new directory %s: %v\n", event.Name, err)
|
|
} else {
|
|
fmt.Printf("[WATCHER] Now watching new directory: %s\n", event.Name)
|
|
}
|
|
}
|
|
}
|
|
|
|
if event.Has(fsnotify.Create | fsnotify.Write | fsnotify.Remove | fsnotify.Rename) {
|
|
fmt.Printf("[WATCHER] Event: %s on %s\n", event.Op, event.Name)
|
|
s.markDirectoryDirty(filepath.Dir(event.Name))
|
|
}
|
|
|
|
case err, ok := <-s.watcher.Errors:
|
|
if !ok {
|
|
fmt.Printf("[WATCHER] Error channel closed\n")
|
|
return
|
|
}
|
|
fmt.Printf("[WATCHER] Error: %v\n", err)
|
|
|
|
case <-ctx.Done():
|
|
fmt.Printf("[WATCHER] Event loop stopped\n")
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
return nil
|
|
}
|
|
|
|
func (s *MediaScanner) markDirectoryDirty(dirPath string) {
|
|
s.dirtyDirsMu.Lock()
|
|
defer s.dirtyDirsMu.Unlock()
|
|
|
|
// Only mark if within watched folders
|
|
var isWatched bool
|
|
for _, folder := range s.folders {
|
|
if strings.HasPrefix(dirPath, folder) {
|
|
isWatched = true
|
|
break
|
|
}
|
|
}
|
|
if !isWatched {
|
|
return
|
|
}
|
|
|
|
// Smart event merging (Jellyfin approach):
|
|
// 1. If parent dir exists, replace with parent (consolidate)
|
|
// 2. If sibling dirs exist, replace with common parent
|
|
// 3. Otherwise, add this dir
|
|
|
|
// Check if parent directory is already dirty
|
|
parentDir := filepath.Dir(dirPath)
|
|
if parentDir != dirPath { // Not at root
|
|
if _, parentExists := s.dirtyDirs[parentDir]; parentExists {
|
|
// Parent already being watched, reset its timestamp
|
|
s.dirtyDirs[parentDir] = time.Now()
|
|
return
|
|
}
|
|
}
|
|
|
|
// Check if any subdirectories are dirty, replace with parent
|
|
for existingDir := range s.dirtyDirs {
|
|
if strings.HasPrefix(existingDir, dirPath+"/") {
|
|
// This is a subdirectory, replace it with parent
|
|
delete(s.dirtyDirs, existingDir)
|
|
}
|
|
}
|
|
|
|
// NEW: Check for sibling directories and consolidate to parent
|
|
parentDir = filepath.Dir(dirPath)
|
|
for existingDir := range s.dirtyDirs {
|
|
existingParent := filepath.Dir(existingDir)
|
|
if existingParent == parentDir && existingParent != dirPath && existingParent != "." {
|
|
// Found a sibling! Both should be replaced with parent
|
|
delete(s.dirtyDirs, existingDir)
|
|
s.dirtyDirs[parentDir] = time.Now()
|
|
return
|
|
}
|
|
}
|
|
|
|
// Add/update this directory
|
|
s.dirtyDirs[dirPath] = time.Now()
|
|
}
|
|
|
|
func (s *MediaScanner) processDirtyDirectories(ctx context.Context) {
|
|
ticker := time.NewTicker(1 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
|
|
case <-ticker.C:
|
|
s.dirtyDirsMu.Lock()
|
|
|
|
now := time.Now()
|
|
readyDirs := make([]string, 0)
|
|
|
|
for dirPath, lastChange := range s.dirtyDirs {
|
|
if now.Sub(lastChange) >= 10*time.Second {
|
|
readyDirs = append(readyDirs, dirPath)
|
|
delete(s.dirtyDirs, dirPath)
|
|
}
|
|
}
|
|
|
|
s.dirtyDirsMu.Unlock()
|
|
|
|
if len(readyDirs) == 0 {
|
|
continue
|
|
}
|
|
|
|
affectedRoots := make(map[string]bool)
|
|
for _, dirPath := range readyDirs {
|
|
for _, folder := range s.folders {
|
|
if strings.HasPrefix(dirPath, folder) {
|
|
affectedRoots[folder] = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
for rootFolder := range affectedRoots {
|
|
s.enqueueLibraryScan(rootFolder, "watcher")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// waitForFileStability checks if a file's mtime has stabilized
|
|
// Returns true when file is stable (not being modified)
|
|
// Polls every 3 seconds, times out after 60 seconds
|
|
// Uses atomic.Bool to prevent race conditions with concurrent checks
|
|
func (s *MediaScanner) waitForFileStability(filePath string) bool {
|
|
s.fileStabilityMu.Lock()
|
|
|
|
// Check if already being checked (atomic.Bool prevents race condition)
|
|
tracking, exists := s.fileStability[filePath]
|
|
if exists {
|
|
s.fileStabilityMu.Unlock()
|
|
// Another goroutine is already checking this file
|
|
if tracking.Load() {
|
|
return false // Still being checked
|
|
}
|
|
// Tracking exists but completed, remove stale entry
|
|
s.fileStabilityMu.Lock()
|
|
delete(s.fileStability, filePath)
|
|
}
|
|
|
|
// Start tracking with atomic.Bool set to true (checking in progress)
|
|
trackingFlag := &atomic.Bool{}
|
|
trackingFlag.Store(true)
|
|
s.fileStability[filePath] = trackingFlag
|
|
s.fileStabilityMu.Unlock()
|
|
|
|
// Get initial mtime
|
|
info, err := os.Stat(filePath)
|
|
if err != nil {
|
|
// Clean up tracking entry if file doesn't exist
|
|
s.fileStabilityMu.Lock()
|
|
delete(s.fileStability, filePath)
|
|
s.fileStabilityMu.Unlock()
|
|
return false
|
|
}
|
|
lastMtime := info.ModTime()
|
|
|
|
// Poll every 3 seconds for up to 60 seconds
|
|
timeout := time.After(60 * time.Second)
|
|
ticker := time.NewTicker(3 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-timeout:
|
|
// Timeout - mark as done and clean up
|
|
trackingFlag.Store(false)
|
|
s.fileStabilityMu.Lock()
|
|
delete(s.fileStability, filePath)
|
|
s.fileStabilityMu.Unlock()
|
|
return false // File never stabilized
|
|
|
|
case <-ticker.C:
|
|
info, err := os.Stat(filePath)
|
|
if err != nil {
|
|
// File deleted - mark as done and clean up
|
|
trackingFlag.Store(false)
|
|
s.fileStabilityMu.Lock()
|
|
delete(s.fileStability, filePath)
|
|
s.fileStabilityMu.Unlock()
|
|
return false
|
|
}
|
|
|
|
currentMtime := info.ModTime()
|
|
if currentMtime.Equal(lastMtime) {
|
|
// File is stable! Mark as done and clean up
|
|
trackingFlag.Store(false)
|
|
s.fileStabilityMu.Lock()
|
|
delete(s.fileStability, filePath)
|
|
s.fileStabilityMu.Unlock()
|
|
return true
|
|
}
|
|
|
|
lastMtime = currentMtime
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *MediaScanner) scanDirectory(ctx context.Context, dirPath string) {
|
|
// Prevent concurrent scans of ANY directory
|
|
// Simple mutex is enough - job queue already serializes by directory
|
|
s.scanMutex.Lock()
|
|
defer s.scanMutex.Unlock()
|
|
|
|
s.scanInProgress.Store(true)
|
|
defer s.scanInProgress.Store(false)
|
|
|
|
// Find library for this directory
|
|
var libraryID pgtype.UUID
|
|
var rootFolder string
|
|
|
|
for _, folder := range s.folders {
|
|
if strings.HasPrefix(dirPath, folder) {
|
|
rootFolder = folder
|
|
if lib, err := s.db.GetLibraryByFolderPathPrefix(ctx, dirPath); err == nil {
|
|
libraryID = lib.LibraryID
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if libraryID is valid before proceeding
|
|
if !libraryID.Valid {
|
|
return
|
|
}
|
|
|
|
// Walk directory and process new files (recurses into subdirectories)
|
|
if err := filepath.WalkDir(dirPath, func(path string, d os.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if d.IsDir() {
|
|
return nil
|
|
}
|
|
if !s.isScannableFile(path) {
|
|
return nil
|
|
}
|
|
|
|
// Check if file is stable before processing (Audiobookshelf approach)
|
|
if !s.waitForFileStability(path) {
|
|
return nil
|
|
}
|
|
relPath := strings.TrimPrefix(path, rootFolder+"/")
|
|
_, err = s.db.GetMediaItemByFilePath(ctx, database.GetMediaItemByFilePathParams{
|
|
FilePath: relPath,
|
|
LibraryID: libraryID,
|
|
})
|
|
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
if _, err := s.processMediaFile(ctx, path); err != nil {
|
|
s.errors++
|
|
} else {
|
|
s.newItems++
|
|
}
|
|
s.totalFiles++
|
|
}
|
|
|
|
return nil
|
|
}); err != nil {
|
|
fmt.Printf("Warning: failed to walk directory %s: %v\n", dirPath, err)
|
|
}
|
|
}
|
|
|
|
// performInitialScan scans all root folders on startup
|
|
// This ensures existing files are detected before watching begins
|
|
func (s *MediaScanner) performInitialScan(ctx context.Context) {
|
|
fmt.Printf("Performing initial scan of root folders...\n")
|
|
|
|
if s.defaultLibraryID.Valid && s.adminID.Valid {
|
|
folderPaths := s.folders
|
|
libraryIDStr := uuid.UUID(s.defaultLibraryID.Bytes).String()
|
|
adminIDStr := uuid.UUID(s.adminID.Bytes).String()
|
|
|
|
job := &Job{
|
|
ID: uuid.New().String(),
|
|
Type: JobTypeScan,
|
|
Status: JobStatusPending,
|
|
UserID: adminIDStr,
|
|
Context: context.Background(),
|
|
Params: map[string]any{
|
|
"library_id": libraryIDStr,
|
|
"folders": folderPaths,
|
|
"admin_id": adminIDStr,
|
|
"db": s.db,
|
|
"force": false,
|
|
"trigger": "initial",
|
|
},
|
|
}
|
|
|
|
if WorkerInstance != nil {
|
|
WorkerInstance.Enqueue(job)
|
|
fmt.Printf("Enqueued initial library scan job\n")
|
|
} else {
|
|
fmt.Printf("Warning: Worker not initialized, skipping initial scan\n")
|
|
}
|
|
} else {
|
|
fmt.Printf("Warning: no library/admin ID set, skipping initial scan\n")
|
|
}
|
|
|
|
fmt.Printf("Initial scan jobs enqueued\n")
|
|
}
|
|
|
|
func (s *MediaScanner) Close() error {
|
|
fmt.Printf("Cleaning up scanner resources...\n")
|
|
|
|
// Close scan log files (no-op-safe if already closed)
|
|
if s.logger != nil {
|
|
s.logger.Close()
|
|
}
|
|
|
|
// Stop watching
|
|
if s.watcher != nil {
|
|
if err := s.watcher.Close(); err != nil {
|
|
fmt.Printf("Warning: failed to close fsnotify watcher: %v\n", err)
|
|
}
|
|
}
|
|
|
|
// Clean up fileStability map to prevent memory leaks
|
|
s.fileStabilityMu.Lock()
|
|
s.fileStability = make(map[string]*atomic.Bool)
|
|
s.fileStabilityMu.Unlock()
|
|
|
|
// Clear dirty directories
|
|
s.dirtyDirsMu.Lock()
|
|
s.dirtyDirs = make(map[string]time.Time)
|
|
s.dirtyDirsMu.Unlock()
|
|
|
|
// Wait for in-progress scan to complete (with timeout)
|
|
timeout := time.After(5 * time.Second)
|
|
done := make(chan struct{})
|
|
|
|
go func() {
|
|
for s.scanInProgress.Load() {
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
close(done)
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
fmt.Printf("Scanner cleanup complete\n")
|
|
case <-timeout:
|
|
fmt.Printf("Timeout waiting for scan to complete\n")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
func (s *MediaScanner) startBackupScan(ctx context.Context) {
|
|
interval := s.GetPollInterval()
|
|
if interval <= 0 {
|
|
fmt.Println("[BACKUP-SCAN] Periodic scan disabled (interval = 0)")
|
|
return
|
|
}
|
|
fmt.Printf("[BACKUP-SCAN] Periodic scan started with interval: %v\n", interval)
|
|
|
|
for {
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
fmt.Println("[BACKUP-SCAN] Periodic scan stopped")
|
|
return
|
|
case <-ticker.C:
|
|
interval = s.GetPollInterval()
|
|
if !s.GetAutoScanEnabled() {
|
|
continue
|
|
}
|
|
fmt.Printf("[BACKUP-SCAN] Running periodic full scan (interval: %v)...\n", interval)
|
|
for _, folder := range s.folders {
|
|
s.enqueueLibraryScan(folder, "poll")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *MediaScanner) calculateFileSHA256(filePath string) (string, error) {
|
|
return computeFileSHA256(filePath)
|
|
}
|
|
|
|
// computeFileSHA256 is the package-level full-file SHA-256 used by the hash
|
|
// backfill service; the MediaScanner method delegates to it.
|
|
func computeFileSHA256(filePath string) (string, error) {
|
|
file, err := os.Open(filePath)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to open file: %v", err)
|
|
}
|
|
defer func() {
|
|
if err := file.Close(); err != nil {
|
|
fmt.Printf("Warning: failed to close file %s: %v\n", filePath, err)
|
|
}
|
|
}()
|
|
|
|
hasher := sha256.New()
|
|
if _, err := io.Copy(hasher, file); err != nil {
|
|
return "", fmt.Errorf("failed to calculate hash: %v", err)
|
|
}
|
|
|
|
return hex.EncodeToString(hasher.Sum(nil)), nil
|
|
}
|
|
|
|
// OPFIdentifier represents an identifier from OPF metadata
|
|
type OPFIdentifier struct {
|
|
XMLName xml.Name `xml:"identifier"`
|
|
ID string `xml:"id,attr"`
|
|
Scheme string `xml:"scheme,attr"`
|
|
Content string `xml:",chardata"`
|
|
}
|
|
|
|
// OPFMetadata represents parsed OPF metadata
|
|
type OPFMetadata struct {
|
|
XMLName xml.Name `xml:"package"`
|
|
Version string `xml:"version,attr"`
|
|
Identifiers []OPFIdentifier `xml:"metadata>identifier"`
|
|
}
|
|
|
|
// extractOPFIdentifiers extracts identifiers from EPUB OPF file
|
|
func (s *MediaScanner) extractOPFIdentifiers(epubPath string) (opfIdentifier, opfUUID string, confidence string, err error) {
|
|
book, err := epub.ReadBook(epubPath)
|
|
if err != nil {
|
|
return "", "", "", fmt.Errorf("failed to open EPUB: %v", err)
|
|
}
|
|
|
|
// Extract OPF identifiers using go-epub library
|
|
identifiers, err := book.MetadataByKey("identifier")
|
|
if err != nil || len(identifiers) == 0 {
|
|
return "", "", "low", nil
|
|
}
|
|
|
|
var identifier, uuidString string
|
|
for _, id := range identifiers {
|
|
id = strings.TrimSpace(id)
|
|
|
|
// Check for UUID format (urn:uuid:)
|
|
if trimmed, found := strings.CutPrefix(strings.ToLower(id), "urn:uuid:"); found {
|
|
uuidString = trimmed
|
|
continue
|
|
}
|
|
|
|
// Check if it's a plain UUID (8-4-4-4-12 format)
|
|
if isValidUUID(id) {
|
|
uuidString = id
|
|
continue
|
|
}
|
|
|
|
// Check for ISBN
|
|
if strings.Contains(strings.ToLower(id), "isbn") {
|
|
isbn := s.extractISBNFromIdentifier(id)
|
|
if isbn != "" {
|
|
identifier = isbn
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Use first identifier as fallback
|
|
if identifier == "" && id != "" {
|
|
identifier = id
|
|
}
|
|
}
|
|
|
|
confidence = s.determineHashConfidence(uuidString, identifier)
|
|
|
|
return identifier, uuidString, confidence, nil
|
|
}
|
|
|
|
// isValidUUID checks if string is a valid UUID (8-4-4-4-12 format)
|
|
func isValidUUID(idStr string) bool {
|
|
uuidRegex := regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
|
|
return uuidRegex.MatchString(idStr)
|
|
}
|
|
|
|
// extractISBNFromIdentifier extracts ISBN from identifier string
|
|
func (s *MediaScanner) extractISBNFromIdentifier(id string) string {
|
|
id = strings.TrimSpace(id)
|
|
|
|
// Remove "isbn:" prefix if present
|
|
if trimmed, found := strings.CutPrefix(strings.ToLower(id), "isbn:"); found {
|
|
id = trimmed
|
|
}
|
|
|
|
// Remove hyphens and spaces
|
|
isbn := regexp.MustCompile(`[\s-]`).ReplaceAllString(id, "")
|
|
|
|
// Check if it's a valid ISBN-10 or ISBN-13
|
|
if len(isbn) == 10 || len(isbn) == 13 {
|
|
return isbn
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// determineHashConfidence determines confidence level based on available identifiers
|
|
func (s *MediaScanner) determineHashConfidence(uuid, identifier string) string {
|
|
if uuid != "" && isValidUUID(uuid) {
|
|
return "high"
|
|
}
|
|
if identifier != "" && (strings.Contains(strings.ToLower(identifier), "isbn") || len(identifier) >= 10) {
|
|
return "medium"
|
|
}
|
|
return "low"
|
|
}
|
|
|
|
// detectFormatType detects the format type based on file extension and content
|
|
func (s *MediaScanner) detectFormatType(filePath string) string {
|
|
base := strings.ToLower(filepath.Base(filePath))
|
|
|
|
// Check for compound extensions first (like .kepub.epub)
|
|
if strings.HasSuffix(base, ".kepub.epub") {
|
|
return "kepub"
|
|
}
|
|
|
|
ext := strings.ToLower(filepath.Ext(filePath))
|
|
|
|
switch ext {
|
|
case ".epub":
|
|
return "epub"
|
|
case ".kepub":
|
|
return "kepub"
|
|
case ".pdf":
|
|
return "pdf"
|
|
case ".cbz", ".cbr", ".cb7", ".cbt":
|
|
return "comic_archive"
|
|
case ".m4b", ".m4a", ".mp3", ".flac", ".opus", ".ogg", ".oga", ".aac", ".wav":
|
|
return "audiobook"
|
|
case ".mobi":
|
|
return "mobi"
|
|
case ".azw", ".azw3":
|
|
return "kfx"
|
|
case ".txt":
|
|
return "txt"
|
|
case ".fb2":
|
|
return "fb2"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
// classifyFormatGroup maps a lowercased file extension to the format
|
|
// group and layout flags stored on the media item. `.epub` is passed in
|
|
// with its detected fixed-layout flag because that check needs the file
|
|
// itself.
|
|
func classifyFormatGroup(ext string, epubIsFixedLayout bool) (formatGroup string, isReflowable, hasFixedLayout bool) {
|
|
switch ext {
|
|
case ".epub":
|
|
if epubIsFixedLayout {
|
|
return "fixed_layout", false, true
|
|
}
|
|
return "reflowable", true, false
|
|
case ".mobi", ".azw", ".azw3", ".fb2", ".txt", ".docx", ".rtf", ".pdb":
|
|
return "reflowable", true, false
|
|
case ".pdf", ".djvu":
|
|
return "fixed_layout", false, true
|
|
case ".cbz", ".cbr", ".cb7", ".cbt":
|
|
return "comic_archive", false, true
|
|
case ".m4b", ".m4a", ".mp3", ".flac", ".opus", ".ogg", ".oga", ".aac", ".wav":
|
|
// Audio is neither reflowable text nor fixed-layout pages; the
|
|
// "audiobook" group routes readers to the audio player instead
|
|
// of any document engine.
|
|
return "audiobook", false, false
|
|
default:
|
|
return "unknown", false, false
|
|
}
|
|
}
|
|
|
|
// extractHashInfo calculates hash and extracts OPF identifiers for a file
|
|
func (s *MediaScanner) extractHashInfo(filePath string) (*HashInfo, *FormatInfo, error) {
|
|
// Calculate SHA-256
|
|
fileSHA256, err := s.calculateFileSHA256(filePath)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to calculate SHA-256: %v", err)
|
|
}
|
|
|
|
// Get file info
|
|
info, err := os.Stat(filePath)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to get file info: %v", err)
|
|
}
|
|
|
|
// Extract OPF identifiers for EPUB files
|
|
var opfIdentifier, opfUUID, confidence string
|
|
if strings.HasSuffix(strings.ToLower(filePath), ".epub") {
|
|
opfIdentifier, opfUUID, confidence, err = s.extractOPFIdentifiers(filePath)
|
|
if err != nil {
|
|
// Non-fatal error, continue with low confidence
|
|
confidence = "low"
|
|
}
|
|
}
|
|
|
|
hashInfo := &HashInfo{
|
|
FileSHA256: fileSHA256,
|
|
OPFIdentifier: opfIdentifier,
|
|
OPFUUID: opfUUID,
|
|
HashConfidence: confidence,
|
|
}
|
|
|
|
formatInfo := &FormatInfo{
|
|
FormatType: s.detectFormatType(filePath),
|
|
FilePath: filePath,
|
|
FileSHA256: fileSHA256,
|
|
FileSizeBytes: info.Size(),
|
|
MimeType: s.getMimeType(filePath),
|
|
}
|
|
|
|
return hashInfo, formatInfo, nil
|
|
}
|
|
|
|
// folderContains reports whether path is folder itself or lies beneath it
|
|
// at a directory boundary. This is the only path-correct containment test:
|
|
// a bare strings.HasPrefix(path, folder) also matches string-prefix
|
|
// siblings ("/app/audiobooks" claims "/app/audiobookssd/..."), which once
|
|
// sliced a stray "sd/" into every stored relative path for that library
|
|
// and resolved those files against the wrong root (folder ownership,
|
|
// library attribution, extension allowlisting, cover 404s).
|
|
func folderContains(folder, path string) bool {
|
|
folder = filepath.Clean(folder)
|
|
return path == folder || strings.HasPrefix(path, folder+string(filepath.Separator))
|
|
}
|
|
|
|
func (s *MediaScanner) getRelativePath(absolutePath string) string {
|
|
// Get the base folder paths from scanner
|
|
for _, baseFolder := range s.folders {
|
|
// Check if path is within this base folder — boundary-aware, so a
|
|
// sibling whose name merely extends ours can never be cut.
|
|
if !folderContains(baseFolder, absolutePath) {
|
|
continue
|
|
}
|
|
return strings.TrimPrefix(absolutePath[len(filepath.Clean(baseFolder)):], "/")
|
|
}
|
|
// Fallback: if no match, return as-is (shouldn't happen)
|
|
return absolutePath
|
|
}
|