Squash merge audio-player-component into main
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
// Package library manages the music library and its configuration.
|
||||
package library
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Config holds Library config data.
|
||||
type Config struct {
|
||||
DirectoryPath Directory `form:"Directory" schema:"directory,required"`
|
||||
}
|
||||
|
||||
// Directory represents a filesystem path to a music directory.
|
||||
type Directory string
|
||||
|
||||
// NewConfig creates a validated library configuration.
|
||||
func NewConfig(dir string) (*Config, error) {
|
||||
config := &Config{
|
||||
DirectoryPath: Directory(dir),
|
||||
}
|
||||
if err := config.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("validation error for new library config: %w", err)
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// Validate checks that the configured directory exists.
|
||||
func (c *Config) Validate() error {
|
||||
if len(c.DirectoryPath) != 0 {
|
||||
dirInfo, err := os.Stat(string(c.DirectoryPath))
|
||||
if err != nil {
|
||||
return fmt.Errorf("problem getting info on library dir (%s): %w", c.DirectoryPath, err)
|
||||
}
|
||||
|
||||
if !dirInfo.IsDir() {
|
||||
return fmt.Errorf("%s is not a directory", c.DirectoryPath)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package library
|
||||
|
||||
templ (d Directory) ToFormElement() {
|
||||
<script>
|
||||
function selectLibraryDirectory(pElement) {
|
||||
try {
|
||||
window.DirectoryPicker()
|
||||
.then((result) => {
|
||||
if (result.length != 0) {
|
||||
pElement.value = result;
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("error with directory picker: " + err);
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
function scanLibrary(button) {
|
||||
button.disabled = true;
|
||||
button.textContent = "Scanning...";
|
||||
window.Scan()
|
||||
.then(() => {
|
||||
button.textContent = "Scan Library";
|
||||
button.disabled = false;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("error scanning library: " + err);
|
||||
button.textContent = "Scan Library";
|
||||
button.disabled = false;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<button type="button" onclick="selectLibraryDirectory(this.nextElementSibling)">Select</button>
|
||||
<input type="text" name="library.directory" value={ d } readonly/>
|
||||
<button type="button" onclick="scanLibrary(this)">Scan Library</button>
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.865
|
||||
package library
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
func (d Directory) ToFormElement() templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<script>\n function selectLibraryDirectory(pElement) {\n try {\n window.DirectoryPicker()\n .then((result) => {\n if (result.length != 0) {\n pElement.value = result;\n }\n })\n .catch((err) => {\n console.error(\"error with directory picker: \" + err);\n });\n }\n catch (err) {\n console.error(err);\n }\n }\n function scanLibrary(button) {\n button.disabled = true;\n button.textContent = \"Scanning...\";\n window.Scan()\n .then(() => {\n button.textContent = \"Scan Library\";\n button.disabled = false;\n })\n .catch((err) => {\n console.error(\"error scanning library: \" + err);\n button.textContent = \"Scan Library\";\n button.disabled = false;\n });\n }\n </script><button type=\"button\" onclick=\"selectLibraryDirectory(this.nextElementSibling)\">Select</button> <input type=\"text\" name=\"library.directory\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(d)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `library/config.templ`, Line: 37, Col: 54}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" readonly> <button type=\"button\" onclick=\"scanLibrary(this)\">Scan Library</button>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
@@ -0,0 +1,80 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"yellowjacket/backend/metadata"
|
||||
"yellowjacket/backend/system"
|
||||
)
|
||||
|
||||
// saveCoverArt saves embedded cover art to the cache directory.
|
||||
// Returns the file path where the art was saved, or empty string if no picture data.
|
||||
func (l *Library) saveCoverArt(pic *metadata.PictureData) (string, error) {
|
||||
if pic == nil || len(pic.Data) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Get the data directory for storing cover art
|
||||
dataDir, err := system.GetUserDataDirPath()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not get user data directory: %w", err)
|
||||
}
|
||||
|
||||
coverDir := filepath.Join(dataDir, "covers")
|
||||
|
||||
// Ensure directory exists
|
||||
if err := os.MkdirAll(coverDir, 0o755); err != nil {
|
||||
return "", fmt.Errorf("could not create covers directory: %w", err)
|
||||
}
|
||||
|
||||
// Generate filename from content hash (deduplication)
|
||||
hash := sha256.Sum256(pic.Data)
|
||||
hashStr := hex.EncodeToString(hash[:8]) // First 8 bytes = 16 hex chars
|
||||
|
||||
ext := pic.Ext
|
||||
if ext == "" {
|
||||
// Determine extension from MIME type
|
||||
ext = extensionFromMIME(pic.MIMEType)
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("%s.%s", hashStr, ext)
|
||||
filePath := filepath.Join(coverDir, filename)
|
||||
|
||||
// Skip if already exists (same content hash)
|
||||
if _, err := os.Stat(filePath); err == nil {
|
||||
l.logger.Debug("cover art already exists", "path", filePath)
|
||||
|
||||
return filePath, nil
|
||||
}
|
||||
|
||||
// Write file
|
||||
if err := os.WriteFile(filePath, pic.Data, 0o644); err != nil {
|
||||
return "", fmt.Errorf("could not write cover art: %w", err)
|
||||
}
|
||||
|
||||
l.logger.Debug("saved cover art", "path", filePath, "size", len(pic.Data))
|
||||
|
||||
return filePath, nil
|
||||
}
|
||||
|
||||
// extensionFromMIME returns a file extension for common image MIME types.
|
||||
func extensionFromMIME(mimeType string) string {
|
||||
switch mimeType {
|
||||
case "image/jpeg":
|
||||
return "jpg"
|
||||
case "image/png":
|
||||
return "png"
|
||||
case "image/gif":
|
||||
return "gif"
|
||||
case "image/webp":
|
||||
return "webp"
|
||||
case "image/bmp":
|
||||
return "bmp"
|
||||
default:
|
||||
return "jpg" // Default to jpg
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
|
||||
"yellowjacket/backend/system"
|
||||
)
|
||||
|
||||
// CoverArtHandler serves cover art images via HTTP.
|
||||
type CoverArtHandler struct {
|
||||
coversDir string
|
||||
}
|
||||
|
||||
// NewCoverArtHandler creates a handler that serves cover art from the user data directory.
|
||||
func NewCoverArtHandler() (*CoverArtHandler, error) {
|
||||
dataDir, err := system.GetUserDataDirPath()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not get user data directory: %w", err)
|
||||
}
|
||||
|
||||
return &CoverArtHandler{
|
||||
coversDir: filepath.Join(dataDir, "covers"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ServeHTTP handles requests for cover art images.
|
||||
func (h *CoverArtHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Extract filename from path like "/covers/abc123.jpg"
|
||||
filename := filepath.Base(r.URL.Path)
|
||||
|
||||
// Prevent directory traversal
|
||||
if filename == "." || filename == ".." {
|
||||
http.NotFound(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
filePath := filepath.Join(h.coversDir, filename)
|
||||
http.ServeFile(w, r, filePath)
|
||||
}
|
||||
+612
-34
@@ -2,62 +2,640 @@ package library
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
goruntime "runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"yellowjacket/backend/database"
|
||||
"yellowjacket/backend/database/sql/sqlcgen"
|
||||
"yellowjacket/backend/events"
|
||||
"yellowjacket/backend/metadata"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
DirectoryPath string
|
||||
SaveFunc func() error `toml:"-"`
|
||||
}
|
||||
|
||||
func (c *Config) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
var DefaultConfig *Config = &Config{
|
||||
DirectoryPath: "",
|
||||
}
|
||||
|
||||
// Library manages scanning and querying the music collection.
|
||||
type Library struct {
|
||||
ctx context.Context
|
||||
conf *Config
|
||||
ctx context.Context
|
||||
logger *slog.Logger
|
||||
conf *Config
|
||||
db *database.DB
|
||||
}
|
||||
|
||||
func NewLibrary(conf *Config) (*Library, error) {
|
||||
// NewLibrary creates a new library with the given configuration.
|
||||
func NewLibrary(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
conf *Config,
|
||||
db *database.DB,
|
||||
) (*Library, error) {
|
||||
if conf == nil {
|
||||
return nil, fmt.Errorf("nil config for library")
|
||||
return nil, errors.New("nil config for library")
|
||||
}
|
||||
|
||||
if err := conf.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid library config %#v: %w", conf, err)
|
||||
}
|
||||
return &Library{
|
||||
conf: conf,
|
||||
}, nil
|
||||
|
||||
library := &Library{
|
||||
ctx: ctx,
|
||||
logger: logger,
|
||||
conf: conf,
|
||||
db: db,
|
||||
}
|
||||
|
||||
return library, nil
|
||||
}
|
||||
|
||||
func (l *Library) Init(ctx context.Context) error {
|
||||
// SetContext sets the Wails runtime context and registers event handlers.
|
||||
func (l *Library) SetContext(ctx context.Context) {
|
||||
l.ctx = ctx
|
||||
l.registerEventHandlers()
|
||||
}
|
||||
|
||||
func (l *Library) registerEventHandlers() {
|
||||
if l.ctx == nil {
|
||||
l.logger.Error("Context is nil, cannot register event handlers")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
runtime.EventsOn(l.ctx, events.LibraryConfigChanged, func(data ...any) {
|
||||
l.logger.Info("Received LibraryConfigChanged event")
|
||||
|
||||
if len(data) == 0 {
|
||||
l.logger.Error("LibraryConfigChanged event received with no data")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
configMap, ok := data[0].(map[string]any)
|
||||
if !ok {
|
||||
l.logger.Error("LibraryConfigChanged event data is not a map", "data", data[0])
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
dir, ok := configMap["DirectoryPath"].(string)
|
||||
if !ok {
|
||||
l.logger.Error("DirectoryPath not found or not a string in config event")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
updatedConfig := Config{DirectoryPath: Directory(dir)}
|
||||
if err := l.handleConfigUpdate(updatedConfig); err != nil {
|
||||
l.logger.Error("Failed to handle config update", "err", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Scan syncs the library by adding new files and removing deleted ones.
|
||||
// Files that exist but have incomplete metadata (recording_id = 0) will be updated.
|
||||
func (l *Library) Scan() error {
|
||||
l.logger.Info("beginning library scan", "workers", scanWorkerCount)
|
||||
|
||||
if len(l.conf.DirectoryPath) == 0 {
|
||||
return errors.New("library directory not configured")
|
||||
}
|
||||
|
||||
// Load existing file paths from the database into a sync.Map for concurrent access.
|
||||
// The map tracks path → audioFile; entries are removed as files are "seen" during the walk.
|
||||
// Any entries remaining after the walk are orphans (files deleted from disk).
|
||||
existingFiles, err := l.db.Queries.GetAllAudioFiles(l.ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not load existing audio files: %w", err)
|
||||
}
|
||||
|
||||
existingPaths := &sync.Map{}
|
||||
for _, f := range existingFiles {
|
||||
existingPaths.Store(f.FilePath, f)
|
||||
}
|
||||
|
||||
l.logger.Debug(
|
||||
"loaded existing files from database",
|
||||
"count", len(existingFiles),
|
||||
"library-directory", l.conf.DirectoryPath,
|
||||
)
|
||||
|
||||
basePath := string(l.conf.DirectoryPath)
|
||||
workChan := make(chan scanWork, 100)
|
||||
resultChan := make(chan importResult, 100)
|
||||
|
||||
var added, skipped, updated atomic.Int64
|
||||
|
||||
var scanErr error
|
||||
|
||||
var errMu sync.Mutex
|
||||
|
||||
// Walker goroutine: traverse directory and send work items to workers
|
||||
go func() {
|
||||
defer close(workChan)
|
||||
|
||||
walkErr := fs.WalkDir(
|
||||
os.DirFS(basePath),
|
||||
".",
|
||||
func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
l.logger.Error("problem walking directory", "path", path, "err", err)
|
||||
|
||||
return nil // continue walking
|
||||
}
|
||||
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
absoluteFilePath := filepath.Join(basePath, path)
|
||||
fileExt := filepath.Ext(d.Name())
|
||||
|
||||
fileType, isSupportedAudioFile := metadata.GetSupportedFileType(fileExt)
|
||||
if !isSupportedAudioFile {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if file already exists in database
|
||||
if existing, exists := existingPaths.LoadAndDelete(absoluteFilePath); exists {
|
||||
audioFile := existing.(sqlcgen.AudioFile)
|
||||
|
||||
// Check if this file needs metadata update (recording_id = 0)
|
||||
if audioFile.RecordingID == 0 {
|
||||
l.logger.Debug("file needs metadata update", "path", absoluteFilePath)
|
||||
|
||||
select {
|
||||
case workChan <- scanWork{
|
||||
absolutePath: absoluteFilePath,
|
||||
fileType: fileType,
|
||||
existingFileID: audioFile.ID,
|
||||
needsUpdate: true,
|
||||
existingLength: audioFile.LengthMilliseconds,
|
||||
}:
|
||||
case <-l.ctx.Done():
|
||||
return l.ctx.Err()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
l.logger.Debug(
|
||||
"file already in library with metadata, skipping",
|
||||
"path",
|
||||
absoluteFilePath,
|
||||
)
|
||||
skipped.Add(1)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
l.logger.Debug("queueing file for import", "path", absoluteFilePath)
|
||||
|
||||
// Send to workers for processing
|
||||
select {
|
||||
case workChan <- scanWork{absolutePath: absoluteFilePath, fileType: fileType}:
|
||||
case <-l.ctx.Done():
|
||||
return l.ctx.Err()
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if walkErr != nil {
|
||||
errMu.Lock()
|
||||
scanErr = errors.Join(
|
||||
scanErr,
|
||||
fmt.Errorf("problem walking library directory: %w", walkErr),
|
||||
)
|
||||
errMu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
// DB writer goroutine: serialize all database writes to avoid SQLite contention
|
||||
var dbWg sync.WaitGroup
|
||||
|
||||
dbWg.Add(1)
|
||||
|
||||
go func() {
|
||||
defer dbWg.Done()
|
||||
|
||||
for result := range resultChan {
|
||||
var saveErr error
|
||||
|
||||
if result.needsUpdate {
|
||||
saveErr = l.updateAudioFileMetadata(result)
|
||||
if saveErr == nil {
|
||||
updated.Add(1)
|
||||
}
|
||||
} else {
|
||||
saveErr = l.saveAudioFile(result)
|
||||
if saveErr == nil {
|
||||
added.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
if saveErr != nil {
|
||||
l.logger.Warn(
|
||||
"failed to save audio file",
|
||||
"path",
|
||||
result.absolutePath,
|
||||
"err",
|
||||
saveErr,
|
||||
)
|
||||
|
||||
errMu.Lock()
|
||||
scanErr = errors.Join(scanErr, saveErr)
|
||||
errMu.Unlock()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Worker pool: extract metadata concurrently, send results to DB writer
|
||||
g := new(errgroup.Group)
|
||||
g.SetLimit(scanWorkerCount)
|
||||
|
||||
for work := range workChan {
|
||||
g.Go(func() error {
|
||||
result, err := l.extractAudioMetadata(work)
|
||||
if err != nil {
|
||||
l.logger.Warn("failed to extract metadata", "path", work.absolutePath, "err", err)
|
||||
|
||||
errMu.Lock()
|
||||
scanErr = errors.Join(scanErr, err)
|
||||
errMu.Unlock()
|
||||
|
||||
return nil // continue processing other files
|
||||
}
|
||||
|
||||
// Send to DB writer
|
||||
select {
|
||||
case resultChan <- result:
|
||||
case <-l.ctx.Done():
|
||||
return l.ctx.Err()
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
_ = g.Wait() // Wait for all metadata extraction to complete
|
||||
|
||||
close(resultChan) // Signal DB writer to finish
|
||||
dbWg.Wait() // Wait for all DB writes to complete
|
||||
|
||||
// Orphan cleanup: any entries remaining in existingPaths are files deleted from disk
|
||||
var removed atomic.Int64
|
||||
|
||||
existingPaths.Range(func(key, value any) bool {
|
||||
path := key.(string)
|
||||
audioFile := value.(sqlcgen.AudioFile)
|
||||
|
||||
l.logger.Debug("removing orphaned database entry", "path", path, "id", audioFile.ID)
|
||||
|
||||
if err := l.db.Queries.DeleteAudioFile(l.ctx, audioFile.ID); err != nil {
|
||||
l.logger.Warn(
|
||||
"failed to delete orphaned audio file",
|
||||
"path", path,
|
||||
"id", audioFile.ID,
|
||||
"err", err,
|
||||
)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
removed.Add(1)
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
l.logger.Info(
|
||||
"library scan complete",
|
||||
"added", added.Load(),
|
||||
"updated", updated.Load(),
|
||||
"removed", removed.Load(),
|
||||
"skipped", skipped.Load(),
|
||||
"library", l.conf.DirectoryPath,
|
||||
)
|
||||
|
||||
return scanErr
|
||||
}
|
||||
|
||||
// scanWorkerCount controls the number of concurrent file processors.
|
||||
// TODO: make configurable via Config.
|
||||
var scanWorkerCount = goruntime.NumCPU()
|
||||
|
||||
// scanWork represents a file to be processed by a worker.
|
||||
type scanWork struct {
|
||||
absolutePath string
|
||||
fileType metadata.AudioFileExtension
|
||||
existingFileID int64 // non-zero if this is an update
|
||||
needsUpdate bool
|
||||
existingLength int64 // existing length if updating
|
||||
}
|
||||
|
||||
// importResult holds metadata extracted by workers, ready for DB insertion.
|
||||
type importResult struct {
|
||||
absolutePath string
|
||||
fileType metadata.AudioFileExtension
|
||||
lengthMillis int64
|
||||
tags *metadata.TrackMetadata
|
||||
existingFileID int64 // non-zero if this is an update
|
||||
needsUpdate bool
|
||||
}
|
||||
|
||||
// extractAudioMetadata reads and extracts metadata from an audio file.
|
||||
func (l *Library) extractAudioMetadata(work scanWork) (importResult, error) {
|
||||
result := importResult{
|
||||
absolutePath: work.absolutePath,
|
||||
fileType: work.fileType,
|
||||
existingFileID: work.existingFileID,
|
||||
needsUpdate: work.needsUpdate,
|
||||
}
|
||||
|
||||
// Get duration (skip if updating and we already have it)
|
||||
if work.needsUpdate && work.existingLength > 0 {
|
||||
result.lengthMillis = work.existingLength
|
||||
} else {
|
||||
trackLengthMillis, err := metadata.GetTrackLengthMillis(work.absolutePath)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf(
|
||||
"could not get track length for %s: %w",
|
||||
work.absolutePath,
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
result.lengthMillis = trackLengthMillis
|
||||
}
|
||||
|
||||
// Extract tags
|
||||
tags, err := metadata.ExtractTags(work.absolutePath)
|
||||
if err != nil {
|
||||
l.logger.Warn("could not extract tags", "path", work.absolutePath, "err", err)
|
||||
// Continue with empty tags - not a fatal error
|
||||
tags = &metadata.TrackMetadata{}
|
||||
}
|
||||
|
||||
result.tags = tags
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// saveAudioFile writes audio file metadata to the database (new files).
|
||||
func (l *Library) saveAudioFile(result importResult) error {
|
||||
l.logger.Debug(
|
||||
"saving audio file to db",
|
||||
"absolute-path", result.absolutePath,
|
||||
"track-length-millis", result.lengthMillis,
|
||||
"file-type", int64(slices.Index(metadata.SupportedFileExtensions, result.fileType)),
|
||||
)
|
||||
|
||||
// Process metadata and create related records
|
||||
recordingID, err := l.processMetadata(result)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not process metadata: %w", err)
|
||||
}
|
||||
|
||||
if _, err := l.db.Queries.CreateAudioFile(
|
||||
l.ctx, sqlcgen.CreateAudioFileParams{
|
||||
FilePath: result.absolutePath,
|
||||
LengthMilliseconds: result.lengthMillis,
|
||||
FileTypeID: int64(
|
||||
slices.Index(metadata.SupportedFileExtensions, result.fileType),
|
||||
),
|
||||
RecordingID: recordingID,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("could not save audio file to db: %w", err)
|
||||
}
|
||||
|
||||
l.logger.Debug("added audio file to library", "path", result.absolutePath)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *Library) GetDir() (string, error) {
|
||||
return l.conf.DirectoryPath, nil
|
||||
}
|
||||
// updateAudioFileMetadata updates an existing audio file with extracted metadata.
|
||||
func (l *Library) updateAudioFileMetadata(result importResult) error {
|
||||
l.logger.Debug(
|
||||
"updating audio file metadata",
|
||||
"absolute-path", result.absolutePath,
|
||||
"file-id", result.existingFileID,
|
||||
)
|
||||
|
||||
func (l *Library) SetDir(dirPath string) error {
|
||||
fileInfo, err := os.Stat(dirPath)
|
||||
// Process metadata and create related records
|
||||
recordingID, err := l.processMetadata(result)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not stat %s: %w", dirPath, err)
|
||||
}
|
||||
if !fileInfo.IsDir() {
|
||||
return fmt.Errorf("dirPath is not a directory: %s", dirPath)
|
||||
return fmt.Errorf("could not process metadata: %w", err)
|
||||
}
|
||||
|
||||
l.conf.DirectoryPath = dirPath
|
||||
err = l.conf.SaveFunc()
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not save library dir config: %w", err)
|
||||
if err := l.db.Queries.UpdateAudioFileRecording(
|
||||
l.ctx, sqlcgen.UpdateAudioFileRecordingParams{
|
||||
RecordingID: recordingID,
|
||||
ID: result.existingFileID,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("could not update audio file recording: %w", err)
|
||||
}
|
||||
|
||||
l.logger.Debug("updated audio file metadata", "path", result.absolutePath)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// processMetadata creates all related database records for metadata and returns the recording ID.
|
||||
func (l *Library) processMetadata(result importResult) (int64, error) {
|
||||
tags := result.tags
|
||||
if tags == nil {
|
||||
tags = &metadata.TrackMetadata{}
|
||||
}
|
||||
|
||||
// 1. Handle cover art (if present)
|
||||
var coverArtID sql.NullInt64
|
||||
|
||||
if tags.Picture != nil {
|
||||
coverPath, err := l.saveCoverArt(tags.Picture)
|
||||
if err != nil {
|
||||
l.logger.Warn("could not save cover art", "err", err)
|
||||
} else if coverPath != "" {
|
||||
// Use upsert to avoid duplicates
|
||||
ca, err := l.db.Queries.UpsertCoverArt(l.ctx, sqlcgen.UpsertCoverArtParams{
|
||||
IsEmbedded: true,
|
||||
FilePath: coverPath,
|
||||
MimeType: tags.Picture.MIMEType,
|
||||
})
|
||||
if err != nil {
|
||||
l.logger.Warn("could not create cover art record", "err", err)
|
||||
} else {
|
||||
coverArtID = sql.NullInt64{Int64: ca.ID, Valid: true}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Get or create artist credit for track artist
|
||||
artistName := tags.Artist
|
||||
if artistName == "" {
|
||||
artistName = "Unknown Artist"
|
||||
}
|
||||
|
||||
artistCredit, err := l.db.Queries.UpsertArtistCredit(l.ctx, artistName)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("could not upsert artist credit: %w", err)
|
||||
}
|
||||
|
||||
// Also create the artist record and link (best effort)
|
||||
artist, err := l.db.Queries.UpsertArtist(l.ctx, artistName)
|
||||
if err != nil {
|
||||
l.logger.Warn("could not upsert artist", "err", err)
|
||||
} else {
|
||||
// Link artist to credit (ignore error if already linked)
|
||||
_, _ = l.db.Queries.CreateArtistCreditArtist(l.ctx, sqlcgen.CreateArtistCreditArtistParams{
|
||||
ArtistID: artist.ID,
|
||||
CreditID: artistCredit.ID,
|
||||
})
|
||||
}
|
||||
|
||||
// 3. Get or create artist credit for album artist (if different)
|
||||
var albumArtistCreditID sql.NullInt64
|
||||
|
||||
if tags.AlbumArtist != "" && tags.AlbumArtist != tags.Artist {
|
||||
albumArtistCredit, err := l.db.Queries.UpsertArtistCredit(l.ctx, tags.AlbumArtist)
|
||||
if err != nil {
|
||||
l.logger.Warn("could not upsert album artist credit", "err", err)
|
||||
} else {
|
||||
albumArtistCreditID = sql.NullInt64{Int64: albumArtistCredit.ID, Valid: true}
|
||||
|
||||
// Also create the artist record and link
|
||||
albumArtist, err := l.db.Queries.UpsertArtist(l.ctx, tags.AlbumArtist)
|
||||
if err != nil {
|
||||
l.logger.Warn("could not upsert album artist", "err", err)
|
||||
} else {
|
||||
_, _ = l.db.Queries.CreateArtistCreditArtist(
|
||||
l.ctx,
|
||||
sqlcgen.CreateArtistCreditArtistParams{
|
||||
ArtistID: albumArtist.ID,
|
||||
CreditID: albumArtistCredit.ID,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return 0, fmt.Errorf("could not create recording: %w", err)
|
||||
}
|
||||
|
||||
// 6. Link recording to release group
|
||||
if releaseGroupID.Valid {
|
||||
_, err = l.db.Queries.CreateReleaseGroupRecording(
|
||||
l.ctx,
|
||||
sqlcgen.CreateReleaseGroupRecordingParams{
|
||||
ReleaseGroupID: releaseGroupID.Int64,
|
||||
RecordingID: recording.ID,
|
||||
TrackNumber: toNullInt64(tags.TrackNumber),
|
||||
DiscNumber: toNullInt64(tags.DiscNumber),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
l.logger.Warn("could not link recording to release group", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
return recording.ID, nil
|
||||
}
|
||||
|
||||
// getRecordingName returns the track title, or falls back to the filename.
|
||||
func (l *Library) getRecordingName(tags *metadata.TrackMetadata, filePath string) string {
|
||||
if tags.Title != "" {
|
||||
return tags.Title
|
||||
}
|
||||
// Fallback to filename without extension
|
||||
base := filepath.Base(filePath)
|
||||
|
||||
return strings.TrimSuffix(base, filepath.Ext(base))
|
||||
}
|
||||
|
||||
// toNullInt64 converts an int to sql.NullInt64, treating 0 as null.
|
||||
func toNullInt64(v int) sql.NullInt64 {
|
||||
if v == 0 {
|
||||
return sql.NullInt64{}
|
||||
}
|
||||
|
||||
return sql.NullInt64{Int64: int64(v), Valid: true}
|
||||
}
|
||||
|
||||
// toNullString converts a string to sql.NullString, treating empty as null.
|
||||
func toNullString(v string) sql.NullString {
|
||||
if v == "" {
|
||||
return sql.NullString{}
|
||||
}
|
||||
|
||||
return sql.NullString{String: v, Valid: true}
|
||||
}
|
||||
|
||||
func (l *Library) handleConfigUpdate(updatedConfigValues Config) error {
|
||||
l.logger.Info("handling config update", "updated", updatedConfigValues)
|
||||
|
||||
var updateErr error
|
||||
|
||||
if l.conf.DirectoryPath != updatedConfigValues.DirectoryPath {
|
||||
l.logger.Info("new library, scanning")
|
||||
|
||||
l.conf.DirectoryPath = updatedConfigValues.DirectoryPath
|
||||
if err := l.Scan(); err != nil {
|
||||
updateErr = errors.Join(
|
||||
updateErr,
|
||||
fmt.Errorf("problem scanning library on config update: %w", err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return updateErr
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Track represents a playable audio file in the library.
|
||||
type Track struct {
|
||||
TrackName string
|
||||
ArtistName string
|
||||
TrackLength string
|
||||
FilePath string
|
||||
}
|
||||
|
||||
// Album represents an album for the cover grid display.
|
||||
type Album struct {
|
||||
ID int64
|
||||
Name string
|
||||
ArtistName string
|
||||
CoverArtPath string
|
||||
Year int64
|
||||
}
|
||||
|
||||
// GetAllTracks returns an array of track structs of every file in the library.
|
||||
func (l *Library) GetAllTracks() ([]Track, error) {
|
||||
audioFiles, err := l.db.Queries.GetAllAudioFilesWithArtist(l.ctx)
|
||||
if err != nil {
|
||||
l.logger.Error("could not retrieve audio files", "error", err)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
l.logger.Info("audio file list", "count", len(audioFiles))
|
||||
|
||||
if len(audioFiles) == 0 {
|
||||
l.logger.Error("no tracks in library")
|
||||
|
||||
return nil, errors.New("no tracks in library")
|
||||
}
|
||||
|
||||
var formattedTracks []Track
|
||||
|
||||
for _, file := range audioFiles {
|
||||
track := Track{
|
||||
TrackName: file.Title,
|
||||
ArtistName: file.ArtistName,
|
||||
TrackLength: strconv.FormatInt(file.LengthMilliseconds, 10),
|
||||
FilePath: file.FilePath,
|
||||
}
|
||||
formattedTracks = append(formattedTracks, track)
|
||||
}
|
||||
|
||||
l.logger.Info("formatted tracks", "count", len(formattedTracks))
|
||||
|
||||
return formattedTracks, nil
|
||||
}
|
||||
|
||||
// GetAlbumTracks returns all tracks for a given album (release group), ordered by disc and track number.
|
||||
func (l *Library) GetAlbumTracks(albumID int64) ([]Track, error) {
|
||||
rows, err := l.db.Queries.GetAudioFilesByReleaseGroup(l.ctx, albumID)
|
||||
if err != nil {
|
||||
l.logger.Error("could not retrieve album tracks", "albumID", albumID, "error", err)
|
||||
|
||||
return nil, fmt.Errorf("could not get album tracks: %w", err)
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
return nil, fmt.Errorf("no tracks found for album %d", albumID)
|
||||
}
|
||||
|
||||
tracks := make([]Track, 0, len(rows))
|
||||
|
||||
for _, row := range rows {
|
||||
tracks = append(tracks, Track{
|
||||
TrackName: row.Title,
|
||||
ArtistName: row.ArtistName,
|
||||
TrackLength: strconv.FormatInt(row.LengthMilliseconds, 10),
|
||||
FilePath: row.FilePath,
|
||||
})
|
||||
}
|
||||
|
||||
return tracks, nil
|
||||
}
|
||||
|
||||
// GetAllAlbums returns all albums with cover art and artist info for the cover grid.
|
||||
func (l *Library) GetAllAlbums() ([]Album, error) {
|
||||
rows, err := l.db.Queries.GetAllAlbumsWithDetails(l.ctx)
|
||||
if err != nil {
|
||||
l.logger.Error("could not retrieve albums", "error", err)
|
||||
|
||||
return nil, fmt.Errorf("could not get albums: %w", err)
|
||||
}
|
||||
|
||||
l.logger.Info("album list", "count", len(rows))
|
||||
|
||||
albums := make([]Album, 0, len(rows))
|
||||
|
||||
for _, row := range rows {
|
||||
album := Album{
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
ArtistName: row.ArtistName,
|
||||
}
|
||||
|
||||
if row.Year.Valid {
|
||||
album.Year = row.Year.Int64
|
||||
}
|
||||
|
||||
// Convert filesystem path to URL path for the asset handler
|
||||
if row.CoverArtPath != "" {
|
||||
album.CoverArtPath = "/covers/" + filepath.Base(row.CoverArtPath)
|
||||
}
|
||||
|
||||
albums = append(albums, album)
|
||||
}
|
||||
|
||||
return albums, nil
|
||||
}
|
||||
Reference in New Issue
Block a user