package scanner import ( "context" "crypto/sha1" "database/sql" "encoding/hex" "encoding/binary" "fmt" "io/fs" "math" "os" "path/filepath" "slices" "strings" "sync" "time" "github.com/dhowden/tag" "github.com/hajimehoshi/go-mp3" "github.com/mewkiz/flac" ) var supportedExtensions = []string{ ".aac", ".flac", ".m4a", ".mp3", ".ogg", ".oga", ".opus", ".wav", ".wma", } type Result struct { Artists int `json:"artists"` Albums int `json:"albums"` Tracks int `json:"tracks"` } type Service struct { db *sql.DB mediaRoot string artworkRoot string mu sync.RWMutex status Status } type Status struct { Scanning bool `json:"scanning"` StartedAt time.Time `json:"startedAt,omitempty"` FinishedAt time.Time `json:"finishedAt,omitempty"` LastError string `json:"lastError,omitempty"` Artists int `json:"artists"` Albums int `json:"albums"` Tracks int `json:"tracks"` } type scannedArtist struct { ID string Name string } type scannedAlbum struct { ID string ArtistID string Title string Year int Genre string CoverArt string } type scannedTrack struct { ID string AlbumID string ArtistID string Title string TrackNumber int DiscNumber int DurationSecs int BitrateKbps int FilePath string ContentType string } func NewService(db *sql.DB, mediaRoot, artworkRoot string) *Service { return &Service{db: db, mediaRoot: mediaRoot, artworkRoot: artworkRoot} } func (s *Service) HasMediaFiles() bool { found := false _ = filepath.WalkDir(s.mediaRoot, func(path string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return nil } if isSupportedAudio(path) { found = true return fs.SkipAll } return nil }) return found } func (s *Service) Scan(ctx context.Context) (Result, error) { if !s.tryMarkStarted() { return Result{}, fmt.Errorf("scan already running") } return s.runScan(ctx) } func (s *Service) runScan(ctx context.Context) (Result, error) { if s.mediaRoot == "" { err := fmt.Errorf("media root is empty") s.markFailed(err) return Result{}, err } if _, err := os.Stat(s.mediaRoot); err != nil { wrapped := fmt.Errorf("media root unavailable: %w", err) s.markFailed(wrapped) return Result{}, wrapped } artists := map[string]scannedArtist{} albums := map[string]scannedAlbum{} var tracks []scannedTrack err := filepath.WalkDir(s.mediaRoot, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() || !isSupportedAudio(path) { return nil } item, err := s.scanFile(path) if err != nil { return nil } artists[item.artist.ID] = item.artist albums[item.album.ID] = item.album tracks = append(tracks, item.track) return nil }) if err != nil { wrapped := fmt.Errorf("walk media root: %w", err) s.markFailed(wrapped) return Result{}, wrapped } tx, err := s.db.BeginTx(ctx, nil) if err != nil { wrapped := fmt.Errorf("begin scan transaction: %w", err) s.markFailed(wrapped) return Result{}, wrapped } if _, err := tx.ExecContext(ctx, `DELETE FROM tracks`); err != nil { _ = tx.Rollback() wrapped := fmt.Errorf("clear tracks: %w", err) s.markFailed(wrapped) return Result{}, wrapped } if _, err := tx.ExecContext(ctx, `DELETE FROM albums`); err != nil { _ = tx.Rollback() wrapped := fmt.Errorf("clear albums: %w", err) s.markFailed(wrapped) return Result{}, wrapped } if _, err := tx.ExecContext(ctx, `DELETE FROM artists`); err != nil { _ = tx.Rollback() wrapped := fmt.Errorf("clear artists: %w", err) s.markFailed(wrapped) return Result{}, wrapped } now := time.Now().UTC().Format(time.RFC3339) artistKeys := make([]string, 0, len(artists)) for key := range artists { artistKeys = append(artistKeys, key) } slices.Sort(artistKeys) for _, key := range artistKeys { artist := artists[key] if _, err := tx.ExecContext( ctx, `INSERT INTO artists (id, name, sort_name, cover_art_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`, artist.ID, artist.Name, artist.Name, "", now, now, ); err != nil { _ = tx.Rollback() wrapped := fmt.Errorf("insert artist: %w", err) s.markFailed(wrapped) return Result{}, wrapped } } albumKeys := make([]string, 0, len(albums)) for key := range albums { albumKeys = append(albumKeys, key) } slices.Sort(albumKeys) for _, key := range albumKeys { album := albums[key] if _, err := tx.ExecContext( ctx, `INSERT INTO albums (id, artist_id, title, sort_title, year, genre, cover_art_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, album.ID, album.ArtistID, album.Title, album.Title, album.Year, album.Genre, album.CoverArt, now, now, ); err != nil { _ = tx.Rollback() wrapped := fmt.Errorf("insert album: %w", err) s.markFailed(wrapped) return Result{}, wrapped } } for _, track := range tracks { if _, err := tx.ExecContext( ctx, `INSERT INTO tracks (id, album_id, artist_id, title, track_number, disc_number, duration_seconds, bitrate_kbps, file_path, content_type, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, track.ID, track.AlbumID, track.ArtistID, track.Title, track.TrackNumber, track.DiscNumber, track.DurationSecs, track.BitrateKbps, track.FilePath, track.ContentType, now, now, ); err != nil { _ = tx.Rollback() wrapped := fmt.Errorf("insert track: %w", err) s.markFailed(wrapped) return Result{}, wrapped } } if err := tx.Commit(); err != nil { wrapped := fmt.Errorf("commit scan transaction: %w", err) s.markFailed(wrapped) return Result{}, wrapped } result := Result{ Artists: len(artists), Albums: len(albums), Tracks: len(tracks), } s.markFinished(result) return result, nil } func (s *Service) ScanAsync(ctx context.Context) bool { if !s.tryMarkStarted() { return false } go func() { _, _ = s.runScan(ctx) }() return true } func (s *Service) Status() Status { s.mu.RLock() defer s.mu.RUnlock() return s.status } type scannedItem struct { artist scannedArtist album scannedAlbum track scannedTrack } func (s *Service) scanFile(path string) (scannedItem, error) { file, err := os.Open(path) if err != nil { return scannedItem{}, err } defer file.Close() metadata, err := tag.ReadFrom(file) title := strings.TrimSpace(strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))) artistName := "Unknown Artist" albumTitle := "Unknown Album" genre := "" year := 0 trackNumber := 0 discNumber := 0 if err == nil { if value := strings.TrimSpace(metadata.Title()); value != "" { title = value } if value := strings.TrimSpace(metadata.Artist()); value != "" { artistName = value } if value := strings.TrimSpace(metadata.Album()); value != "" { albumTitle = value } if value := strings.TrimSpace(metadata.Genre()); value != "" { genre = value } year = metadata.Year() trackNumber, _ = metadata.Track() discNumber, _ = metadata.Disc() } artistID := hashID("artist", artistName) albumID := hashID("album", artistName, albumTitle, fmt.Sprintf("%d", year)) trackID := hashID("track", path) coverArt := "" if err == nil { coverArt = s.extractEmbeddedCoverArt(metadata, albumID) } if coverArt == "" { coverArt = findCoverArt(filepath.Dir(path)) } fileInfo, statErr := file.Stat() fileSize := int64(0) if statErr == nil { fileSize = fileInfo.Size() } contentType := detectContentType(path) durationSecs, bitrateKbps := scanAudioProperties(path, contentType, fileSize) return scannedItem{ artist: scannedArtist{ ID: artistID, Name: artistName, }, album: scannedAlbum{ ID: albumID, ArtistID: artistID, Title: albumTitle, Year: year, Genre: genre, CoverArt: coverArt, }, track: scannedTrack{ ID: trackID, AlbumID: albumID, ArtistID: artistID, Title: title, TrackNumber: trackNumber, DiscNumber: discNumber, DurationSecs: durationSecs, BitrateKbps: bitrateKbps, FilePath: path, ContentType: contentType, }, }, nil } func isSupportedAudio(path string) bool { ext := strings.ToLower(filepath.Ext(path)) for _, supported := range supportedExtensions { if ext == supported { return true } } return false } func detectContentType(path string) string { switch strings.ToLower(filepath.Ext(path)) { case ".mp3": return "audio/mpeg" case ".flac": return "audio/flac" case ".m4a": return "audio/mp4" case ".ogg", ".oga": return "audio/ogg" case ".opus": return "audio/ogg" case ".wav": return "audio/wav" case ".aac": return "audio/aac" case ".wma": return "audio/x-ms-wma" default: return "application/octet-stream" } } func hashID(parts ...string) string { sum := sha1.Sum([]byte(strings.Join(parts, "::"))) return hex.EncodeToString(sum[:]) } func findCoverArt(dir string) string { candidates := []string{ "cover.jpg", "cover.jpeg", "cover.png", "folder.jpg", "folder.jpeg", "folder.png", "front.jpg", "front.jpeg", "front.png", } for _, candidate := range candidates { path := filepath.Join(dir, candidate) if _, err := os.Stat(path); err == nil { return path } } return "" } func (s *Service) extractEmbeddedCoverArt(metadata tag.Metadata, albumID string) string { if metadata == nil { return "" } picture := metadata.Picture() if picture == nil || len(picture.Data) == 0 { return "" } if s.artworkRoot == "" { return "" } if err := os.MkdirAll(s.artworkRoot, 0o755); err != nil { return "" } filename := albumID + coverExtension(picture.MIMEType) path := filepath.Join(s.artworkRoot, filename) if err := os.WriteFile(path, picture.Data, 0o644); err != nil { return "" } return path } func coverExtension(mimeType string) string { switch strings.ToLower(mimeType) { case "image/png": return ".png" case "image/webp": return ".webp" default: return ".jpg" } } func scanAudioProperties(path, contentType string, fileSize int64) (int, int) { durationSecs := extractDurationSeconds(path, contentType) bitrateKbps := calculateBitrateKbps(fileSize, durationSecs) return durationSecs, bitrateKbps } func extractDurationSeconds(path, contentType string) int { switch strings.ToLower(filepath.Ext(path)) { case ".flac": return extractFLACDurationSeconds(path) case ".mp3": return extractMP3DurationSeconds(path) case ".wav": return extractWAVDurationSeconds(path) default: _ = contentType return 0 } } func extractFLACDurationSeconds(path string) int { stream, err := flac.ParseFile(path) if err != nil || stream == nil || stream.Info == nil || stream.Info.SampleRate == 0 || stream.Info.NSamples == 0 { return 0 } return int(math.Round(float64(stream.Info.NSamples) / float64(stream.Info.SampleRate))) } func extractMP3DurationSeconds(path string) int { file, err := os.Open(path) if err != nil { return 0 } defer file.Close() decoder, err := mp3.NewDecoder(file) if err != nil { return 0 } if decoder.SampleRate() == 0 || decoder.Length() <= 0 { return 0 } seconds := float64(decoder.Length()) / 4 / float64(decoder.SampleRate()) return int(math.Round(seconds)) } func extractWAVDurationSeconds(path string) int { file, err := os.Open(path) if err != nil { return 0 } defer file.Close() header := make([]byte, 12) if _, err := file.Read(header); err != nil { return 0 } if string(header[0:4]) != "RIFF" || string(header[8:12]) != "WAVE" { return 0 } var byteRate uint32 var dataSize uint32 for { chunkHeader := make([]byte, 8) if _, err := file.Read(chunkHeader); err != nil { break } chunkSize := binary.LittleEndian.Uint32(chunkHeader[4:8]) chunkID := string(chunkHeader[0:4]) switch chunkID { case "fmt ": if chunkSize < 16 { return 0 } chunkData := make([]byte, chunkSize) if _, err := file.Read(chunkData); err != nil { return 0 } byteRate = binary.LittleEndian.Uint32(chunkData[8:12]) case "data": dataSize = chunkSize if _, err := file.Seek(int64(chunkSize), 1); err != nil { return 0 } default: if _, err := file.Seek(int64(chunkSize), 1); err != nil { return 0 } } if chunkSize%2 == 1 { if _, err := file.Seek(1, 1); err != nil { return 0 } } if byteRate > 0 && dataSize > 0 { return int(math.Round(float64(dataSize) / float64(byteRate))) } } return 0 } func calculateBitrateKbps(fileSize int64, durationSecs int) int { if fileSize <= 0 || durationSecs <= 0 { return 0 } return int(math.Round(float64(fileSize*8) / float64(durationSecs) / 1000)) } func (s *Service) tryMarkStarted() bool { s.mu.Lock() defer s.mu.Unlock() if s.status.Scanning { return false } s.status.Scanning = true s.status.StartedAt = time.Now().UTC() s.status.FinishedAt = time.Time{} s.status.LastError = "" return true } func (s *Service) markFinished(result Result) { s.mu.Lock() defer s.mu.Unlock() s.status.Scanning = false s.status.FinishedAt = time.Now().UTC() s.status.LastError = "" s.status.Artists = result.Artists s.status.Albums = result.Albums s.status.Tracks = result.Tracks } func (s *Service) markFailed(err error) { s.mu.Lock() defer s.mu.Unlock() s.status.Scanning = false s.status.FinishedAt = time.Now().UTC() s.status.LastError = err.Error() }