From 81793975b4ba81208a78e0b3c5193b468678c24f Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Thu, 19 Feb 2026 10:17:09 -0500 Subject: [PATCH] improved library scan speeds, added library manager component -libraries tab now opens a library manager -choose library directory, manually soft scan and rescan -batched db queues -mp3 header-based duration extraction --- backend/app.go | 5 + backend/config/config.go | 48 ++ .../database/sql/queries/artist_credit.sql | 3 + .../sql/queries/artist_credit_artists.sql | 3 + backend/database/sql/queries/artists.sql | 3 + backend/database/sql/queries/audio_files.sql | 3 + backend/database/sql/queries/cover_art.sql | 3 + backend/database/sql/queries/playlists.sql | 3 + backend/database/sql/queries/recordings.sql | 3 + .../sql/queries/release_group_recordings.sql | 3 + .../database/sql/queries/release_groups.sql | 3 + .../database/sql/sqlcgen/artist_credit.sql.go | 9 + .../sql/sqlcgen/artist_credit_artists.sql.go | 9 + backend/database/sql/sqlcgen/artists.sql.go | 9 + .../database/sql/sqlcgen/audio_files.sql.go | 9 + backend/database/sql/sqlcgen/cover_art.sql.go | 9 + backend/database/sql/sqlcgen/playlists.sql.go | 9 + .../database/sql/sqlcgen/recordings.sql.go | 9 + .../sqlcgen/release_group_recordings.sql.go | 9 + .../sql/sqlcgen/release_groups.sql.go | 9 + backend/events/events.go | 1 + backend/library/library.go | 635 +++++++++++++----- backend/library/rescan.go | 185 +++++ backend/metadata/duration.go | 36 + backend/metadata/metadata.go | 46 ++ backend/metadata/mp3duration.go | 329 +++++++++ backend/metadata/mp3duration_test.go | 117 ++++ backend/queue/queue.go | 23 + frontend/index.ts | 4 + .../library-manager/library-manager.ts | 329 +++++++++ frontend/src/events.ts | 1 + frontend/wailsjs/go/config/Config.d.ts | 18 + frontend/wailsjs/go/config/Config.js | 31 + frontend/wailsjs/go/library/Library.d.ts | 4 + frontend/wailsjs/go/library/Library.js | 8 + frontend/wailsjs/go/models.ts | 601 +++++++++++++++++ 36 files changed, 2350 insertions(+), 179 deletions(-) create mode 100644 backend/library/rescan.go create mode 100644 backend/metadata/duration.go create mode 100644 backend/metadata/mp3duration.go create mode 100644 backend/metadata/mp3duration_test.go create mode 100644 frontend/src/components/library-manager/library-manager.ts create mode 100755 frontend/wailsjs/go/config/Config.d.ts create mode 100755 frontend/wailsjs/go/config/Config.js diff --git a/backend/app.go b/backend/app.go index 1f75ddf..4bec25a 100644 --- a/backend/app.go +++ b/backend/app.go @@ -100,6 +100,7 @@ func NewYellowJacketApp( yjApp.FEBindings = []any{ yjApp.FrontendUtil, + yjApp.appConfig, yjApp.library, yjApp.playlist, } @@ -141,6 +142,10 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) { yj.queue.SetPlayer(yj.player) yj.queue.RestoreState() + // Give the library a reference to the queue so FullRescan can + // clear the queue and stop playback before wiping data. + yj.library.SetQueue(yj.queue) + // Register playback finished handler to drive queue auto-advance. yj.player.SetPlaybackFinishedHandler(yj.queue.OnPlaybackFinished) diff --git a/backend/config/config.go b/backend/config/config.go index 3115d62..93cb8c8 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -11,7 +11,9 @@ import ( "path" "github.com/BurntSushi/toml" + "github.com/wailsapp/wails/v2/pkg/runtime" + "yellowjacket/backend/events" "yellowjacket/backend/library" "yellowjacket/backend/system" ) @@ -148,3 +150,49 @@ func (c *Config) applyDefaults() { func (c *Config) SetContext(ctx context.Context) { c.ctx = ctx } + +// GetLibraryDirectory returns the currently configured library directory path. +func (c *Config) GetLibraryDirectory() string { + if c.Library == nil { + return "" + } + + return string(c.Library.DirectoryPath) +} + +// SetLibraryDirectory validates and saves a new library directory, +// then emits the LibraryConfigChanged event so listeners (e.g. the +// Library scanner) can react. +func (c *Config) SetLibraryDirectory(dir string) error { + newLibConf, err := library.NewConfig(dir) + if err != nil { + return fmt.Errorf( + "invalid library directory: %w", err, + ) + } + + c.Library = newLibConf + + if err := c.Save(); err != nil { + return fmt.Errorf( + "could not save config after directory change: %w", err, + ) + } + + if c.ctx != nil { + runtime.EventsEmit( + c.ctx, + events.LibraryConfigChanged, + map[string]any{ + "DirectoryPath": dir, + }, + ) + } + + c.logger.Info( + "library directory updated", + "directory", dir, + ) + + return nil +} diff --git a/backend/database/sql/queries/artist_credit.sql b/backend/database/sql/queries/artist_credit.sql index 73e6219..b659668 100644 --- a/backend/database/sql/queries/artist_credit.sql +++ b/backend/database/sql/queries/artist_credit.sql @@ -23,3 +23,6 @@ WHERE id = ?; -- name: DeleteArtistCredit :exec DELETE FROM artist_credit WHERE id = ?; + +-- name: DeleteAllArtistCredits :exec +DELETE FROM artist_credit; diff --git a/backend/database/sql/queries/artist_credit_artists.sql b/backend/database/sql/queries/artist_credit_artists.sql index 532f42a..617157e 100644 --- a/backend/database/sql/queries/artist_credit_artists.sql +++ b/backend/database/sql/queries/artist_credit_artists.sql @@ -15,3 +15,6 @@ WHERE id =?; DELETE FROM artist_credit_artist WHERE id =?; +-- name: DeleteAllArtistCreditArtists :exec +DELETE FROM artist_credit_artist; + diff --git a/backend/database/sql/queries/artists.sql b/backend/database/sql/queries/artists.sql index 36b2933..23ea4e0 100644 --- a/backend/database/sql/queries/artists.sql +++ b/backend/database/sql/queries/artists.sql @@ -24,6 +24,9 @@ WHERE id = ?; DELETE FROM artists WHERE id = ?; +-- name: DeleteAllArtists :exec +DELETE FROM artists; + -- name: GetAllArtists :many SELECT * FROM artists ORDER BY name; diff --git a/backend/database/sql/queries/audio_files.sql b/backend/database/sql/queries/audio_files.sql index eea0aef..13ce89a 100644 --- a/backend/database/sql/queries/audio_files.sql +++ b/backend/database/sql/queries/audio_files.sql @@ -71,6 +71,9 @@ LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id WHERE af.file_path = ? LIMIT 1; +-- name: DeleteAllAudioFiles :exec +DELETE FROM audio_files; + -- name: GetAudioFilesByReleaseGroup :many SELECT af.file_path, diff --git a/backend/database/sql/queries/cover_art.sql b/backend/database/sql/queries/cover_art.sql index 4006232..1838d22 100644 --- a/backend/database/sql/queries/cover_art.sql +++ b/backend/database/sql/queries/cover_art.sql @@ -26,3 +26,6 @@ WHERE id = ?; -- name: DeleteCoverArt :exec DELETE FROM cover_art WHERE id = ?; + +-- name: DeleteAllCoverArt :exec +DELETE FROM cover_art; diff --git a/backend/database/sql/queries/playlists.sql b/backend/database/sql/queries/playlists.sql index 6c094d8..870b51c 100644 --- a/backend/database/sql/queries/playlists.sql +++ b/backend/database/sql/queries/playlists.sql @@ -82,6 +82,9 @@ LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id ORDER BY pt.playlist_id, pt.position; +-- name: DeleteAllPlaylistTracks :exec +DELETE FROM playlist_tracks; + -- name: GetNextPlaylistTrackPosition :one SELECT COALESCE(MAX(position), -1) + 1 AS next_position FROM playlist_tracks WHERE playlist_id = ?; diff --git a/backend/database/sql/queries/recordings.sql b/backend/database/sql/queries/recordings.sql index 4b21e67..31bab1a 100644 --- a/backend/database/sql/queries/recordings.sql +++ b/backend/database/sql/queries/recordings.sql @@ -28,6 +28,9 @@ WHERE id = ?; DELETE FROM recordings WHERE id = ?; +-- name: DeleteAllRecordings :exec +DELETE FROM recordings; + -- name: GetAllRecordings :many SELECT * FROM recordings ORDER BY name; diff --git a/backend/database/sql/queries/release_group_recordings.sql b/backend/database/sql/queries/release_group_recordings.sql index 500caf9..6198bce 100644 --- a/backend/database/sql/queries/release_group_recordings.sql +++ b/backend/database/sql/queries/release_group_recordings.sql @@ -23,3 +23,6 @@ WHERE id = ?; -- name: DeleteReleaseGroupRecordingByFK :exec DELETE FROM release_group_recordings WHERE release_group_id = ? AND recording_id = ?; + +-- name: DeleteAllReleaseGroupRecordings :exec +DELETE FROM release_group_recordings; diff --git a/backend/database/sql/queries/release_groups.sql b/backend/database/sql/queries/release_groups.sql index a0ace65..3689553 100644 --- a/backend/database/sql/queries/release_groups.sql +++ b/backend/database/sql/queries/release_groups.sql @@ -38,6 +38,9 @@ WHERE id = ?; DELETE FROM release_groups WHERE id = ?; +-- name: DeleteAllReleaseGroups :exec +DELETE FROM release_groups; + -- name: GetAllReleaseGroups :many SELECT * FROM release_groups ORDER BY name; diff --git a/backend/database/sql/sqlcgen/artist_credit.sql.go b/backend/database/sql/sqlcgen/artist_credit.sql.go index b19c3a8..5d17bc0 100644 --- a/backend/database/sql/sqlcgen/artist_credit.sql.go +++ b/backend/database/sql/sqlcgen/artist_credit.sql.go @@ -21,6 +21,15 @@ func (q *Queries) CreateArtistCredit(ctx context.Context, text string) (ArtistCr return i, err } +const deleteAllArtistCredits = `-- name: DeleteAllArtistCredits :exec +DELETE FROM artist_credit +` + +func (q *Queries) DeleteAllArtistCredits(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, deleteAllArtistCredits) + return err +} + const deleteArtistCredit = `-- name: DeleteArtistCredit :exec DELETE FROM artist_credit WHERE id = ? diff --git a/backend/database/sql/sqlcgen/artist_credit_artists.sql.go b/backend/database/sql/sqlcgen/artist_credit_artists.sql.go index 851418a..f762fbd 100644 --- a/backend/database/sql/sqlcgen/artist_credit_artists.sql.go +++ b/backend/database/sql/sqlcgen/artist_credit_artists.sql.go @@ -26,6 +26,15 @@ func (q *Queries) CreateArtistCreditArtist(ctx context.Context, arg CreateArtist return i, err } +const deleteAllArtistCreditArtists = `-- name: DeleteAllArtistCreditArtists :exec +DELETE FROM artist_credit_artist +` + +func (q *Queries) DeleteAllArtistCreditArtists(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, deleteAllArtistCreditArtists) + return err +} + const deleteArtistCreditArtist = `-- name: DeleteArtistCreditArtist :exec DELETE FROM artist_credit_artist WHERE id =? diff --git a/backend/database/sql/sqlcgen/artists.sql.go b/backend/database/sql/sqlcgen/artists.sql.go index a74e46f..08cf13d 100644 --- a/backend/database/sql/sqlcgen/artists.sql.go +++ b/backend/database/sql/sqlcgen/artists.sql.go @@ -21,6 +21,15 @@ func (q *Queries) CreateArtist(ctx context.Context, name string) (Artist, error) return i, err } +const deleteAllArtists = `-- name: DeleteAllArtists :exec +DELETE FROM artists +` + +func (q *Queries) DeleteAllArtists(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, deleteAllArtists) + return err +} + const deleteArtist = `-- name: DeleteArtist :exec DELETE FROM artists WHERE id = ? diff --git a/backend/database/sql/sqlcgen/audio_files.sql.go b/backend/database/sql/sqlcgen/audio_files.sql.go index 463b403..4a09103 100644 --- a/backend/database/sql/sqlcgen/audio_files.sql.go +++ b/backend/database/sql/sqlcgen/audio_files.sql.go @@ -51,6 +51,15 @@ func (q *Queries) CreateAudioFile(ctx context.Context, arg CreateAudioFileParams return i, err } +const deleteAllAudioFiles = `-- name: DeleteAllAudioFiles :exec +DELETE FROM audio_files +` + +func (q *Queries) DeleteAllAudioFiles(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, deleteAllAudioFiles) + return err +} + const deleteAudioFile = `-- name: DeleteAudioFile :exec DELETE FROM audio_files WHERE id = ? diff --git a/backend/database/sql/sqlcgen/cover_art.sql.go b/backend/database/sql/sqlcgen/cover_art.sql.go index 13277ac..3184ad9 100644 --- a/backend/database/sql/sqlcgen/cover_art.sql.go +++ b/backend/database/sql/sqlcgen/cover_art.sql.go @@ -32,6 +32,15 @@ func (q *Queries) CreateCoverArt(ctx context.Context, arg CreateCoverArtParams) return i, err } +const deleteAllCoverArt = `-- name: DeleteAllCoverArt :exec +DELETE FROM cover_art +` + +func (q *Queries) DeleteAllCoverArt(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, deleteAllCoverArt) + return err +} + const deleteCoverArt = `-- name: DeleteCoverArt :exec DELETE FROM cover_art WHERE id = ? diff --git a/backend/database/sql/sqlcgen/playlists.sql.go b/backend/database/sql/sqlcgen/playlists.sql.go index d1cc3db..83bcba5 100644 --- a/backend/database/sql/sqlcgen/playlists.sql.go +++ b/backend/database/sql/sqlcgen/playlists.sql.go @@ -58,6 +58,15 @@ func (q *Queries) CreatePlaylist(ctx context.Context, name string) (Playlist, er return i, err } +const deleteAllPlaylistTracks = `-- name: DeleteAllPlaylistTracks :exec +DELETE FROM playlist_tracks +` + +func (q *Queries) DeleteAllPlaylistTracks(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, deleteAllPlaylistTracks) + return err +} + const deletePlaylist = `-- name: DeletePlaylist :exec DELETE FROM playlists WHERE id = ? ` diff --git a/backend/database/sql/sqlcgen/recordings.sql.go b/backend/database/sql/sqlcgen/recordings.sql.go index cc41590..4519a8b 100644 --- a/backend/database/sql/sqlcgen/recordings.sql.go +++ b/backend/database/sql/sqlcgen/recordings.sql.go @@ -86,6 +86,15 @@ func (q *Queries) CreateRecordingFull(ctx context.Context, arg CreateRecordingFu return i, err } +const deleteAllRecordings = `-- name: DeleteAllRecordings :exec +DELETE FROM recordings +` + +func (q *Queries) DeleteAllRecordings(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, deleteAllRecordings) + return err +} + const deleteRecording = `-- name: DeleteRecording :exec DELETE FROM recordings WHERE id = ? diff --git a/backend/database/sql/sqlcgen/release_group_recordings.sql.go b/backend/database/sql/sqlcgen/release_group_recordings.sql.go index 5fee597..22b9b48 100644 --- a/backend/database/sql/sqlcgen/release_group_recordings.sql.go +++ b/backend/database/sql/sqlcgen/release_group_recordings.sql.go @@ -41,6 +41,15 @@ func (q *Queries) CreateReleaseGroupRecording(ctx context.Context, arg CreateRel return i, err } +const deleteAllReleaseGroupRecordings = `-- name: DeleteAllReleaseGroupRecordings :exec +DELETE FROM release_group_recordings +` + +func (q *Queries) DeleteAllReleaseGroupRecordings(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, deleteAllReleaseGroupRecordings) + return err +} + const deleteReleaseGroupRecording = `-- name: DeleteReleaseGroupRecording :exec DELETE FROM release_group_recordings WHERE id = ? diff --git a/backend/database/sql/sqlcgen/release_groups.sql.go b/backend/database/sql/sqlcgen/release_groups.sql.go index 6e8a236..2968323 100644 --- a/backend/database/sql/sqlcgen/release_groups.sql.go +++ b/backend/database/sql/sqlcgen/release_groups.sql.go @@ -68,6 +68,15 @@ func (q *Queries) CreateReleaseGroupFull(ctx context.Context, arg CreateReleaseG return i, err } +const deleteAllReleaseGroups = `-- name: DeleteAllReleaseGroups :exec +DELETE FROM release_groups +` + +func (q *Queries) DeleteAllReleaseGroups(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, deleteAllReleaseGroups) + return err +} + const deleteReleaseGroup = `-- name: DeleteReleaseGroup :exec DELETE FROM release_groups WHERE id = ? diff --git a/backend/events/events.go b/backend/events/events.go index aeece66..4bd3d0f 100644 --- a/backend/events/events.go +++ b/backend/events/events.go @@ -56,5 +56,6 @@ const ( // Library events. const ( + LibraryScanStarted = "LibraryScanStarted" LibraryScanComplete = "LibraryScanComplete" ) diff --git a/backend/library/library.go b/backend/library/library.go index 2acee18..15ff597 100644 --- a/backend/library/library.go +++ b/backend/library/library.go @@ -26,12 +26,55 @@ import ( var errLibraryDirNotConfigured = errors.New("library directory not configured") +// scanBatchSize controls how many files are committed in a single +// database transaction during a scan. Larger batches amortize +// SQLite's fsync cost but increase the blast radius of a failed commit. +const scanBatchSize = 50 + +// entityCache holds recently resolved database entities so that +// repeated upserts for the same artist/album/cover art within a scan +// can be served from memory instead of hitting the database. +// It is only accessed from the single DB-writer goroutine and +// therefore needs no synchronisation. +type entityCache struct { + artistCredits map[string]sqlcgen.ArtistCredit + artists map[string]sqlcgen.Artist + releaseGroups map[string]sqlcgen.ReleaseGroup + coverArt map[string]sqlcgen.CoverArt + // linkedCredits tracks artist-credit-artist links already created + // so we skip the duplicate INSERT. Key is "artistID:creditID". + linkedCredits map[string]struct{} +} + +func newEntityCache() *entityCache { + return &entityCache{ + artistCredits: make(map[string]sqlcgen.ArtistCredit), + artists: make(map[string]sqlcgen.Artist), + releaseGroups: make(map[string]sqlcgen.ReleaseGroup), + coverArt: make(map[string]sqlcgen.CoverArt), + linkedCredits: make(map[string]struct{}), + } +} + +// queueClearer is a narrow interface for clearing the playback queue. +type queueClearer interface { + Clear() +} + // Library manages scanning and querying the music collection. type Library struct { ctx context.Context logger *slog.Logger conf *Config db *database.DB + queue queueClearer +} + +// SetQueue provides the library with a reference to the queue so +// that destructive operations like FullRescan can clear the queue +// and stop playback before wiping data. +func (l *Library) SetQueue(q queueClearer) { + l.queue = q } // NewLibrary creates a new library with the given configuration. @@ -107,7 +150,11 @@ func (l *Library) registerEventHandlers() { // Scan syncs the library by adding new files and removing deleted ones. // Files that exist but have incomplete metadata (recording_id = 0) will be updated. func (l *Library) Scan() error { - l.logger.Info("beginning library scan", "workers", scanWorkerCount) + l.logger.Info( + "beginning library scan", "workers", scanWorkerCount, + ) + + runtime.EventsEmit(l.ctx, events.LibraryScanStarted) if len(l.conf.DirectoryPath) == 0 { return errLibraryDirNotConfigured @@ -224,7 +271,9 @@ func (l *Library) Scan() error { } }() - // DB writer goroutine: serialize all database writes to avoid SQLite contention + // DB writer goroutine: serialize all database writes to avoid SQLite + // contention. Results are committed in batches to amortize the cost + // of SQLite's fsync-per-commit. var dbWg sync.WaitGroup dbWg.Add(1) @@ -232,35 +281,35 @@ func (l *Library) Scan() error { go func() { defer dbWg.Done() - for result := range resultChan { - var saveErr error + cache := newEntityCache() - if result.needsUpdate { - saveErr = l.updateAudioFileMetadata(result) - if saveErr == nil { - updated.Add(1) - } - } else { - saveErr = l.saveAudioFile(result) - if saveErr == nil { - added.Add(1) - } + var batch []importResult + + flushBatch := func() { + if len(batch) == 0 { + return } - if saveErr != nil { - l.logger.Warn( - "failed to save audio file", - "path", - result.absolutePath, - "err", - saveErr, - ) - + if batchErr := l.commitBatch( + batch, cache, &added, &updated, + ); batchErr != nil { errMu.Lock() - scanErr = errors.Join(scanErr, saveErr) + scanErr = errors.Join(scanErr, batchErr) errMu.Unlock() } + + batch = batch[:0] } + + for result := range resultChan { + batch = append(batch, result) + if len(batch) >= scanBatchSize { + flushBatch() + } + } + + // Flush any remaining results. + flushBatch() }() // Worker pool: extract metadata concurrently, send results to DB writer @@ -368,6 +417,7 @@ type importResult struct { } // extractAudioMetadata reads and extracts metadata from an audio file. +// It opens the file once, extracting both tags and duration in a single pass. func (l *Library) extractAudioMetadata(work scanWork) (importResult, error) { result := importResult{ absolutePath: work.absolutePath, @@ -376,236 +426,241 @@ func (l *Library) extractAudioMetadata(work scanWork) (importResult, error) { needsUpdate: work.needsUpdate, } - // Get duration (skip if updating and we already have it) - if work.needsUpdate && work.existingLength > 0 { - result.lengthMillis = work.existingLength - } else { - trackLengthMillis, err := metadata.GetTrackLengthMillis(work.absolutePath) - if err != nil { - return result, fmt.Errorf( - "could not get track length for %s: %w", - work.absolutePath, - err, - ) - } + // Skip duration decode if we already have it from a previous import. + skipDuration := work.needsUpdate && work.existingLength > 0 - result.lengthMillis = trackLengthMillis - } - - // Extract tags - tags, err := metadata.ExtractTags(work.absolutePath) + tags, lengthMillis, err := metadata.ExtractAllMetadata( + work.absolutePath, skipDuration, + ) if err != nil { - l.logger.Warn("could not extract tags", "path", work.absolutePath, "err", err) - // Continue with empty tags - not a fatal error - tags = &metadata.TrackMetadata{} + return result, fmt.Errorf( + "could not extract metadata for %s: %w", + work.absolutePath, + err, + ) } result.tags = tags + if skipDuration { + result.lengthMillis = work.existingLength + } else { + result.lengthMillis = lengthMillis + } + return result, nil } +// commitBatch wraps a slice of import results in a single database +// transaction, creating all related records and audio file entries. +// Individual file failures are logged and accumulated but do not +// abort the entire batch. +func (l *Library) commitBatch( + batch []importResult, + cache *entityCache, + added, updated *atomic.Int64, +) error { + tx, err := l.db.BeginTx() + if err != nil { + return fmt.Errorf("could not begin transaction: %w", err) + } + + txq := l.db.Queries.WithTx(tx) + + var batchErr error + + for i := range batch { + result := &batch[i] + + var saveErr error + + if result.needsUpdate { + saveErr = l.updateAudioFileMetadata(txq, cache, *result) + if saveErr == nil { + updated.Add(1) + } + } else { + saveErr = l.saveAudioFile(txq, cache, *result) + if saveErr == nil { + added.Add(1) + } + } + + if saveErr != nil { + l.logger.Warn( + "failed to save audio file", + "path", result.absolutePath, + "err", saveErr, + ) + + batchErr = errors.Join(batchErr, saveErr) + } + } + + if commitErr := tx.Commit(); commitErr != nil { + return fmt.Errorf( + "could not commit batch of %d files: %w", + len(batch), commitErr, + ) + } + + return batchErr +} + // saveAudioFile writes audio file metadata to the database (new files). -func (l *Library) saveAudioFile(result importResult) error { +func (l *Library) saveAudioFile( + q *sqlcgen.Queries, + cache *entityCache, + result importResult, +) error { l.logger.Debug( "saving audio file to db", "absolute-path", result.absolutePath, "track-length-millis", result.lengthMillis, - "file-type", int64(slices.Index(metadata.SupportedFileExtensions, result.fileType)), + "file-type", int64( + slices.Index( + metadata.SupportedFileExtensions, + result.fileType, + ), + ), ) - // Process metadata and create related records - recordingID, err := l.processMetadata(result) + // Process metadata and create related records. + recordingID, err := l.processMetadata(q, cache, result) if err != nil { return fmt.Errorf("could not process metadata: %w", err) } - if _, err := l.db.Queries.CreateAudioFile( + if _, err := q.CreateAudioFile( l.ctx, sqlcgen.CreateAudioFileParams{ FilePath: result.absolutePath, LengthMilliseconds: result.lengthMillis, FileTypeID: int64( - slices.Index(metadata.SupportedFileExtensions, result.fileType), + slices.Index( + metadata.SupportedFileExtensions, + result.fileType, + ), ), RecordingID: recordingID, }); err != nil { - return fmt.Errorf("could not save audio file to db: %w", err) + return fmt.Errorf( + "could not save audio file to db: %w", err, + ) } - l.logger.Debug("added audio file to library", "path", result.absolutePath) + l.logger.Debug( + "added audio file to library", + "path", result.absolutePath, + ) return nil } // updateAudioFileMetadata updates an existing audio file with extracted metadata. -func (l *Library) updateAudioFileMetadata(result importResult) error { +func (l *Library) updateAudioFileMetadata( + q *sqlcgen.Queries, + cache *entityCache, + result importResult, +) error { l.logger.Debug( "updating audio file metadata", "absolute-path", result.absolutePath, "file-id", result.existingFileID, ) - // Process metadata and create related records - recordingID, err := l.processMetadata(result) + // Process metadata and create related records. + recordingID, err := l.processMetadata(q, cache, result) if err != nil { return fmt.Errorf("could not process metadata: %w", err) } - if err := l.db.Queries.UpdateAudioFileRecording( + if err := q.UpdateAudioFileRecording( l.ctx, sqlcgen.UpdateAudioFileRecordingParams{ RecordingID: recordingID, ID: result.existingFileID, }); err != nil { - return fmt.Errorf("could not update audio file recording: %w", err) + return fmt.Errorf( + "could not update audio file recording: %w", err, + ) } - l.logger.Debug("updated audio file metadata", "path", result.absolutePath) + l.logger.Debug( + "updated audio file metadata", + "path", result.absolutePath, + ) return nil } -// processMetadata creates all related database records for metadata and returns the recording ID. -func (l *Library) processMetadata(result importResult) (int64, error) { +// processMetadata creates all related database records for metadata +// and returns the recording ID. It uses the provided queries object +// (which may be transaction-scoped) and the entity cache to avoid +// redundant upserts for repeated artist/album/cover-art values. +func (l *Library) processMetadata( + q *sqlcgen.Queries, + cache *entityCache, + result importResult, +) (int64, error) { tags := result.tags if tags == nil { tags = &metadata.TrackMetadata{} } - // 1. Handle cover art (if present) - var coverArtID sql.NullInt64 + // 1. Handle cover art (if present). + coverArtID := l.processCoverArt(q, cache, tags) - if tags.Picture != nil { - coverPath, err := l.saveCoverArt(tags.Picture) - if err != nil { - l.logger.Warn("could not save cover art", "err", err) - } else if coverPath != "" { - // Use upsert to avoid duplicates - ca, err := l.db.Queries.UpsertCoverArt(l.ctx, sqlcgen.UpsertCoverArtParams{ - IsEmbedded: true, - FilePath: coverPath, - MimeType: tags.Picture.MIMEType, - }) - if err != nil { - l.logger.Warn("could not create cover art record", "err", err) - } else { - coverArtID = sql.NullInt64{Int64: ca.ID, Valid: true} - } - } - } - - // 2. Get or create artist credit for track artist + // 2. Get or create artist credit for track artist. artistName := tags.Artist if artistName == "" { artistName = "Unknown Artist" } - artistCredit, err := l.db.Queries.UpsertArtistCredit(l.ctx, artistName) + artistCredit, err := l.cachedUpsertArtistCredit( + q, cache, artistName, + ) if err != nil { - return 0, fmt.Errorf("could not upsert artist credit: %w", err) + return 0, fmt.Errorf( + "could not upsert artist credit: %w", err, + ) } - // Also create the artist record and link (best effort) - artist, err := l.db.Queries.UpsertArtist(l.ctx, artistName) - if err != nil { - l.logger.Warn("could not upsert artist", "err", err) - } else { - // Link artist to credit (ignore error if already linked) - _, _ = l.db.Queries.CreateArtistCreditArtist(l.ctx, sqlcgen.CreateArtistCreditArtistParams{ - ArtistID: artist.ID, - CreditID: artistCredit.ID, - }) - } + l.cachedLinkArtist(q, cache, artistName, artistCredit.ID) // 3. Get or create artist credit for album artist. - // Always assign an album artist credit so the cover grid displays an - // artist name. When the AlbumArtist tag is absent or identical to the - // track Artist, reuse the track artist credit instead of leaving it NULL. - var albumArtistCreditID sql.NullInt64 + albumArtistCreditID := l.resolveAlbumArtistCredit( + q, cache, tags, artistCredit.ID, + ) - if tags.AlbumArtist != "" && tags.AlbumArtist != tags.Artist { - albumArtistCredit, err := l.db.Queries.UpsertArtistCredit( - l.ctx, tags.AlbumArtist, - ) - if err != nil { - l.logger.Warn("could not upsert album artist credit", "err", err) - } else { - albumArtistCreditID = sql.NullInt64{ - Int64: albumArtistCredit.ID, Valid: true, - } + // 4. Get or create release group (album). + releaseGroupID := l.resolveReleaseGroup( + q, cache, tags, albumArtistCreditID, coverArtID, + ) - // Also create the artist record and link. - albumArtist, err := l.db.Queries.UpsertArtist( - l.ctx, tags.AlbumArtist, - ) - if err != nil { - l.logger.Warn("could not upsert album artist", "err", err) - } else { - _, _ = l.db.Queries.CreateArtistCreditArtist( - l.ctx, - sqlcgen.CreateArtistCreditArtistParams{ - ArtistID: albumArtist.ID, - CreditID: albumArtistCredit.ID, - }, - ) - } - } - } else { - // AlbumArtist is empty or matches the track artist — reuse the - // track artist credit so the release group always has an artist. - albumArtistCreditID = sql.NullInt64{ - Int64: artistCredit.ID, Valid: true, - } - } - - // 4. Get or create release group (album) - var releaseGroupID sql.NullInt64 - - if tags.Album != "" { - rg, err := l.db.Queries.UpsertReleaseGroup(l.ctx, sqlcgen.UpsertReleaseGroupParams{ - Name: tags.Album, - AlbumArtistCreditID: albumArtistCreditID, - Year: toNullInt64(tags.Year), - }) - if err != nil { - l.logger.Warn("could not upsert release group", "err", err) - } else { - releaseGroupID = sql.NullInt64{Int64: rg.ID, Valid: true} - - // Update cover art if this album doesn't have one yet - if coverArtID.Valid && !rg.CoverArtID.Valid { - err := l.db.Queries.UpdateReleaseGroupCoverArt( - l.ctx, - sqlcgen.UpdateReleaseGroupCoverArtParams{ - CoverArtID: coverArtID, - ID: rg.ID, - }, - ) - if err != nil { - l.logger.Warn("could not update release group cover art", "err", err) - } - } - } - } - - // 5. Create recording - recording, err := l.db.Queries.CreateRecordingFull(l.ctx, sqlcgen.CreateRecordingFullParams{ - Name: l.getRecordingName(tags, result.absolutePath), - ArtistCreditID: artistCredit.ID, - TrackNumber: toNullInt64(tags.TrackNumber), - DiscNumber: toNullInt64(tags.DiscNumber), - Year: toNullInt64(tags.Year), - Genre: toNullString(tags.Genre), - Composer: toNullString(tags.Composer), - Lyrics: toNullString(tags.Lyrics), - Comment: toNullString(tags.Comment), - }) + // 5. Create recording. + recording, err := q.CreateRecordingFull( + l.ctx, sqlcgen.CreateRecordingFullParams{ + Name: l.getRecordingName( + tags, result.absolutePath, + ), + ArtistCreditID: artistCredit.ID, + TrackNumber: toNullInt64(tags.TrackNumber), + DiscNumber: toNullInt64(tags.DiscNumber), + Year: toNullInt64(tags.Year), + Genre: toNullString(tags.Genre), + Composer: toNullString(tags.Composer), + Lyrics: toNullString(tags.Lyrics), + Comment: toNullString(tags.Comment), + }, + ) if err != nil { - return 0, fmt.Errorf("could not create recording: %w", err) + return 0, fmt.Errorf( + "could not create recording: %w", err, + ) } - // 6. Link recording to release group + // 6. Link recording to release group. if releaseGroupID.Valid { - _, err = l.db.Queries.CreateReleaseGroupRecording( + _, err = q.CreateReleaseGroupRecording( l.ctx, sqlcgen.CreateReleaseGroupRecordingParams{ ReleaseGroupID: releaseGroupID.Int64, @@ -615,13 +670,235 @@ func (l *Library) processMetadata(result importResult) (int64, error) { }, ) if err != nil { - l.logger.Warn("could not link recording to release group", "err", err) + l.logger.Warn( + "could not link recording to release group", + "err", err, + ) } } return recording.ID, nil } +// processCoverArt saves cover art to disk and upserts the DB record, +// using the cache to skip work for previously seen images. +func (l *Library) processCoverArt( + q *sqlcgen.Queries, + cache *entityCache, + tags *metadata.TrackMetadata, +) sql.NullInt64 { + if tags.Picture == nil { + return sql.NullInt64{} + } + + coverPath, err := l.saveCoverArt(tags.Picture) + if err != nil { + l.logger.Warn("could not save cover art", "err", err) + + return sql.NullInt64{} + } + + if coverPath == "" { + return sql.NullInt64{} + } + + // Check cache first. + if cached, ok := cache.coverArt[coverPath]; ok { + return sql.NullInt64{Int64: cached.ID, Valid: true} + } + + ca, err := q.UpsertCoverArt(l.ctx, sqlcgen.UpsertCoverArtParams{ + IsEmbedded: true, + FilePath: coverPath, + MimeType: tags.Picture.MIMEType, + }) + if err != nil { + l.logger.Warn( + "could not create cover art record", "err", err, + ) + + return sql.NullInt64{} + } + + cache.coverArt[coverPath] = ca + + return sql.NullInt64{Int64: ca.ID, Valid: true} +} + +// cachedUpsertArtistCredit returns the artist credit for the given +// name, using the cache when possible. +func (l *Library) cachedUpsertArtistCredit( + q *sqlcgen.Queries, + cache *entityCache, + name string, +) (sqlcgen.ArtistCredit, error) { + if cached, ok := cache.artistCredits[name]; ok { + return cached, nil + } + + ac, err := q.UpsertArtistCredit(l.ctx, name) + if err != nil { + return sqlcgen.ArtistCredit{}, err + } + + cache.artistCredits[name] = ac + + return ac, nil +} + +// cachedLinkArtist upserts the artist record and creates the +// artist-credit-artist link, skipping work already done. +func (l *Library) cachedLinkArtist( + q *sqlcgen.Queries, + cache *entityCache, + name string, + creditID int64, +) { + artist, ok := cache.artists[name] + if !ok { + var err error + + artist, err = q.UpsertArtist(l.ctx, name) + if err != nil { + l.logger.Warn( + "could not upsert artist", "err", err, + ) + + return + } + + cache.artists[name] = artist + } + + linkKey := fmt.Sprintf("%d:%d", artist.ID, creditID) + if _, done := cache.linkedCredits[linkKey]; done { + return + } + + _, _ = q.CreateArtistCreditArtist( + l.ctx, + sqlcgen.CreateArtistCreditArtistParams{ + ArtistID: artist.ID, + CreditID: creditID, + }, + ) + + cache.linkedCredits[linkKey] = struct{}{} +} + +// resolveAlbumArtistCredit returns the album artist credit ID. +// When the AlbumArtist tag is absent or matches the track artist, +// the track artist credit is reused. +func (l *Library) resolveAlbumArtistCredit( + q *sqlcgen.Queries, + cache *entityCache, + tags *metadata.TrackMetadata, + trackArtistCreditID int64, +) sql.NullInt64 { + if tags.AlbumArtist == "" || tags.AlbumArtist == tags.Artist { + return sql.NullInt64{ + Int64: trackArtistCreditID, Valid: true, + } + } + + albumArtistCredit, err := l.cachedUpsertArtistCredit( + q, cache, tags.AlbumArtist, + ) + if err != nil { + l.logger.Warn( + "could not upsert album artist credit", "err", err, + ) + + return sql.NullInt64{} + } + + l.cachedLinkArtist( + q, cache, tags.AlbumArtist, albumArtistCredit.ID, + ) + + return sql.NullInt64{ + Int64: albumArtistCredit.ID, Valid: true, + } +} + +// resolveReleaseGroup returns the release group ID for the album, +// using the cache when possible. +func (l *Library) resolveReleaseGroup( + q *sqlcgen.Queries, + cache *entityCache, + tags *metadata.TrackMetadata, + albumArtistCreditID sql.NullInt64, + coverArtID sql.NullInt64, +) sql.NullInt64 { + if tags.Album == "" { + return sql.NullInt64{} + } + + // Check cache first. + if cached, ok := cache.releaseGroups[tags.Album]; ok { + // If the cached release group lacks cover art and we now + // have it, update it. + if coverArtID.Valid && !cached.CoverArtID.Valid { + err := q.UpdateReleaseGroupCoverArt( + l.ctx, + sqlcgen.UpdateReleaseGroupCoverArtParams{ + CoverArtID: coverArtID, + ID: cached.ID, + }, + ) + if err != nil { + l.logger.Warn( + "could not update release group cover art", + "err", err, + ) + } else { + cached.CoverArtID = coverArtID + cache.releaseGroups[tags.Album] = cached + } + } + + return sql.NullInt64{Int64: cached.ID, Valid: true} + } + + rg, err := q.UpsertReleaseGroup( + l.ctx, sqlcgen.UpsertReleaseGroupParams{ + Name: tags.Album, + AlbumArtistCreditID: albumArtistCreditID, + Year: toNullInt64(tags.Year), + }, + ) + if err != nil { + l.logger.Warn( + "could not upsert release group", "err", err, + ) + + return sql.NullInt64{} + } + + // Update cover art if this album doesn't have one yet. + if coverArtID.Valid && !rg.CoverArtID.Valid { + err := q.UpdateReleaseGroupCoverArt( + l.ctx, + sqlcgen.UpdateReleaseGroupCoverArtParams{ + CoverArtID: coverArtID, + ID: rg.ID, + }, + ) + if err != nil { + l.logger.Warn( + "could not update release group cover art", + "err", err, + ) + } else { + rg.CoverArtID = coverArtID + } + } + + cache.releaseGroups[tags.Album] = rg + + return sql.NullInt64{Int64: rg.ID, Valid: true} +} + // getRecordingName returns the track title, or falls back to the filename. func (l *Library) getRecordingName(tags *metadata.TrackMetadata, filePath string) string { if tags.Title != "" { diff --git a/backend/library/rescan.go b/backend/library/rescan.go new file mode 100644 index 0000000..3111492 --- /dev/null +++ b/backend/library/rescan.go @@ -0,0 +1,185 @@ +package library + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/wailsapp/wails/v2/pkg/runtime" + + "yellowjacket/backend/events" + "yellowjacket/backend/system" +) + +// FullRescan clears the queue and player, wipes all library data +// (database records and cover art files), and performs a fresh +// scan from scratch. +func (l *Library) FullRescan() error { + l.logger.Info("beginning full library rescan") + + runtime.EventsEmit(l.ctx, events.LibraryScanStarted) + + // Stop playback and clear the queue before wiping data so + // the player is not referencing now-deleted tracks. + if l.queue != nil { + l.queue.Clear() + } + + if err := l.clearLibraryData(); err != nil { + return fmt.Errorf("could not clear library data: %w", err) + } + + return l.Scan() +} + +// clearLibraryData removes all library-related records from the +// database and deletes all cover art files from disk. The deletes +// are executed in FK-safe order within a single transaction. +func (l *Library) clearLibraryData() error { + l.logger.Info("clearing all library data") + + if err := l.clearLibraryTables(); err != nil { + return err + } + + if err := l.clearCoverArtFiles(); err != nil { + return err + } + + l.logger.Info("library data cleared successfully") + + return 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) + } + + if err := txq.DeleteAllPlaylistTracks(l.ctx); err != nil { + return fmt.Errorf( + "could not clear playlist tracks: %w", err, + ) + } + + if err := txq.DeleteAllReleaseGroupRecordings(l.ctx); err != nil { + return fmt.Errorf( + "could not clear release group recordings: %w", err, + ) + } + + if err := txq.DeleteAllArtistCreditArtists(l.ctx); err != nil { + return fmt.Errorf( + "could not clear artist credit artists: %w", err, + ) + } + + // Phase 2: mid-level tables. + if err := txq.DeleteAllAudioFiles(l.ctx); err != nil { + return fmt.Errorf( + "could not clear audio files: %w", err, + ) + } + + if err := txq.DeleteAllReleaseGroups(l.ctx); err != nil { + return fmt.Errorf( + "could not clear release groups: %w", err, + ) + } + + if err := txq.DeleteAllRecordings(l.ctx); err != nil { + return fmt.Errorf( + "could not clear recordings: %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.DeleteAllArtistCredits(l.ctx); err != nil { + return fmt.Errorf( + "could not clear artist credits: %w", err, + ) + } + + if err := txq.DeleteAllArtists(l.ctx); err != nil { + return fmt.Errorf( + "could not clear artists: %w", err, + ) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf( + "could not commit library clear transaction: %w", err, + ) + } + + l.logger.Info("all library tables cleared") + + return nil +} + +// clearCoverArtFiles removes all files from the covers directory. +func (l *Library) clearCoverArtFiles() error { + dataDir, err := system.GetUserDataDirPath() + if err != nil { + return fmt.Errorf( + "could not get user data directory: %w", err, + ) + } + + coverDir := filepath.Join(dataDir, "covers") + + 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 +} diff --git a/backend/metadata/duration.go b/backend/metadata/duration.go new file mode 100644 index 0000000..147d603 --- /dev/null +++ b/backend/metadata/duration.go @@ -0,0 +1,36 @@ +package metadata + +import ( + "fmt" + "os" + "path/filepath" +) + +// getTrackDuration returns the duration of an audio file in +// milliseconds. For MP3 files it uses a fast header-only parser +// (Xing/VBRI/CBR); for other formats it falls back to a full +// decode via beep which is already O(1) for FLAC, OGG, and WAV. +// +// The file position is undefined after this call. +func getTrackDuration(f *os.File) (int64, error) { + ext := filepath.Ext(f.Name()) + + if ext == ".mp3" { + return getMP3Duration(f) + } + + // FLAC, OGG, and WAV: beep's Decode() + Len() is already + // cheap (reads headers/metadata only, no full audio decode). + streamer, format, err := DecodeFile(f) + if err != nil { + return 0, fmt.Errorf("error decoding file: %w", err) + } + + lengthMillis := int64( + float64(streamer.Len()*1000) / + float64(format.SampleRate), + ) + _ = streamer.Close() + + return lengthMillis, nil +} diff --git a/backend/metadata/metadata.go b/backend/metadata/metadata.go index 35e7c55..568558b 100644 --- a/backend/metadata/metadata.go +++ b/backend/metadata/metadata.go @@ -2,6 +2,7 @@ package metadata import ( "fmt" + "io" "os" ) @@ -50,3 +51,48 @@ func GetTrackLengthMillis(path string) (int64, error) { return lengthMillis, nil } + +// ExtractAllMetadata opens the file once and extracts both tags and duration. +// This avoids the overhead of opening the file twice when both are needed. +// If skipDuration is true, only tags are extracted and lengthMillis is 0. +func ExtractAllMetadata( + path string, + skipDuration bool, +) (*TrackMetadata, int64, error) { + f, err := os.Open(path) + if err != nil { + return nil, 0, fmt.Errorf( + "could not open file: %w", err, + ) + } + + defer func() { _ = f.Close() }() + + // Extract tags first (reads only headers, fast). + tags, err := ExtractTagsFromReader(f) + if err != nil { + return nil, 0, fmt.Errorf( + "could not extract tags from %s: %w", path, err, + ) + } + + if skipDuration { + return tags, 0, nil + } + + // Seek back to the beginning for duration extraction. + if _, err := f.Seek(0, io.SeekStart); err != nil { + return tags, 0, fmt.Errorf( + "could not seek file for duration: %w", err, + ) + } + + lengthMillis, err := getTrackDuration(f) + if err != nil { + return tags, 0, fmt.Errorf( + "error getting duration for %s: %w", path, err, + ) + } + + return tags, lengthMillis, nil +} diff --git a/backend/metadata/mp3duration.go b/backend/metadata/mp3duration.go new file mode 100644 index 0000000..541b879 --- /dev/null +++ b/backend/metadata/mp3duration.go @@ -0,0 +1,329 @@ +package metadata + +import ( + "encoding/binary" + "errors" + "fmt" + "io" + "os" +) + +// errNoSyncWord is returned when no valid MP3 frame sync word +// is found within the search window. +var errNoSyncWord = errors.New("could not find MP3 sync word") + +// maxSyncSearchBytes limits how far we scan for the first sync word +// after skipping any ID3v2 tag. +const maxSyncSearchBytes = 64 * 1024 + +// MPEG version constants. +const ( + mpegVersion1 = 3 // 0b11 + mpegVersion2 = 2 // 0b10 + mpegVersion2_5 = 0 // 0b00 (unofficial extension) +) + +// bitrateTable maps [versionIndex][bitrateIndex] to kbps. +// versionIndex 0 = MPEG1, 1 = MPEG2/2.5. +// bitrateIndex 0 and 15 are invalid. +// +//nolint:mnd // lookup table values are from the MPEG spec. +var bitrateTable = [2][16]int{ + // MPEG1 Layer 3 + {0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0}, + // MPEG2/2.5 Layer 3 + {0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0}, +} + +// sampleRateTable maps [versionIndex][sampleRateIndex] to Hz. +// versionIndex: 0 = MPEG1, 1 = MPEG2, 2 = MPEG2.5. +// +//nolint:mnd // lookup table values are from the MPEG spec. +var sampleRateTable = [3][4]int{ + {44100, 48000, 32000, 0}, // MPEG1 + {22050, 24000, 16000, 0}, // MPEG2 + {11025, 12000, 8000, 0}, // MPEG2.5 +} + +// samplesPerFrame returns the number of PCM samples per MP3 frame +// for the given MPEG version (Layer 3 only). +// +//nolint:mnd // constants from the MPEG spec. +func samplesPerFrame(version int) int { + if version == mpegVersion1 { + return 1152 + } + + return 576 // MPEG2 / MPEG2.5 +} + +// getMP3Duration computes the duration of an MP3 file in +// milliseconds by reading only the first frame's header and any +// Xing/VBRI VBR header it contains. For CBR files (no VBR header) +// it falls back to fileSize / bitrate. +// +// The file position is undefined after this call. +func getMP3Duration(f *os.File) (int64, error) { + // 1. Skip a leading ID3v2 tag if present. + audioStart, err := skipID3v2(f) + if err != nil { + return 0, fmt.Errorf("skipping ID3v2: %w", err) + } + + // 2. Find and parse the first MP3 frame header. + hdr, frameOffset, err := findFrameHeader(f, audioStart) + if err != nil { + return 0, err + } + + // 3. Attempt to read a VBR header (Xing/Info or VBRI) from + // inside the first frame. + vbrFrames, found, err := readVBRHeader(f, hdr, frameOffset) + if err != nil { + return 0, err + } + + if found && vbrFrames > 0 { + spf := samplesPerFrame(hdr.version) + durationMS := int64(vbrFrames) * + int64(spf) * 1000 / int64(hdr.sampleRate) + + return durationMS, nil + } + + // 4. CBR fallback: duration = audioBytes * 8 / bitrate. + fi, err := f.Stat() + if err != nil { + return 0, fmt.Errorf("stat file for CBR duration: %w", err) + } + + audioBytes := fi.Size() - audioStart + durationMS := audioBytes * 8 * 1000 / + (int64(hdr.bitrateKbps) * 1000) + + return durationMS, nil +} + +// mpegFrameHeader holds the parsed fields of a 4-byte MPEG audio +// frame header. +type mpegFrameHeader struct { + version int // mpegVersion1, mpegVersion2, mpegVersion2_5 + bitrateKbps int + sampleRate int + channelMode int // 0-3; 3 = mono + padding int // 0 or 1 +} + +// skipID3v2 checks for an ID3v2 tag at the start of f and returns +// the byte offset where audio data begins. +// +//nolint:mnd // byte offsets from the ID3v2 spec. +func skipID3v2(f *os.File) (int64, error) { + var buf [10]byte + + if _, err := f.ReadAt(buf[:], 0); err != nil { + return 0, fmt.Errorf("reading ID3v2 header: %w", err) + } + + if string(buf[:3]) != "ID3" { + return 0, nil // no ID3v2 tag + } + + // Syncsafe integer: 4 bytes, each using 7 bits. + size := int64(buf[6])<<21 | + int64(buf[7])<<14 | + int64(buf[8])<<7 | + int64(buf[9]) + + return 10 + size, nil +} + +// findFrameHeader scans from startOffset for the first valid MP3 +// sync word and returns the parsed header plus the file offset +// where the frame begins. +// +//nolint:mnd,cyclop // bit manipulation from the MPEG spec. +func findFrameHeader( + f *os.File, + startOffset int64, +) (mpegFrameHeader, int64, error) { + if _, err := f.Seek(startOffset, io.SeekStart); err != nil { + return mpegFrameHeader{}, 0, fmt.Errorf( + "seeking to audio start: %w", err, + ) + } + + // Read a chunk large enough to contain the first frame. + buf := make([]byte, maxSyncSearchBytes) + + n, err := io.ReadAtLeast(f, buf, 4) + if err != nil { + return mpegFrameHeader{}, 0, fmt.Errorf( + "reading audio data: %w", err, + ) + } + + buf = buf[:n] + + for i := 0; i <= len(buf)-4; i++ { + // Sync word: 11 set bits (0xFF followed by 0xE0 mask). + if buf[i] != 0xFF || buf[i+1]&0xE0 != 0xE0 { + continue + } + + hdr, ok := parseFrameHeader(buf[i : i+4]) + if !ok { + continue + } + + return hdr, startOffset + int64(i), nil + } + + return mpegFrameHeader{}, 0, errNoSyncWord +} + +// parseFrameHeader decodes a 4-byte MPEG audio frame header. +// Returns false if the header contains invalid field combinations. +// +//nolint:mnd,cyclop // bit manipulation from the MPEG spec. +func parseFrameHeader(b []byte) (mpegFrameHeader, bool) { + version := int((b[1] >> 3) & 0x03) + layer := int((b[1] >> 1) & 0x03) + + // We only handle Layer 3. + if layer != 1 { // Layer encoding: 1 = Layer 3 + return mpegFrameHeader{}, false + } + + // Determine version index for the bitrate table. + var bitrateIdx int + + switch version { + case mpegVersion1: + bitrateIdx = 0 + case mpegVersion2, mpegVersion2_5: + bitrateIdx = 1 + default: + return mpegFrameHeader{}, false // reserved + } + + brIndex := int((b[2] >> 4) & 0x0F) + bitrate := bitrateTable[bitrateIdx][brIndex] + + if bitrate == 0 { + return mpegFrameHeader{}, false + } + + // Sample rate. + var srVersionIdx int + + switch version { + case mpegVersion1: + srVersionIdx = 0 + case mpegVersion2: + srVersionIdx = 1 + case mpegVersion2_5: + srVersionIdx = 2 + } + + srIndex := int((b[2] >> 2) & 0x03) + sampleRate := sampleRateTable[srVersionIdx][srIndex] + + if sampleRate == 0 { + return mpegFrameHeader{}, false + } + + padding := int((b[2] >> 1) & 0x01) + channelMode := int((b[3] >> 6) & 0x03) + + return mpegFrameHeader{ + version: version, + bitrateKbps: bitrate, + sampleRate: sampleRate, + channelMode: channelMode, + padding: padding, + }, true +} + +// readVBRHeader tries to read a Xing/Info or VBRI header from the +// first frame at frameOffset. Returns the total frame count and +// whether a VBR header was found. +// +//nolint:mnd // byte offsets from Xing/VBRI specs. +func readVBRHeader( + f *os.File, + hdr mpegFrameHeader, + frameOffset int64, +) (uint32, bool, error) { + // Xing/Info header offset depends on version and channel mode. + var sideInfoSize int + + switch { + case hdr.version == mpegVersion1 && hdr.channelMode != 3: + sideInfoSize = 32 + case hdr.version == mpegVersion1 && hdr.channelMode == 3: + sideInfoSize = 17 + case hdr.channelMode != 3: + sideInfoSize = 17 + default: + sideInfoSize = 9 + } + + // The Xing header sits right after the 4-byte frame header + + // side information. + xingOffset := frameOffset + 4 + int64(sideInfoSize) + + // Read enough bytes for Xing header (magic + flags + frames). + var xingBuf [12]byte + + if _, err := f.ReadAt(xingBuf[:], xingOffset); err != nil { + if errors.Is(err, io.EOF) { + return 0, false, nil + } + + return 0, false, fmt.Errorf( + "reading Xing header: %w", err, + ) + } + + magic := string(xingBuf[:4]) + if magic == "Xing" || magic == "Info" { + flags := binary.BigEndian.Uint32(xingBuf[4:8]) + + // Bit 0 of flags indicates the frames field is present. + if flags&0x01 != 0 { + frames := binary.BigEndian.Uint32(xingBuf[8:12]) + + return frames, true, nil + } + + // Xing header present but no frame count — fall through + // to CBR fallback. + return 0, true, nil + } + + // VBRI header is always at a fixed offset of 36 bytes from + // the frame start (regardless of version/channel mode). + vbriOffset := frameOffset + 36 + + var vbriBuf [26]byte + + if _, err := f.ReadAt(vbriBuf[:], vbriOffset); err != nil { + if errors.Is(err, io.EOF) { + return 0, false, nil + } + + return 0, false, fmt.Errorf( + "reading VBRI header: %w", err, + ) + } + + if string(vbriBuf[:4]) == "VBRI" { + // Total frames at offset 14 from VBRI magic. + frames := binary.BigEndian.Uint32(vbriBuf[14:18]) + + return frames, true, nil + } + + return 0, false, nil +} diff --git a/backend/metadata/mp3duration_test.go b/backend/metadata/mp3duration_test.go new file mode 100644 index 0000000..e3a9dc0 --- /dev/null +++ b/backend/metadata/mp3duration_test.go @@ -0,0 +1,117 @@ +package metadata + +import ( + "os" + "path/filepath" + "testing" +) + +// testMP3Files returns the paths to all .mp3 files in the test_data +// directory. It skips the test if none are found. +func testMP3Files(t *testing.T) []string { + t.Helper() + + root := filepath.Join("..", "..", "test_data") + + var files []string + + err := filepath.Walk(root, func( + path string, info os.FileInfo, err error, + ) error { + if err != nil { + return err + } + + if !info.IsDir() && filepath.Ext(path) == ".mp3" { + files = append(files, path) + } + + return nil + }) + if err != nil { + t.Fatalf("walking test_data: %v", err) + } + + if len(files) == 0 { + t.Skip("no .mp3 test fixtures found in test_data/") + } + + return files +} + +// TestGetMP3Duration_MatchesBeepDecode verifies that the fast +// header-only parser produces a duration within 1 second of the +// full decode via beep, for every test MP3 file. +func TestGetMP3Duration_MatchesBeepDecode(t *testing.T) { + for _, path := range testMP3Files(t) { + t.Run(filepath.Base(path), func(t *testing.T) { + // Reference value: full beep decode. + refMS, err := GetTrackLengthMillis(path) + if err != nil { + t.Fatalf( + "beep decode failed: %v", err, + ) + } + + // Fast path. + f, err := os.Open(path) + if err != nil { + t.Fatalf("open: %v", err) + } + + defer func() { _ = f.Close() }() + + fastMS, err := getMP3Duration(f) + if err != nil { + t.Fatalf( + "getMP3Duration failed: %v", err, + ) + } + + diffMS := refMS - fastMS + if diffMS < 0 { + diffMS = -diffMS + } + + // Allow up to 1 second of difference to account + // for rounding and the slight inaccuracy of the + // CBR fallback for VBR-without-Xing files. + const toleranceMS = 1000 + + t.Logf( + "beep=%dms fast=%dms diff=%dms", + refMS, fastMS, diffMS, + ) + + if diffMS > toleranceMS { + t.Errorf( + "duration mismatch: beep=%dms fast=%dms "+ + "(diff %dms exceeds %dms tolerance)", + refMS, fastMS, diffMS, toleranceMS, + ) + } + }) + } +} + +// TestGetMP3Duration_BasicParsing exercises the parser on a single +// file and verifies a positive duration is returned. +func TestGetMP3Duration_BasicParsing(t *testing.T) { + files := testMP3Files(t) + + f, err := os.Open(files[0]) + if err != nil { + t.Fatalf("open: %v", err) + } + + defer func() { _ = f.Close() }() + + ms, err := getMP3Duration(f) + if err != nil { + t.Fatalf("getMP3Duration: %v", err) + } + + if ms <= 0 { + t.Errorf("expected positive duration, got %d", ms) + } +} diff --git a/backend/queue/queue.go b/backend/queue/queue.go index 3323d7f..d813be2 100644 --- a/backend/queue/queue.go +++ b/backend/queue/queue.go @@ -1185,6 +1185,29 @@ func (q *Queue) GetState() State { } } +// Clear removes all tracks from the queue, stops playback, and +// resets the queue state. It persists the cleared state and +// notifies the frontend. +func (q *Queue) Clear() { + q.mu.Lock() + defer q.mu.Unlock() + + q.logger.Info("Clearing queue") + + q.tracks = nil + q.currentIndex = -1 + q.shuffleOrder = nil + q.sourcePlaylistID = 0 + + if q.player != nil { + q.player.UnloadTrack() + } + + q.persistTracks() + q.persistState() + q.emitQueueChanged() +} + // EmitCurrentState emits the current queue state to the frontend. // This is called after the frontend DOM is ready. func (q *Queue) EmitCurrentState() { diff --git a/frontend/index.ts b/frontend/index.ts index 23b4e78..440e04a 100644 --- a/frontend/index.ts +++ b/frontend/index.ts @@ -5,6 +5,7 @@ import '@components/now-playing/now-playing.ts'; import '@components/sidebar/app-sidebar.ts'; import '@components/queue-panel/queue-panel.ts'; import '@components/playlist-view/playlist-view.ts'; +import '@components/library-manager/library-manager.ts'; import '@awesome.me/webawesome/dist/styles/themes/default.css'; import '@awesome.me/webawesome/dist/components/icon/icon.js'; import { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js'; @@ -36,6 +37,9 @@ document.addEventListener('navigate', (e: Event) => { case 'playlists': mainContent.innerHTML = ''; break; + case 'libraries': + mainContent.innerHTML = ''; + break; default: mainContent.innerHTML = `

Coming soon: ${view}

diff --git a/frontend/src/components/library-manager/library-manager.ts b/frontend/src/components/library-manager/library-manager.ts new file mode 100644 index 0000000..5430fa6 --- /dev/null +++ b/frontend/src/components/library-manager/library-manager.ts @@ -0,0 +1,329 @@ +import { LitElement, html, css } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; +import { EventsOn, EventsOff } from '@runtime/runtime'; +import { Scan, FullRescan } from '@go/library/Library'; +import { + GetLibraryDirectory, + SetLibraryDirectory, +} from '@go/config/Config'; +import { DirectoryPicker } from '@go/frontendutil/FrontendUtil'; +import { Events } from '../../events'; + +@customElement('library-manager') +export class LibraryManager extends LitElement { + @state() private libraryDirectory = ''; + @state() private selectedDirectory = ''; + @state() private scanning = false; + @state() private statusMessage = ''; + + static override styles = css` + :host { + display: block; + padding: 1.5em; + color: #e9ecef; + font-family: system-ui, -apple-system, sans-serif; + overflow-y: auto; + } + + h2 { + margin: 0 0 1em 0; + font-size: 1.4em; + font-weight: 600; + color: #f8f9fa; + } + + .section { + margin-bottom: 2em; + padding: 1.25em; + background: #2b3035; + border-radius: 8px; + } + + .section-title { + margin: 0 0 0.75em 0; + font-size: 1em; + font-weight: 600; + color: #dee2e6; + } + + .section-description { + margin: 0 0 1em 0; + font-size: 0.85em; + color: #868e96; + line-height: 1.4; + } + + .directory-row { + display: flex; + align-items: center; + gap: 0.75em; + margin-bottom: 1em; + } + + .directory-path { + flex: 1; + padding: 0.5em 0.75em; + background: #1a1d20; + border: 1px solid #495057; + border-radius: 4px; + color: #adb5bd; + font-size: 0.85em; + font-family: monospace; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-height: 1.2em; + } + + .directory-path.has-value { + color: #e9ecef; + } + + button { + padding: 0.5em 1.25em; + border: none; + border-radius: 4px; + font-size: 0.85em; + font-weight: 500; + cursor: pointer; + transition: background-color 0.15s ease; + white-space: nowrap; + } + + button:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .btn-primary { + background: #4263eb; + color: white; + } + + .btn-primary:hover:not(:disabled) { + background: #3b5bdb; + } + + .btn-success { + background: #2f9e44; + color: white; + } + + .btn-success:hover:not(:disabled) { + background: #2b8a3e; + } + + .btn-warning { + background: #e8590c; + color: white; + } + + .btn-warning:hover:not(:disabled) { + background: #d9480f; + } + + .btn-danger { + background: #e03131; + color: white; + } + + .btn-danger:hover:not(:disabled) { + background: #c92a2a; + } + + .scan-actions { + display: flex; + gap: 0.75em; + flex-wrap: wrap; + } + + .status-bar { + margin-top: 1.5em; + padding: 0.75em 1em; + background: #1a1d20; + border-radius: 4px; + font-size: 0.85em; + color: #868e96; + min-height: 1.2em; + } + + .status-bar.active { + color: #ffd43b; + } + `; + + override connectedCallback(): void { + super.connectedCallback(); + this.loadCurrentDirectory(); + + EventsOn( + Events.LibraryScanStarted, + this.handleScanStarted, + ); + EventsOn( + Events.LibraryScanComplete, + this.handleScanComplete, + ); + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + EventsOff(Events.LibraryScanStarted); + EventsOff(Events.LibraryScanComplete); + } + + private async loadCurrentDirectory(): Promise { + try { + const dir = await GetLibraryDirectory(); + this.libraryDirectory = dir; + this.selectedDirectory = dir; + } catch (err) { + console.error( + 'Failed to load library directory:', + err, + ); + } + } + + private handleScanStarted = (): void => { + this.scanning = true; + this.statusMessage = 'Scanning...'; + }; + + private handleScanComplete = (): void => { + this.scanning = false; + this.statusMessage = 'Scan complete.'; + }; + + private handleSelectDirectory = async (): Promise => { + try { + const dir = await DirectoryPicker(); + + if (dir) { + this.selectedDirectory = dir; + } + } catch (err) { + console.error('Directory picker failed:', err); + } + }; + + private handleSaveDirectory = async (): Promise => { + if (!this.selectedDirectory) return; + + try { + await SetLibraryDirectory(this.selectedDirectory); + this.libraryDirectory = this.selectedDirectory; + this.statusMessage = + 'Library directory saved. A scan will start automatically if the directory changed.'; + } catch (err) { + this.statusMessage = `Failed to save directory: ${err}`; + console.error('Failed to save directory:', err); + } + }; + + private handleSoftScan = async (): Promise => { + try { + await Scan(); + } catch (err) { + this.statusMessage = `Scan failed: ${err}`; + console.error('Soft scan failed:', err); + } + }; + + private handleFullRescan = async (): Promise => { + const confirmed = confirm( + 'This will delete ALL library data including cover art and re-scan from scratch. Continue?', + ); + + if (!confirmed) return; + + try { + await FullRescan(); + } catch (err) { + this.statusMessage = `Full rescan failed: ${err}`; + console.error('Full rescan failed:', err); + } + }; + + private get directoryChanged(): boolean { + return ( + this.selectedDirectory !== this.libraryDirectory + ); + } + + override render() { + return html` +

Library Manager

+ +
+

+ Library Directory +

+

+ Select the root directory containing your + music files. Changing this will + automatically trigger a scan. +

+
+
+ ${this.selectedDirectory || + 'No directory selected'} +
+ + +
+
+ +
+

Scan Actions

+

+ Soft scan finds new files, skips existing + ones, and removes orphaned entries. Full + rescan clears the entire database and + cover art cache, then re-imports + everything. +

+
+ + +
+
+ +
+ ${this.statusMessage || 'Ready.'} +
+ `; + } +} diff --git a/frontend/src/events.ts b/frontend/src/events.ts index 938c6c2..718d7ae 100644 --- a/frontend/src/events.ts +++ b/frontend/src/events.ts @@ -39,6 +39,7 @@ export const Events = { RequestRemoveTracksFromQueue: "RequestRemoveTracksFromQueue", // Library events + LibraryScanStarted: "LibraryScanStarted", LibraryScanComplete: "LibraryScanComplete", } as const; diff --git a/frontend/wailsjs/go/config/Config.d.ts b/frontend/wailsjs/go/config/Config.d.ts new file mode 100755 index 0000000..4090fa7 --- /dev/null +++ b/frontend/wailsjs/go/config/Config.d.ts @@ -0,0 +1,18 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT +import {http} from '../models'; +import {context} from '../models'; + +export function GetLibraryDirectory():Promise; + +export function Load():Promise; + +export function Save():Promise; + +export function ServeHTTP(arg1:http.ResponseWriter,arg2:http.Request):Promise; + +export function SetContext(arg1:context.Context):Promise; + +export function SetLibraryDirectory(arg1:string):Promise; + +export function Validate():Promise; diff --git a/frontend/wailsjs/go/config/Config.js b/frontend/wailsjs/go/config/Config.js new file mode 100755 index 0000000..48778dd --- /dev/null +++ b/frontend/wailsjs/go/config/Config.js @@ -0,0 +1,31 @@ +// @ts-check +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +export function GetLibraryDirectory() { + return window['go']['config']['Config']['GetLibraryDirectory'](); +} + +export function Load() { + return window['go']['config']['Config']['Load'](); +} + +export function Save() { + return window['go']['config']['Config']['Save'](); +} + +export function ServeHTTP(arg1, arg2) { + return window['go']['config']['Config']['ServeHTTP'](arg1, arg2); +} + +export function SetContext(arg1) { + return window['go']['config']['Config']['SetContext'](arg1); +} + +export function SetLibraryDirectory(arg1) { + return window['go']['config']['Config']['SetLibraryDirectory'](arg1); +} + +export function Validate() { + return window['go']['config']['Config']['Validate'](); +} diff --git a/frontend/wailsjs/go/library/Library.d.ts b/frontend/wailsjs/go/library/Library.d.ts index 7f560d9..e98c04a 100755 --- a/frontend/wailsjs/go/library/Library.d.ts +++ b/frontend/wailsjs/go/library/Library.d.ts @@ -3,6 +3,8 @@ import {library} from '../models'; import {context} from '../models'; +export function FullRescan():Promise; + export function GetAlbumTracks(arg1:number):Promise>; export function GetAllAlbums():Promise>; @@ -12,3 +14,5 @@ export function GetAllTracks():Promise>; export function Scan():Promise; export function SetContext(arg1:context.Context):Promise; + +export function SetQueue(arg1:library.queueClearer):Promise; diff --git a/frontend/wailsjs/go/library/Library.js b/frontend/wailsjs/go/library/Library.js index e6ecf27..cdcdfac 100755 --- a/frontend/wailsjs/go/library/Library.js +++ b/frontend/wailsjs/go/library/Library.js @@ -2,6 +2,10 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT +export function FullRescan() { + return window['go']['library']['Library']['FullRescan'](); +} + export function GetAlbumTracks(arg1) { return window['go']['library']['Library']['GetAlbumTracks'](arg1); } @@ -21,3 +25,7 @@ export function Scan() { export function SetContext(arg1) { return window['go']['library']['Library']['SetContext'](arg1); } + +export function SetQueue(arg1) { + return window['go']['library']['Library']['SetQueue'](arg1); +} diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 8cd3526..404310c 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1,3 +1,132 @@ +export namespace http { + + export class Response { + Status: string; + StatusCode: number; + Proto: string; + ProtoMajor: number; + ProtoMinor: number; + Header: Record>; + Body: any; + ContentLength: number; + TransferEncoding: string[]; + Close: boolean; + Uncompressed: boolean; + Trailer: Record>; + Request?: Request; + TLS?: tls.ConnectionState; + + static createFrom(source: any = {}) { + return new Response(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.Status = source["Status"]; + this.StatusCode = source["StatusCode"]; + this.Proto = source["Proto"]; + this.ProtoMajor = source["ProtoMajor"]; + this.ProtoMinor = source["ProtoMinor"]; + this.Header = source["Header"]; + this.Body = source["Body"]; + this.ContentLength = source["ContentLength"]; + this.TransferEncoding = source["TransferEncoding"]; + this.Close = source["Close"]; + this.Uncompressed = source["Uncompressed"]; + this.Trailer = source["Trailer"]; + this.Request = this.convertValues(source["Request"], Request); + this.TLS = this.convertValues(source["TLS"], tls.ConnectionState); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + export class Request { + Method: string; + URL?: url.URL; + Proto: string; + ProtoMajor: number; + ProtoMinor: number; + Header: Record>; + Body: any; + ContentLength: number; + TransferEncoding: string[]; + Close: boolean; + Host: string; + Form: Record>; + PostForm: Record>; + MultipartForm?: multipart.Form; + Trailer: Record>; + RemoteAddr: string; + RequestURI: string; + TLS?: tls.ConnectionState; + Response?: Response; + Pattern: string; + + static createFrom(source: any = {}) { + return new Request(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.Method = source["Method"]; + this.URL = this.convertValues(source["URL"], url.URL); + this.Proto = source["Proto"]; + this.ProtoMajor = source["ProtoMajor"]; + this.ProtoMinor = source["ProtoMinor"]; + this.Header = source["Header"]; + this.Body = source["Body"]; + this.ContentLength = source["ContentLength"]; + this.TransferEncoding = source["TransferEncoding"]; + this.Close = source["Close"]; + this.Host = source["Host"]; + this.Form = source["Form"]; + this.PostForm = source["PostForm"]; + this.MultipartForm = this.convertValues(source["MultipartForm"], multipart.Form); + this.Trailer = source["Trailer"]; + this.RemoteAddr = source["RemoteAddr"]; + this.RequestURI = source["RequestURI"]; + this.TLS = this.convertValues(source["TLS"], tls.ConnectionState); + this.Response = this.convertValues(source["Response"], Response); + this.Pattern = source["Pattern"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + +} + export namespace library { export class Album { @@ -51,6 +180,163 @@ export namespace library { } +export namespace multipart { + + export class FileHeader { + Filename: string; + Header: Record>; + Size: number; + + static createFrom(source: any = {}) { + return new FileHeader(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.Filename = source["Filename"]; + this.Header = source["Header"]; + this.Size = source["Size"]; + } + } + export class Form { + Value: Record>; + File: Record>; + + static createFrom(source: any = {}) { + return new Form(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.Value = source["Value"]; + this.File = this.convertValues(source["File"], Array, true); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + +} + +export namespace net { + + export class IPNet { + IP: number[]; + Mask: number[]; + + static createFrom(source: any = {}) { + return new IPNet(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.IP = source["IP"]; + this.Mask = source["Mask"]; + } + } + +} + +export namespace pkix { + + export class AttributeTypeAndValue { + Type: number[]; + Value: any; + + static createFrom(source: any = {}) { + return new AttributeTypeAndValue(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.Type = source["Type"]; + this.Value = source["Value"]; + } + } + export class Extension { + Id: number[]; + Critical: boolean; + Value: number[]; + + static createFrom(source: any = {}) { + return new Extension(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.Id = source["Id"]; + this.Critical = source["Critical"]; + this.Value = source["Value"]; + } + } + export class Name { + Country: string[]; + Organization: string[]; + OrganizationalUnit: string[]; + Locality: string[]; + Province: string[]; + StreetAddress: string[]; + PostalCode: string[]; + SerialNumber: string; + CommonName: string; + Names: AttributeTypeAndValue[]; + ExtraNames: AttributeTypeAndValue[]; + + static createFrom(source: any = {}) { + return new Name(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.Country = source["Country"]; + this.Organization = source["Organization"]; + this.OrganizationalUnit = source["OrganizationalUnit"]; + this.Locality = source["Locality"]; + this.Province = source["Province"]; + this.StreetAddress = source["StreetAddress"]; + this.PostalCode = source["PostalCode"]; + this.SerialNumber = source["SerialNumber"]; + this.CommonName = source["CommonName"]; + this.Names = this.convertValues(source["Names"], AttributeTypeAndValue); + this.ExtraNames = this.convertValues(source["ExtraNames"], AttributeTypeAndValue); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + +} + export namespace playlist { export class Summary { @@ -134,3 +420,318 @@ export namespace playlist { } +export namespace tls { + + export class ConnectionState { + Version: number; + HandshakeComplete: boolean; + DidResume: boolean; + CipherSuite: number; + CurveID: number; + NegotiatedProtocol: string; + NegotiatedProtocolIsMutual: boolean; + ServerName: string; + PeerCertificates: x509.Certificate[]; + VerifiedChains: x509.Certificate[][]; + SignedCertificateTimestamps: number[][]; + OCSPResponse: number[]; + TLSUnique: number[]; + ECHAccepted: boolean; + + static createFrom(source: any = {}) { + return new ConnectionState(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.Version = source["Version"]; + this.HandshakeComplete = source["HandshakeComplete"]; + this.DidResume = source["DidResume"]; + this.CipherSuite = source["CipherSuite"]; + this.CurveID = source["CurveID"]; + this.NegotiatedProtocol = source["NegotiatedProtocol"]; + this.NegotiatedProtocolIsMutual = source["NegotiatedProtocolIsMutual"]; + this.ServerName = source["ServerName"]; + this.PeerCertificates = this.convertValues(source["PeerCertificates"], x509.Certificate); + this.VerifiedChains = this.convertValues(source["VerifiedChains"], x509.Certificate); + this.SignedCertificateTimestamps = source["SignedCertificateTimestamps"]; + this.OCSPResponse = source["OCSPResponse"]; + this.TLSUnique = source["TLSUnique"]; + this.ECHAccepted = source["ECHAccepted"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + +} + +export namespace url { + + export class Userinfo { + + + static createFrom(source: any = {}) { + return new Userinfo(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + + } + } + export class URL { + Scheme: string; + Opaque: string; + // Go type: Userinfo + User?: any; + Host: string; + Path: string; + RawPath: string; + OmitHost: boolean; + ForceQuery: boolean; + RawQuery: string; + Fragment: string; + RawFragment: string; + + static createFrom(source: any = {}) { + return new URL(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.Scheme = source["Scheme"]; + this.Opaque = source["Opaque"]; + this.User = this.convertValues(source["User"], null); + this.Host = source["Host"]; + this.Path = source["Path"]; + this.RawPath = source["RawPath"]; + this.OmitHost = source["OmitHost"]; + this.ForceQuery = source["ForceQuery"]; + this.RawQuery = source["RawQuery"]; + this.Fragment = source["Fragment"]; + this.RawFragment = source["RawFragment"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + +} + +export namespace x509 { + + export class PolicyMapping { + // Go type: OID + IssuerDomainPolicy: any; + // Go type: OID + SubjectDomainPolicy: any; + + static createFrom(source: any = {}) { + return new PolicyMapping(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.IssuerDomainPolicy = this.convertValues(source["IssuerDomainPolicy"], null); + this.SubjectDomainPolicy = this.convertValues(source["SubjectDomainPolicy"], null); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + export class OID { + + + static createFrom(source: any = {}) { + return new OID(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + + } + } + export class Certificate { + Raw: number[]; + RawTBSCertificate: number[]; + RawSubjectPublicKeyInfo: number[]; + RawSubject: number[]; + RawIssuer: number[]; + Signature: number[]; + SignatureAlgorithm: number; + PublicKeyAlgorithm: number; + PublicKey: any; + Version: number; + // Go type: big + SerialNumber?: any; + Issuer: pkix.Name; + Subject: pkix.Name; + // Go type: time + NotBefore: any; + // Go type: time + NotAfter: any; + KeyUsage: number; + Extensions: pkix.Extension[]; + ExtraExtensions: pkix.Extension[]; + UnhandledCriticalExtensions: number[][]; + ExtKeyUsage: number[]; + UnknownExtKeyUsage: number[][]; + BasicConstraintsValid: boolean; + IsCA: boolean; + MaxPathLen: number; + MaxPathLenZero: boolean; + SubjectKeyId: number[]; + AuthorityKeyId: number[]; + OCSPServer: string[]; + IssuingCertificateURL: string[]; + DNSNames: string[]; + EmailAddresses: string[]; + IPAddresses: number[][]; + URIs: url.URL[]; + PermittedDNSDomainsCritical: boolean; + PermittedDNSDomains: string[]; + ExcludedDNSDomains: string[]; + PermittedIPRanges: net.IPNet[]; + ExcludedIPRanges: net.IPNet[]; + PermittedEmailAddresses: string[]; + ExcludedEmailAddresses: string[]; + PermittedURIDomains: string[]; + ExcludedURIDomains: string[]; + CRLDistributionPoints: string[]; + PolicyIdentifiers: number[][]; + Policies: OID[]; + InhibitAnyPolicy: number; + InhibitAnyPolicyZero: boolean; + InhibitPolicyMapping: number; + InhibitPolicyMappingZero: boolean; + RequireExplicitPolicy: number; + RequireExplicitPolicyZero: boolean; + PolicyMappings: PolicyMapping[]; + + static createFrom(source: any = {}) { + return new Certificate(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.Raw = source["Raw"]; + this.RawTBSCertificate = source["RawTBSCertificate"]; + this.RawSubjectPublicKeyInfo = source["RawSubjectPublicKeyInfo"]; + this.RawSubject = source["RawSubject"]; + this.RawIssuer = source["RawIssuer"]; + this.Signature = source["Signature"]; + this.SignatureAlgorithm = source["SignatureAlgorithm"]; + this.PublicKeyAlgorithm = source["PublicKeyAlgorithm"]; + this.PublicKey = source["PublicKey"]; + this.Version = source["Version"]; + this.SerialNumber = this.convertValues(source["SerialNumber"], null); + this.Issuer = this.convertValues(source["Issuer"], pkix.Name); + this.Subject = this.convertValues(source["Subject"], pkix.Name); + this.NotBefore = this.convertValues(source["NotBefore"], null); + this.NotAfter = this.convertValues(source["NotAfter"], null); + this.KeyUsage = source["KeyUsage"]; + this.Extensions = this.convertValues(source["Extensions"], pkix.Extension); + this.ExtraExtensions = this.convertValues(source["ExtraExtensions"], pkix.Extension); + this.UnhandledCriticalExtensions = source["UnhandledCriticalExtensions"]; + this.ExtKeyUsage = source["ExtKeyUsage"]; + this.UnknownExtKeyUsage = source["UnknownExtKeyUsage"]; + this.BasicConstraintsValid = source["BasicConstraintsValid"]; + this.IsCA = source["IsCA"]; + this.MaxPathLen = source["MaxPathLen"]; + this.MaxPathLenZero = source["MaxPathLenZero"]; + this.SubjectKeyId = source["SubjectKeyId"]; + this.AuthorityKeyId = source["AuthorityKeyId"]; + this.OCSPServer = source["OCSPServer"]; + this.IssuingCertificateURL = source["IssuingCertificateURL"]; + this.DNSNames = source["DNSNames"]; + this.EmailAddresses = source["EmailAddresses"]; + this.IPAddresses = source["IPAddresses"]; + this.URIs = this.convertValues(source["URIs"], url.URL); + this.PermittedDNSDomainsCritical = source["PermittedDNSDomainsCritical"]; + this.PermittedDNSDomains = source["PermittedDNSDomains"]; + this.ExcludedDNSDomains = source["ExcludedDNSDomains"]; + this.PermittedIPRanges = this.convertValues(source["PermittedIPRanges"], net.IPNet); + this.ExcludedIPRanges = this.convertValues(source["ExcludedIPRanges"], net.IPNet); + this.PermittedEmailAddresses = source["PermittedEmailAddresses"]; + this.ExcludedEmailAddresses = source["ExcludedEmailAddresses"]; + this.PermittedURIDomains = source["PermittedURIDomains"]; + this.ExcludedURIDomains = source["ExcludedURIDomains"]; + this.CRLDistributionPoints = source["CRLDistributionPoints"]; + this.PolicyIdentifiers = source["PolicyIdentifiers"]; + this.Policies = this.convertValues(source["Policies"], OID); + this.InhibitAnyPolicy = source["InhibitAnyPolicy"]; + this.InhibitAnyPolicyZero = source["InhibitAnyPolicyZero"]; + this.InhibitPolicyMapping = source["InhibitPolicyMapping"]; + this.InhibitPolicyMappingZero = source["InhibitPolicyMappingZero"]; + this.RequireExplicitPolicy = source["RequireExplicitPolicy"]; + this.RequireExplicitPolicyZero = source["RequireExplicitPolicyZero"]; + this.PolicyMappings = this.convertValues(source["PolicyMappings"], PolicyMapping); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + +} +