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
This commit is contained in:
2026-02-19 10:17:09 -05:00
parent 8c013d4179
commit 81793975b4
36 changed files with 2350 additions and 179 deletions
+5
View File
@@ -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)
+48
View File
@@ -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
}
@@ -23,3 +23,6 @@ WHERE id = ?;
-- name: DeleteArtistCredit :exec
DELETE FROM artist_credit
WHERE id = ?;
-- name: DeleteAllArtistCredits :exec
DELETE FROM artist_credit;
@@ -15,3 +15,6 @@ WHERE id =?;
DELETE FROM artist_credit_artist
WHERE id =?;
-- name: DeleteAllArtistCreditArtists :exec
DELETE FROM artist_credit_artist;
+3
View File
@@ -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;
@@ -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,
@@ -26,3 +26,6 @@ WHERE id = ?;
-- name: DeleteCoverArt :exec
DELETE FROM cover_art
WHERE id = ?;
-- name: DeleteAllCoverArt :exec
DELETE FROM cover_art;
@@ -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 = ?;
@@ -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;
@@ -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;
@@ -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;
@@ -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 = ?
@@ -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 =?
@@ -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 = ?
@@ -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 = ?
@@ -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 = ?
@@ -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 = ?
`
@@ -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 = ?
@@ -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 = ?
@@ -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 = ?
+1
View File
@@ -56,5 +56,6 @@ const (
// Library events.
const (
LibraryScanStarted = "LibraryScanStarted"
LibraryScanComplete = "LibraryScanComplete"
)
+456 -179
View File
@@ -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 != "" {
+185
View File
@@ -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
}
+36
View File
@@ -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
}
+46
View File
@@ -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
}
+329
View File
@@ -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
}
+117
View File
@@ -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)
}
}
+23
View File
@@ -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() {
+4
View File
@@ -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 = '<playlist-view></playlist-view>';
break;
case 'libraries':
mainContent.innerHTML = '<library-manager></library-manager>';
break;
default:
mainContent.innerHTML = `<div style="padding: 1em; color: #b3b3b3;">
<p>Coming soon: ${view}</p>
@@ -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<void> {
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<void> => {
try {
const dir = await DirectoryPicker();
if (dir) {
this.selectedDirectory = dir;
}
} catch (err) {
console.error('Directory picker failed:', err);
}
};
private handleSaveDirectory = async (): Promise<void> => {
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<void> => {
try {
await Scan();
} catch (err) {
this.statusMessage = `Scan failed: ${err}`;
console.error('Soft scan failed:', err);
}
};
private handleFullRescan = async (): Promise<void> => {
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`
<h2>Library Manager</h2>
<div class="section">
<p class="section-title">
Library Directory
</p>
<p class="section-description">
Select the root directory containing your
music files. Changing this will
automatically trigger a scan.
</p>
<div class="directory-row">
<div
class="directory-path ${this.selectedDirectory ? 'has-value' : ''}"
>
${this.selectedDirectory ||
'No directory selected'}
</div>
<button
class="btn-primary"
@click=${this.handleSelectDirectory}
>
Browse
</button>
<button
class="btn-success"
?disabled=${!this.directoryChanged ||
this.scanning}
@click=${this.handleSaveDirectory}
>
Save
</button>
</div>
</div>
<div class="section">
<p class="section-title">Scan Actions</p>
<p class="section-description">
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.
</p>
<div class="scan-actions">
<button
class="btn-warning"
?disabled=${this.scanning}
@click=${this.handleSoftScan}
>
${this.scanning
? 'Scanning...'
: 'Soft Scan'}
</button>
<button
class="btn-danger"
?disabled=${this.scanning}
@click=${this.handleFullRescan}
>
${this.scanning
? 'Scanning...'
: 'Full Rescan'}
</button>
</div>
</div>
<div
class="status-bar ${this.scanning ? 'active' : ''}"
>
${this.statusMessage || 'Ready.'}
</div>
`;
}
}
+1
View File
@@ -39,6 +39,7 @@ export const Events = {
RequestRemoveTracksFromQueue: "RequestRemoveTracksFromQueue",
// Library events
LibraryScanStarted: "LibraryScanStarted",
LibraryScanComplete: "LibraryScanComplete",
} as const;
+18
View File
@@ -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<string>;
export function Load():Promise<void>;
export function Save():Promise<void>;
export function ServeHTTP(arg1:http.ResponseWriter,arg2:http.Request):Promise<void>;
export function SetContext(arg1:context.Context):Promise<void>;
export function SetLibraryDirectory(arg1:string):Promise<void>;
export function Validate():Promise<void>;
+31
View File
@@ -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']();
}
+4
View File
@@ -3,6 +3,8 @@
import {library} from '../models';
import {context} from '../models';
export function FullRescan():Promise<void>;
export function GetAlbumTracks(arg1:number):Promise<Array<library.Track>>;
export function GetAllAlbums():Promise<Array<library.Album>>;
@@ -12,3 +14,5 @@ export function GetAllTracks():Promise<Array<library.Track>>;
export function Scan():Promise<void>;
export function SetContext(arg1:context.Context):Promise<void>;
export function SetQueue(arg1:library.queueClearer):Promise<void>;
+8
View File
@@ -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);
}
+601
View File
@@ -1,3 +1,132 @@
export namespace http {
export class Response {
Status: string;
StatusCode: number;
Proto: string;
ProtoMajor: number;
ProtoMinor: number;
Header: Record<string, Array<string>>;
Body: any;
ContentLength: number;
TransferEncoding: string[];
Close: boolean;
Uncompressed: boolean;
Trailer: Record<string, Array<string>>;
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<string, Array<string>>;
Body: any;
ContentLength: number;
TransferEncoding: string[];
Close: boolean;
Host: string;
Form: Record<string, Array<string>>;
PostForm: Record<string, Array<string>>;
MultipartForm?: multipart.Form;
Trailer: Record<string, Array<string>>;
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<string, Array<string>>;
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<string, Array<string>>;
File: Record<string, Array<FileHeader>>;
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<FileHeader>, 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;
}
}
}