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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 = ?
|
||||
|
||||
@@ -56,5 +56,6 @@ const (
|
||||
|
||||
// Library events.
|
||||
const (
|
||||
LibraryScanStarted = "LibraryScanStarted"
|
||||
LibraryScanComplete = "LibraryScanComplete"
|
||||
)
|
||||
|
||||
+456
-179
@@ -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 != "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ export const Events = {
|
||||
RequestRemoveTracksFromQueue: "RequestRemoveTracksFromQueue",
|
||||
|
||||
// Library events
|
||||
LibraryScanStarted: "LibraryScanStarted",
|
||||
LibraryScanComplete: "LibraryScanComplete",
|
||||
} as const;
|
||||
|
||||
|
||||
+18
@@ -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>;
|
||||
Executable
+31
@@ -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
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user