package library import ( "errors" "fmt" "os" "path/filepath" "time" "yellowjacket/backend/coverart" ) var errNoLibrariesConfigured = errors.New( "no libraries configured for rescan", ) // FullRescan clears the queue and player, wipes all library data // (database records and cover art files), and performs a fresh // scan of every library in the database. The returned ScanMetrics // reflects the last library scanned; clear-phase durations are // folded into its totals. func (l *Library) FullRescan() (*ScanMetrics, error) { l.logger.Info("beginning full library rescan") // Resolve all libraries from the database. libs, err := l.db.Queries.GetAllLibraries(l.ctx) if err != nil { return nil, fmt.Errorf( "could not get libraries for rescan: %w", err, ) } if len(libs) == 0 { return nil, errNoLibrariesConfigured } // Run the pre-clear hook (e.g. clear queue / stop playback) // before wiping data so the player is not referencing // now-deleted tracks. clearQueueStart := time.Now() if l.rescanHooks.PreClear != nil { l.rescanHooks.PreClear() } clearQueueDur := time.Since(clearQueueStart) // Clear all library data (DB + cover art files). clearDBStart := time.Now() if err := l.clearLibraryTables(); err != nil { return nil, fmt.Errorf( "could not clear library tables: %w", err, ) } clearDBDur := time.Since(clearDBStart) clearFilesStart := time.Now() if err := l.clearCoverArtFiles(); err != nil { return nil, fmt.Errorf( "could not clear cover art files: %w", err, ) } clearFilesDur := time.Since(clearFilesStart) l.logger.Info("library data cleared successfully") // Scan the first library directly (bypassing the queue) so // we get ScanMetrics back. Queue remaining libraries so // they run sequentially via the scan coordinator. metrics := l.scanInternal(libs[0].ID, libs[0].Name, libs[0].Path) for _, lib := range libs[1:] { if scanErr := l.ScanLibrary(lib.ID); scanErr != nil { l.logger.Error("could not queue library for rescan", "libraryID", lib.ID, "err", scanErr) } } // Drain the queue in a goroutine so any queued libraries // scan sequentially. scanInternal was called directly (not // via startScan), so drainQueue hasn't been invoked yet. go l.drainQueue() if metrics != nil { metrics.ClearQueue = clearQueueDur metrics.ClearDatabase = clearDBDur metrics.ClearCoverFiles = clearFilesDur // Include clear-phase durations in the total so the // displayed value reflects true wall-clock time. metrics.Total += clearQueueDur + clearDBDur + clearFilesDur } // Run the post-scan hook (e.g. restore playlists from M3U8 // files) now that audio_files are populated again. if l.rescanHooks.PostScan != nil { l.rescanHooks.PostScan() } return metrics, nil } // clearLibraryTables deletes all library-related rows in FK-safe // order within a single transaction. func (l *Library) clearLibraryTables() error { tx, err := l.db.BeginTx() if err != nil { return fmt.Errorf("could not begin transaction: %w", err) } defer func() { _ = tx.Rollback() }() txq := l.db.Queries.WithTx(tx) // Phase 1: leaf tables (nothing references these). if err := txq.ClearQueueTracks(l.ctx); err != nil { return fmt.Errorf("could not clear queue tracks: %w", err) } // A full rescan is the "start over" button, and it is the only way // back for a path removed from the library by mistake: the file is // still on disk, but nothing else will ever import it again while // its exclusion stands. Until there is a UI for managing the list, // clearing it here is the escape hatch. if err := txq.ClearExcludedPaths(l.ctx); err != nil { return fmt.Errorf("could not clear excluded paths: %w", err) } // Preserve playlist tracks across rescan: populate phantom // metadata for all linked tracks before audio_files are deleted. // ON DELETE SET NULL will null out audio_file_id, converting them // to phantoms that ResolvePhantomTracksAfterScan can re-link. if _, err := tx.ExecContext(l.ctx, ` UPDATE playlist_tracks SET phantom_title = COALESCE(phantom_title, ( SELECT tm.title FROM track_metadata tm WHERE tm.id = playlist_tracks.audio_file_id )), phantom_artist = COALESCE(phantom_artist, ( SELECT tm.artist_name FROM track_metadata tm WHERE tm.id = playlist_tracks.audio_file_id )), phantom_album = COALESCE(phantom_album, ( SELECT tm.album FROM track_metadata tm WHERE tm.id = playlist_tracks.audio_file_id )), phantom_duration_ms = COALESCE(phantom_duration_ms, ( SELECT af.length_milliseconds FROM audio_files af WHERE af.id = playlist_tracks.audio_file_id )), phantom_file_path = COALESCE(phantom_file_path, ( SELECT af.file_path FROM audio_files af WHERE af.id = playlist_tracks.audio_file_id )) WHERE audio_file_id IS NOT NULL `); err != nil { return fmt.Errorf( "could not preserve playlist track metadata: %w", err, ) } // Phase 2: the files. file_genres cascades with them. if err := txq.DeleteAllAudioFiles(l.ctx); err != nil { return fmt.Errorf( "could not clear audio files: %w", err, ) } if err := txq.DeleteAllAlbums(l.ctx); err != nil { return fmt.Errorf( "could not clear albums: %w", err, ) } // Phase 3: root tables. if err := txq.DeleteAllCoverArt(l.ctx); err != nil { return fmt.Errorf( "could not clear cover art: %w", err, ) } if err := txq.DeleteAllArtists(l.ctx); err != nil { return fmt.Errorf( "could not clear artists: %w", err, ) } if err := txq.DeleteAllGenres(l.ctx); err != nil { return fmt.Errorf( "could not clear genres: %w", err, ) } if err := tx.Commit(); err != nil { return fmt.Errorf( "could not commit library clear transaction: %w", err, ) } // Clear FTS5 search index AFTER the transaction. // ClearSearchIndex drops and recreates the contentless FTS5 // virtual table, which cannot run inside a transaction. if err := l.db.ClearSearchIndex(); err != nil { return fmt.Errorf( "could not clear search index: %w", err, ) } l.logger.Info("all library tables cleared") return nil } // clearCoverArtFiles removes all files from the covers directory. func (l *Library) clearCoverArtFiles() error { coverDir, err := coverart.CoversDir() if err != nil { return fmt.Errorf( "could not resolve covers directory: %w", err, ) } entries, err := os.ReadDir(coverDir) if err != nil { if os.IsNotExist(err) { return nil } return fmt.Errorf( "could not read covers directory: %w", err, ) } var removed int for _, entry := range entries { if entry.IsDir() { continue } path := filepath.Join(coverDir, entry.Name()) if err := os.Remove(path); err != nil { l.logger.Warn( "could not remove cover art file", "path", path, "err", err, ) continue } removed++ } l.logger.Info("cover art files removed", "count", removed) return nil }