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.FEBindings = []any{
|
||||||
yjApp.FrontendUtil,
|
yjApp.FrontendUtil,
|
||||||
|
yjApp.appConfig,
|
||||||
yjApp.library,
|
yjApp.library,
|
||||||
yjApp.playlist,
|
yjApp.playlist,
|
||||||
}
|
}
|
||||||
@@ -141,6 +142,10 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
|
|||||||
yj.queue.SetPlayer(yj.player)
|
yj.queue.SetPlayer(yj.player)
|
||||||
yj.queue.RestoreState()
|
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.
|
// Register playback finished handler to drive queue auto-advance.
|
||||||
yj.player.SetPlaybackFinishedHandler(yj.queue.OnPlaybackFinished)
|
yj.player.SetPlaybackFinishedHandler(yj.queue.OnPlaybackFinished)
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import (
|
|||||||
"path"
|
"path"
|
||||||
|
|
||||||
"github.com/BurntSushi/toml"
|
"github.com/BurntSushi/toml"
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
|
||||||
|
"yellowjacket/backend/events"
|
||||||
"yellowjacket/backend/library"
|
"yellowjacket/backend/library"
|
||||||
"yellowjacket/backend/system"
|
"yellowjacket/backend/system"
|
||||||
)
|
)
|
||||||
@@ -148,3 +150,49 @@ func (c *Config) applyDefaults() {
|
|||||||
func (c *Config) SetContext(ctx context.Context) {
|
func (c *Config) SetContext(ctx context.Context) {
|
||||||
c.ctx = ctx
|
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
|
-- name: DeleteArtistCredit :exec
|
||||||
DELETE FROM artist_credit
|
DELETE FROM artist_credit
|
||||||
WHERE id = ?;
|
WHERE id = ?;
|
||||||
|
|
||||||
|
-- name: DeleteAllArtistCredits :exec
|
||||||
|
DELETE FROM artist_credit;
|
||||||
|
|||||||
@@ -15,3 +15,6 @@ WHERE id =?;
|
|||||||
DELETE FROM artist_credit_artist
|
DELETE FROM artist_credit_artist
|
||||||
WHERE id =?;
|
WHERE id =?;
|
||||||
|
|
||||||
|
-- name: DeleteAllArtistCreditArtists :exec
|
||||||
|
DELETE FROM artist_credit_artist;
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ WHERE id = ?;
|
|||||||
DELETE FROM artists
|
DELETE FROM artists
|
||||||
WHERE id = ?;
|
WHERE id = ?;
|
||||||
|
|
||||||
|
-- name: DeleteAllArtists :exec
|
||||||
|
DELETE FROM artists;
|
||||||
|
|
||||||
-- name: GetAllArtists :many
|
-- name: GetAllArtists :many
|
||||||
SELECT * FROM artists
|
SELECT * FROM artists
|
||||||
ORDER BY name;
|
ORDER BY name;
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
|
|||||||
WHERE af.file_path = ?
|
WHERE af.file_path = ?
|
||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- name: DeleteAllAudioFiles :exec
|
||||||
|
DELETE FROM audio_files;
|
||||||
|
|
||||||
-- name: GetAudioFilesByReleaseGroup :many
|
-- name: GetAudioFilesByReleaseGroup :many
|
||||||
SELECT
|
SELECT
|
||||||
af.file_path,
|
af.file_path,
|
||||||
|
|||||||
@@ -26,3 +26,6 @@ WHERE id = ?;
|
|||||||
-- name: DeleteCoverArt :exec
|
-- name: DeleteCoverArt :exec
|
||||||
DELETE FROM cover_art
|
DELETE FROM cover_art
|
||||||
WHERE id = ?;
|
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
|
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
|
||||||
ORDER BY pt.playlist_id, pt.position;
|
ORDER BY pt.playlist_id, pt.position;
|
||||||
|
|
||||||
|
-- name: DeleteAllPlaylistTracks :exec
|
||||||
|
DELETE FROM playlist_tracks;
|
||||||
|
|
||||||
-- name: GetNextPlaylistTrackPosition :one
|
-- name: GetNextPlaylistTrackPosition :one
|
||||||
SELECT COALESCE(MAX(position), -1) + 1 AS next_position
|
SELECT COALESCE(MAX(position), -1) + 1 AS next_position
|
||||||
FROM playlist_tracks WHERE playlist_id = ?;
|
FROM playlist_tracks WHERE playlist_id = ?;
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ WHERE id = ?;
|
|||||||
DELETE FROM recordings
|
DELETE FROM recordings
|
||||||
WHERE id = ?;
|
WHERE id = ?;
|
||||||
|
|
||||||
|
-- name: DeleteAllRecordings :exec
|
||||||
|
DELETE FROM recordings;
|
||||||
|
|
||||||
-- name: GetAllRecordings :many
|
-- name: GetAllRecordings :many
|
||||||
SELECT * FROM recordings
|
SELECT * FROM recordings
|
||||||
ORDER BY name;
|
ORDER BY name;
|
||||||
|
|||||||
@@ -23,3 +23,6 @@ WHERE id = ?;
|
|||||||
-- name: DeleteReleaseGroupRecordingByFK :exec
|
-- name: DeleteReleaseGroupRecordingByFK :exec
|
||||||
DELETE FROM release_group_recordings
|
DELETE FROM release_group_recordings
|
||||||
WHERE release_group_id = ? AND recording_id = ?;
|
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
|
DELETE FROM release_groups
|
||||||
WHERE id = ?;
|
WHERE id = ?;
|
||||||
|
|
||||||
|
-- name: DeleteAllReleaseGroups :exec
|
||||||
|
DELETE FROM release_groups;
|
||||||
|
|
||||||
-- name: GetAllReleaseGroups :many
|
-- name: GetAllReleaseGroups :many
|
||||||
SELECT * FROM release_groups
|
SELECT * FROM release_groups
|
||||||
ORDER BY name;
|
ORDER BY name;
|
||||||
|
|||||||
@@ -21,6 +21,15 @@ func (q *Queries) CreateArtistCredit(ctx context.Context, text string) (ArtistCr
|
|||||||
return i, err
|
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
|
const deleteArtistCredit = `-- name: DeleteArtistCredit :exec
|
||||||
DELETE FROM artist_credit
|
DELETE FROM artist_credit
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
|
|||||||
@@ -26,6 +26,15 @@ func (q *Queries) CreateArtistCreditArtist(ctx context.Context, arg CreateArtist
|
|||||||
return i, err
|
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
|
const deleteArtistCreditArtist = `-- name: DeleteArtistCreditArtist :exec
|
||||||
DELETE FROM artist_credit_artist
|
DELETE FROM artist_credit_artist
|
||||||
WHERE id =?
|
WHERE id =?
|
||||||
|
|||||||
@@ -21,6 +21,15 @@ func (q *Queries) CreateArtist(ctx context.Context, name string) (Artist, error)
|
|||||||
return i, err
|
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
|
const deleteArtist = `-- name: DeleteArtist :exec
|
||||||
DELETE FROM artists
|
DELETE FROM artists
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
|
|||||||
@@ -51,6 +51,15 @@ func (q *Queries) CreateAudioFile(ctx context.Context, arg CreateAudioFileParams
|
|||||||
return i, err
|
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
|
const deleteAudioFile = `-- name: DeleteAudioFile :exec
|
||||||
DELETE FROM audio_files
|
DELETE FROM audio_files
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
|
|||||||
@@ -32,6 +32,15 @@ func (q *Queries) CreateCoverArt(ctx context.Context, arg CreateCoverArtParams)
|
|||||||
return i, err
|
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
|
const deleteCoverArt = `-- name: DeleteCoverArt :exec
|
||||||
DELETE FROM cover_art
|
DELETE FROM cover_art
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
|
|||||||
@@ -58,6 +58,15 @@ func (q *Queries) CreatePlaylist(ctx context.Context, name string) (Playlist, er
|
|||||||
return i, err
|
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
|
const deletePlaylist = `-- name: DeletePlaylist :exec
|
||||||
DELETE FROM playlists WHERE id = ?
|
DELETE FROM playlists WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -86,6 +86,15 @@ func (q *Queries) CreateRecordingFull(ctx context.Context, arg CreateRecordingFu
|
|||||||
return i, err
|
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
|
const deleteRecording = `-- name: DeleteRecording :exec
|
||||||
DELETE FROM recordings
|
DELETE FROM recordings
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
|
|||||||
@@ -41,6 +41,15 @@ func (q *Queries) CreateReleaseGroupRecording(ctx context.Context, arg CreateRel
|
|||||||
return i, err
|
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
|
const deleteReleaseGroupRecording = `-- name: DeleteReleaseGroupRecording :exec
|
||||||
DELETE FROM release_group_recordings
|
DELETE FROM release_group_recordings
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
|
|||||||
@@ -68,6 +68,15 @@ func (q *Queries) CreateReleaseGroupFull(ctx context.Context, arg CreateReleaseG
|
|||||||
return i, err
|
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
|
const deleteReleaseGroup = `-- name: DeleteReleaseGroup :exec
|
||||||
DELETE FROM release_groups
|
DELETE FROM release_groups
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
|
|||||||
@@ -56,5 +56,6 @@ const (
|
|||||||
|
|
||||||
// Library events.
|
// Library events.
|
||||||
const (
|
const (
|
||||||
|
LibraryScanStarted = "LibraryScanStarted"
|
||||||
LibraryScanComplete = "LibraryScanComplete"
|
LibraryScanComplete = "LibraryScanComplete"
|
||||||
)
|
)
|
||||||
|
|||||||
+456
-179
@@ -26,12 +26,55 @@ import (
|
|||||||
|
|
||||||
var errLibraryDirNotConfigured = errors.New("library directory not configured")
|
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.
|
// Library manages scanning and querying the music collection.
|
||||||
type Library struct {
|
type Library struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
conf *Config
|
conf *Config
|
||||||
db *database.DB
|
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.
|
// 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.
|
// 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.
|
// Files that exist but have incomplete metadata (recording_id = 0) will be updated.
|
||||||
func (l *Library) Scan() error {
|
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 {
|
if len(l.conf.DirectoryPath) == 0 {
|
||||||
return errLibraryDirNotConfigured
|
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
|
var dbWg sync.WaitGroup
|
||||||
|
|
||||||
dbWg.Add(1)
|
dbWg.Add(1)
|
||||||
@@ -232,35 +281,35 @@ func (l *Library) Scan() error {
|
|||||||
go func() {
|
go func() {
|
||||||
defer dbWg.Done()
|
defer dbWg.Done()
|
||||||
|
|
||||||
for result := range resultChan {
|
cache := newEntityCache()
|
||||||
var saveErr error
|
|
||||||
|
|
||||||
if result.needsUpdate {
|
var batch []importResult
|
||||||
saveErr = l.updateAudioFileMetadata(result)
|
|
||||||
if saveErr == nil {
|
flushBatch := func() {
|
||||||
updated.Add(1)
|
if len(batch) == 0 {
|
||||||
}
|
return
|
||||||
} else {
|
|
||||||
saveErr = l.saveAudioFile(result)
|
|
||||||
if saveErr == nil {
|
|
||||||
added.Add(1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if saveErr != nil {
|
if batchErr := l.commitBatch(
|
||||||
l.logger.Warn(
|
batch, cache, &added, &updated,
|
||||||
"failed to save audio file",
|
); batchErr != nil {
|
||||||
"path",
|
|
||||||
result.absolutePath,
|
|
||||||
"err",
|
|
||||||
saveErr,
|
|
||||||
)
|
|
||||||
|
|
||||||
errMu.Lock()
|
errMu.Lock()
|
||||||
scanErr = errors.Join(scanErr, saveErr)
|
scanErr = errors.Join(scanErr, batchErr)
|
||||||
errMu.Unlock()
|
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
|
// 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.
|
// 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) {
|
func (l *Library) extractAudioMetadata(work scanWork) (importResult, error) {
|
||||||
result := importResult{
|
result := importResult{
|
||||||
absolutePath: work.absolutePath,
|
absolutePath: work.absolutePath,
|
||||||
@@ -376,236 +426,241 @@ func (l *Library) extractAudioMetadata(work scanWork) (importResult, error) {
|
|||||||
needsUpdate: work.needsUpdate,
|
needsUpdate: work.needsUpdate,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get duration (skip if updating and we already have it)
|
// Skip duration decode if we already have it from a previous import.
|
||||||
if work.needsUpdate && work.existingLength > 0 {
|
skipDuration := 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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
result.lengthMillis = trackLengthMillis
|
tags, lengthMillis, err := metadata.ExtractAllMetadata(
|
||||||
}
|
work.absolutePath, skipDuration,
|
||||||
|
)
|
||||||
// Extract tags
|
|
||||||
tags, err := metadata.ExtractTags(work.absolutePath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.logger.Warn("could not extract tags", "path", work.absolutePath, "err", err)
|
return result, fmt.Errorf(
|
||||||
// Continue with empty tags - not a fatal error
|
"could not extract metadata for %s: %w",
|
||||||
tags = &metadata.TrackMetadata{}
|
work.absolutePath,
|
||||||
|
err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
result.tags = tags
|
result.tags = tags
|
||||||
|
|
||||||
|
if skipDuration {
|
||||||
|
result.lengthMillis = work.existingLength
|
||||||
|
} else {
|
||||||
|
result.lengthMillis = lengthMillis
|
||||||
|
}
|
||||||
|
|
||||||
return result, nil
|
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).
|
// 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(
|
l.logger.Debug(
|
||||||
"saving audio file to db",
|
"saving audio file to db",
|
||||||
"absolute-path", result.absolutePath,
|
"absolute-path", result.absolutePath,
|
||||||
"track-length-millis", result.lengthMillis,
|
"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
|
// Process metadata and create related records.
|
||||||
recordingID, err := l.processMetadata(result)
|
recordingID, err := l.processMetadata(q, cache, result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("could not process metadata: %w", err)
|
return fmt.Errorf("could not process metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := l.db.Queries.CreateAudioFile(
|
if _, err := q.CreateAudioFile(
|
||||||
l.ctx, sqlcgen.CreateAudioFileParams{
|
l.ctx, sqlcgen.CreateAudioFileParams{
|
||||||
FilePath: result.absolutePath,
|
FilePath: result.absolutePath,
|
||||||
LengthMilliseconds: result.lengthMillis,
|
LengthMilliseconds: result.lengthMillis,
|
||||||
FileTypeID: int64(
|
FileTypeID: int64(
|
||||||
slices.Index(metadata.SupportedFileExtensions, result.fileType),
|
slices.Index(
|
||||||
|
metadata.SupportedFileExtensions,
|
||||||
|
result.fileType,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
RecordingID: recordingID,
|
RecordingID: recordingID,
|
||||||
}); err != nil {
|
}); 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateAudioFileMetadata updates an existing audio file with extracted metadata.
|
// 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(
|
l.logger.Debug(
|
||||||
"updating audio file metadata",
|
"updating audio file metadata",
|
||||||
"absolute-path", result.absolutePath,
|
"absolute-path", result.absolutePath,
|
||||||
"file-id", result.existingFileID,
|
"file-id", result.existingFileID,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Process metadata and create related records
|
// Process metadata and create related records.
|
||||||
recordingID, err := l.processMetadata(result)
|
recordingID, err := l.processMetadata(q, cache, result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("could not process metadata: %w", err)
|
return fmt.Errorf("could not process metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := l.db.Queries.UpdateAudioFileRecording(
|
if err := q.UpdateAudioFileRecording(
|
||||||
l.ctx, sqlcgen.UpdateAudioFileRecordingParams{
|
l.ctx, sqlcgen.UpdateAudioFileRecordingParams{
|
||||||
RecordingID: recordingID,
|
RecordingID: recordingID,
|
||||||
ID: result.existingFileID,
|
ID: result.existingFileID,
|
||||||
}); err != nil {
|
}); 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// processMetadata creates all related database records for metadata and returns the recording ID.
|
// processMetadata creates all related database records for metadata
|
||||||
func (l *Library) processMetadata(result importResult) (int64, error) {
|
// 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
|
tags := result.tags
|
||||||
if tags == nil {
|
if tags == nil {
|
||||||
tags = &metadata.TrackMetadata{}
|
tags = &metadata.TrackMetadata{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Handle cover art (if present)
|
// 1. Handle cover art (if present).
|
||||||
var coverArtID sql.NullInt64
|
coverArtID := l.processCoverArt(q, cache, tags)
|
||||||
|
|
||||||
if tags.Picture != nil {
|
// 2. Get or create artist credit for track artist.
|
||||||
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
|
|
||||||
artistName := tags.Artist
|
artistName := tags.Artist
|
||||||
if artistName == "" {
|
if artistName == "" {
|
||||||
artistName = "Unknown Artist"
|
artistName = "Unknown Artist"
|
||||||
}
|
}
|
||||||
|
|
||||||
artistCredit, err := l.db.Queries.UpsertArtistCredit(l.ctx, artistName)
|
artistCredit, err := l.cachedUpsertArtistCredit(
|
||||||
|
q, cache, artistName,
|
||||||
|
)
|
||||||
if err != nil {
|
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)
|
l.cachedLinkArtist(q, cache, artistName, artistCredit.ID)
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Get or create artist credit for album artist.
|
// 3. Get or create artist credit for album artist.
|
||||||
// Always assign an album artist credit so the cover grid displays an
|
albumArtistCreditID := l.resolveAlbumArtistCredit(
|
||||||
// artist name. When the AlbumArtist tag is absent or identical to the
|
q, cache, tags, artistCredit.ID,
|
||||||
// track Artist, reuse the track artist credit instead of leaving it NULL.
|
)
|
||||||
var albumArtistCreditID sql.NullInt64
|
|
||||||
|
|
||||||
if tags.AlbumArtist != "" && tags.AlbumArtist != tags.Artist {
|
// 4. Get or create release group (album).
|
||||||
albumArtistCredit, err := l.db.Queries.UpsertArtistCredit(
|
releaseGroupID := l.resolveReleaseGroup(
|
||||||
l.ctx, tags.AlbumArtist,
|
q, cache, tags, albumArtistCreditID, coverArtID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
|
||||||
l.logger.Warn("could not upsert album artist credit", "err", err)
|
|
||||||
} else {
|
|
||||||
albumArtistCreditID = sql.NullInt64{
|
|
||||||
Int64: albumArtistCredit.ID, Valid: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also create the artist record and link.
|
// 5. Create recording.
|
||||||
albumArtist, err := l.db.Queries.UpsertArtist(
|
recording, err := q.CreateRecordingFull(
|
||||||
l.ctx, tags.AlbumArtist,
|
l.ctx, sqlcgen.CreateRecordingFullParams{
|
||||||
)
|
Name: l.getRecordingName(
|
||||||
if err != nil {
|
tags, result.absolutePath,
|
||||||
l.logger.Warn("could not upsert album artist", "err", err)
|
),
|
||||||
} else {
|
ArtistCreditID: artistCredit.ID,
|
||||||
_, _ = l.db.Queries.CreateArtistCreditArtist(
|
TrackNumber: toNullInt64(tags.TrackNumber),
|
||||||
l.ctx,
|
DiscNumber: toNullInt64(tags.DiscNumber),
|
||||||
sqlcgen.CreateArtistCreditArtistParams{
|
Year: toNullInt64(tags.Year),
|
||||||
ArtistID: albumArtist.ID,
|
Genre: toNullString(tags.Genre),
|
||||||
CreditID: albumArtistCredit.ID,
|
Composer: toNullString(tags.Composer),
|
||||||
},
|
Lyrics: toNullString(tags.Lyrics),
|
||||||
)
|
Comment: toNullString(tags.Comment),
|
||||||
}
|
},
|
||||||
}
|
)
|
||||||
} 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),
|
|
||||||
})
|
|
||||||
if err != nil {
|
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 {
|
if releaseGroupID.Valid {
|
||||||
_, err = l.db.Queries.CreateReleaseGroupRecording(
|
_, err = q.CreateReleaseGroupRecording(
|
||||||
l.ctx,
|
l.ctx,
|
||||||
sqlcgen.CreateReleaseGroupRecordingParams{
|
sqlcgen.CreateReleaseGroupRecordingParams{
|
||||||
ReleaseGroupID: releaseGroupID.Int64,
|
ReleaseGroupID: releaseGroupID.Int64,
|
||||||
@@ -615,13 +670,235 @@ func (l *Library) processMetadata(result importResult) (int64, error) {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
if err != nil {
|
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
|
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.
|
// getRecordingName returns the track title, or falls back to the filename.
|
||||||
func (l *Library) getRecordingName(tags *metadata.TrackMetadata, filePath string) string {
|
func (l *Library) getRecordingName(tags *metadata.TrackMetadata, filePath string) string {
|
||||||
if tags.Title != "" {
|
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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -50,3 +51,48 @@ func GetTrackLengthMillis(path string) (int64, error) {
|
|||||||
|
|
||||||
return lengthMillis, nil
|
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.
|
// EmitCurrentState emits the current queue state to the frontend.
|
||||||
// This is called after the frontend DOM is ready.
|
// This is called after the frontend DOM is ready.
|
||||||
func (q *Queue) EmitCurrentState() {
|
func (q *Queue) EmitCurrentState() {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import '@components/now-playing/now-playing.ts';
|
|||||||
import '@components/sidebar/app-sidebar.ts';
|
import '@components/sidebar/app-sidebar.ts';
|
||||||
import '@components/queue-panel/queue-panel.ts';
|
import '@components/queue-panel/queue-panel.ts';
|
||||||
import '@components/playlist-view/playlist-view.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/styles/themes/default.css';
|
||||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||||
import { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js';
|
import { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js';
|
||||||
@@ -36,6 +37,9 @@ document.addEventListener('navigate', (e: Event) => {
|
|||||||
case 'playlists':
|
case 'playlists':
|
||||||
mainContent.innerHTML = '<playlist-view></playlist-view>';
|
mainContent.innerHTML = '<playlist-view></playlist-view>';
|
||||||
break;
|
break;
|
||||||
|
case 'libraries':
|
||||||
|
mainContent.innerHTML = '<library-manager></library-manager>';
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
mainContent.innerHTML = `<div style="padding: 1em; color: #b3b3b3;">
|
mainContent.innerHTML = `<div style="padding: 1em; color: #b3b3b3;">
|
||||||
<p>Coming soon: ${view}</p>
|
<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",
|
RequestRemoveTracksFromQueue: "RequestRemoveTracksFromQueue",
|
||||||
|
|
||||||
// Library events
|
// Library events
|
||||||
|
LibraryScanStarted: "LibraryScanStarted",
|
||||||
LibraryScanComplete: "LibraryScanComplete",
|
LibraryScanComplete: "LibraryScanComplete",
|
||||||
} as const;
|
} 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 {library} from '../models';
|
||||||
import {context} from '../models';
|
import {context} from '../models';
|
||||||
|
|
||||||
|
export function FullRescan():Promise<void>;
|
||||||
|
|
||||||
export function GetAlbumTracks(arg1:number):Promise<Array<library.Track>>;
|
export function GetAlbumTracks(arg1:number):Promise<Array<library.Track>>;
|
||||||
|
|
||||||
export function GetAllAlbums():Promise<Array<library.Album>>;
|
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 Scan():Promise<void>;
|
||||||
|
|
||||||
export function SetContext(arg1:context.Context):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
|
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||||
// This file is automatically generated. DO NOT EDIT
|
// This file is automatically generated. DO NOT EDIT
|
||||||
|
|
||||||
|
export function FullRescan() {
|
||||||
|
return window['go']['library']['Library']['FullRescan']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetAlbumTracks(arg1) {
|
export function GetAlbumTracks(arg1) {
|
||||||
return window['go']['library']['Library']['GetAlbumTracks'](arg1);
|
return window['go']['library']['Library']['GetAlbumTracks'](arg1);
|
||||||
}
|
}
|
||||||
@@ -21,3 +25,7 @@ export function Scan() {
|
|||||||
export function SetContext(arg1) {
|
export function SetContext(arg1) {
|
||||||
return window['go']['library']['Library']['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 namespace library {
|
||||||
|
|
||||||
export class Album {
|
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 namespace playlist {
|
||||||
|
|
||||||
export class Summary {
|
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