navidrome/scanner/tag_scanner.go

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

474 lines
12 KiB
Go
Raw Normal View History

2020-01-16 22:53:48 +01:00
package scanner
import (
"context"
"crypto/md5"
"fmt"
"os"
"path/filepath"
"sort"
2020-01-16 22:53:48 +01:00
"strings"
"sync"
2020-01-16 22:53:48 +01:00
"time"
"github.com/deluan/navidrome/consts"
2020-01-24 01:44:08 +01:00
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/utils"
"github.com/kennygrant/sanitize"
2020-01-16 22:53:48 +01:00
)
type TagScanner struct {
rootFolder string
ds model.DataStore
detector *ChangeDetector
firstRun sync.Once
2020-01-16 22:53:48 +01:00
}
func NewTagScanner(rootFolder string, ds model.DataStore) *TagScanner {
2020-01-16 22:53:48 +01:00
return &TagScanner{
rootFolder: rootFolder,
ds: ds,
detector: NewChangeDetector(rootFolder),
firstRun: sync.Once{},
2020-01-16 22:53:48 +01:00
}
}
2020-06-11 23:36:09 +02:00
type (
2020-07-12 17:55:19 +02:00
artistMap map[string]struct{}
albumMap map[string]struct{}
2020-07-12 18:35:23 +02:00
counters struct {
added int64
updated int64
deleted int64
}
2020-06-11 23:36:09 +02:00
)
const (
// batchSize used for albums/artists updates
batchSize = 5
// filesBatchSize used for extract file metadata
filesBatchSize = 100
)
2020-01-16 22:53:48 +01:00
// Scan algorithm overview:
// For each changed folder: Get all files from DB that starts with the folder, scan each file:
// if file in folder is newer, update the one in DB
// if file in folder does not exists in DB, add
// for each file in the DB that is not found in the folder, delete from DB
// For each deleted folder: delete all files from DB that starts with the folder path
// Only on first run, check if any folder under each changed folder is missing.
// if it is, delete everything under it
// Create new albums/artists, update counters:
// collect all albumIDs and artistIDs from previous steps
// refresh the collected albums and artists with the metadata from the mediafiles
2020-01-16 22:53:48 +01:00
// Delete all empty albums, delete all empty Artists
func (s *TagScanner) Scan(ctx context.Context, lastModifiedSince time.Time) error {
start := time.Now()
2020-07-12 19:30:03 +02:00
log.Trace(ctx, "Looking for changes in music folder", "folder", s.rootFolder)
changed, deleted, err := s.detector.Scan(ctx, lastModifiedSince)
2020-01-16 22:53:48 +01:00
if err != nil {
return err
}
if len(changed)+len(deleted) == 0 {
log.Debug(ctx, "No changes found in Music Folder", "folder", s.rootFolder)
2020-01-16 22:53:48 +01:00
return nil
}
if log.CurrentLevel() >= log.LevelTrace {
log.Info(ctx, "Folder changes found", "numChanged", len(changed), "numDeleted", len(deleted),
"changed", strings.Join(changed, ";"), "deleted", strings.Join(deleted, ";"))
} else {
log.Info(ctx, "Folder changes found", "numChanged", len(changed), "numDeleted", len(deleted))
}
2020-01-16 22:53:48 +01:00
sort.Strings(changed)
sort.Strings(deleted)
2020-07-12 17:55:19 +02:00
updatedArtists := artistMap{}
updatedAlbums := albumMap{}
2020-07-12 18:35:23 +02:00
cnt := &counters{}
2020-01-16 22:53:48 +01:00
for _, c := range changed {
2020-07-12 18:35:23 +02:00
err := s.processChangedDir(ctx, c, updatedArtists, updatedAlbums, cnt)
2020-01-16 22:53:48 +01:00
if err != nil {
return err
}
}
for _, c := range deleted {
2020-07-12 18:35:23 +02:00
err := s.processDeletedDir(ctx, c, updatedArtists, updatedAlbums, cnt)
2020-01-16 22:53:48 +01:00
if err != nil {
return err
}
}
err = s.flushAlbums(ctx, updatedAlbums)
2020-01-16 22:53:48 +01:00
if err != nil {
return err
}
err = s.flushArtists(ctx, updatedArtists)
2020-01-16 22:53:48 +01:00
if err != nil {
return err
}
s.firstRun.Do(func() {
2020-07-12 18:35:23 +02:00
s.removeDeletedFolders(context.TODO(), changed, cnt)
})
err = s.ds.GC(log.NewContext(context.TODO()))
2020-07-12 18:35:23 +02:00
log.Info("Finished Music Folder", "folder", s.rootFolder, "elapsed", time.Since(start),
"added", cnt.added, "updated", cnt.updated, "deleted", cnt.deleted)
2020-01-18 05:28:11 +01:00
return err
2020-01-16 22:53:48 +01:00
}
2020-07-12 17:55:19 +02:00
func (s *TagScanner) flushAlbums(ctx context.Context, updatedAlbums albumMap) error {
if len(updatedAlbums) == 0 {
return nil
}
2020-01-16 22:53:48 +01:00
var ids []string
for id := range updatedAlbums {
ids = append(ids, id)
delete(updatedAlbums, id)
2020-01-16 22:53:48 +01:00
}
return s.ds.Album(ctx).Refresh(ids...)
2020-01-16 22:53:48 +01:00
}
2020-07-12 17:55:19 +02:00
func (s *TagScanner) flushArtists(ctx context.Context, updatedArtists artistMap) error {
if len(updatedArtists) == 0 {
return nil
}
2020-01-16 22:53:48 +01:00
var ids []string
for id := range updatedArtists {
ids = append(ids, id)
delete(updatedArtists, id)
2020-01-16 22:53:48 +01:00
}
return s.ds.Artist(ctx).Refresh(ids...)
2020-01-16 22:53:48 +01:00
}
2020-07-12 18:35:23 +02:00
func (s *TagScanner) processChangedDir(ctx context.Context, dir string, updatedArtists artistMap, updatedAlbums albumMap, cnt *counters) error {
dir = filepath.Join(s.rootFolder, dir)
2020-01-16 22:53:48 +01:00
start := time.Now()
// Load folder's current tracks from DB into a map
currentTracks := map[string]model.MediaFile{}
ct, err := s.ds.MediaFile(ctx).FindByPath(dir)
2020-01-16 22:53:48 +01:00
if err != nil {
return err
}
for _, t := range ct {
currentTracks[t.Path] = t
2020-01-16 22:53:48 +01:00
}
// Load tracks FileInfo from the folder
files, err := LoadAllAudioFiles(dir)
2020-01-16 22:53:48 +01:00
if err != nil {
return err
}
// If no files to process, return
if len(files)+len(currentTracks) == 0 {
return nil
}
// If track from folder is newer than the one in DB, select for update/insert in DB and delete from the current tracks
log.Trace("Processing changed folder", "dir", dir, "tracksInDB", len(currentTracks), "tracksInFolder", len(files))
var filesToUpdate []string
for filePath, info := range files {
c, ok := currentTracks[filePath]
2020-07-12 18:35:23 +02:00
if !ok {
filesToUpdate = append(filesToUpdate, filePath)
cnt.added++
}
if ok && info.ModTime().After(c.UpdatedAt) {
filesToUpdate = append(filesToUpdate, filePath)
2020-07-12 18:35:23 +02:00
cnt.updated++
}
delete(currentTracks, filePath)
// Force a refresh of the album and artist, to cater for cover art files. Ideally we would only do this
// if there are any image file in the folder (TODO)
err = s.updateAlbum(ctx, c.AlbumID, updatedAlbums)
if err != nil {
return err
}
err = s.updateArtist(ctx, c.AlbumArtistID, updatedArtists)
if err != nil {
return err
}
}
2020-01-16 22:53:48 +01:00
numUpdatedTracks := 0
numPurgedTracks := 0
if len(filesToUpdate) > 0 {
2020-06-11 23:36:09 +02:00
// Break the file list in chunks to avoid calling ffmpeg with too many parameters
chunks := utils.BreakUpStringSlice(filesToUpdate, filesBatchSize)
for _, chunk := range chunks {
// Load tracks Metadata from the folder
newTracks, err := s.loadTracks(chunk)
if err != nil {
return err
}
2020-06-11 23:36:09 +02:00
// If track from folder is newer than the one in DB, update/insert in DB
log.Trace("Updating mediaFiles in DB", "dir", dir, "files", chunk, "numFiles", len(chunk))
for i := range newTracks {
n := newTracks[i]
err := s.ds.MediaFile(ctx).Put(&n)
if err != nil {
return err
}
err = s.updateAlbum(ctx, n.AlbumID, updatedAlbums)
if err != nil {
return err
}
err = s.updateArtist(ctx, n.AlbumArtistID, updatedArtists)
if err != nil {
return err
}
numUpdatedTracks++
}
}
2020-01-16 22:53:48 +01:00
}
if len(currentTracks) > 0 {
log.Trace("Deleting dangling tracks from DB", "dir", dir, "numTracks", len(currentTracks))
// Remaining tracks from DB that are not in the folder are deleted
for _, ct := range currentTracks {
numPurgedTracks++
err = s.updateAlbum(ctx, ct.AlbumID, updatedAlbums)
if err != nil {
return err
}
err = s.updateArtist(ctx, ct.AlbumArtistID, updatedArtists)
if err != nil {
return err
}
if err := s.ds.MediaFile(ctx).Delete(ct.ID); err != nil {
return err
}
2020-07-12 18:35:23 +02:00
cnt.deleted++
2020-01-16 22:53:48 +01:00
}
}
log.Info("Finished processing changed folder", "dir", dir, "updated", numUpdatedTracks, "purged", numPurgedTracks, "elapsed", time.Since(start))
2020-01-16 22:53:48 +01:00
return nil
}
2020-07-12 17:55:19 +02:00
func (s *TagScanner) updateAlbum(ctx context.Context, albumId string, updatedAlbums albumMap) error {
updatedAlbums[albumId] = struct{}{}
2020-06-11 23:36:09 +02:00
if len(updatedAlbums) >= batchSize {
err := s.flushAlbums(ctx, updatedAlbums)
if err != nil {
return err
}
}
return nil
}
2020-07-12 17:55:19 +02:00
func (s *TagScanner) updateArtist(ctx context.Context, artistId string, updatedArtists artistMap) error {
updatedArtists[artistId] = struct{}{}
2020-06-11 23:36:09 +02:00
if len(updatedArtists) >= batchSize {
err := s.flushArtists(ctx, updatedArtists)
if err != nil {
return err
}
}
return nil
}
2020-07-12 18:35:23 +02:00
func (s *TagScanner) processDeletedDir(ctx context.Context, dir string, updatedArtists artistMap, updatedAlbums albumMap, cnt *counters) error {
dir = filepath.Join(s.rootFolder, dir)
start := time.Now()
2020-01-16 22:53:48 +01:00
2020-07-12 18:35:23 +02:00
mfs, err := s.ds.MediaFile(ctx).FindByPath(dir)
2020-01-16 22:53:48 +01:00
if err != nil {
return err
}
2020-07-12 18:35:23 +02:00
for _, t := range mfs {
err = s.updateAlbum(ctx, t.AlbumID, updatedAlbums)
if err != nil {
return err
}
err = s.updateArtist(ctx, t.AlbumArtistID, updatedArtists)
if err != nil {
return err
}
2020-01-16 22:53:48 +01:00
}
2020-07-12 18:35:23 +02:00
log.Info("Finished processing deleted folder", "dir", dir, "purged", len(mfs), "elapsed", time.Since(start))
c, err := s.ds.MediaFile(ctx).DeleteByPath(dir)
cnt.deleted += c
2020-07-12 17:48:57 +02:00
return err
2020-01-16 22:53:48 +01:00
}
2020-07-12 18:35:23 +02:00
func (s *TagScanner) removeDeletedFolders(ctx context.Context, changed []string, cnt *counters) {
for _, dir := range changed {
fullPath := filepath.Join(s.rootFolder, dir)
paths, err := s.ds.MediaFile(ctx).FindPathsRecursively(fullPath)
if err != nil {
log.Error(ctx, "Error reading paths from DB", "path", dir, err)
return
}
// If a path is unreadable, remove from the DB
for _, path := range paths {
if readable, err := utils.IsDirReadable(path); !readable {
log.Info(ctx, "Path unavailable. Removing tracks from DB", "path", path, err)
2020-07-12 18:35:23 +02:00
c, err := s.ds.MediaFile(ctx).DeleteByPath(path)
if err != nil {
log.Error(ctx, "Error removing MediaFiles from DB", "path", path, err)
}
2020-07-12 18:35:23 +02:00
cnt.deleted += c
}
}
}
}
2020-01-16 22:53:48 +01:00
func (s *TagScanner) loadTracks(filePaths []string) (model.MediaFiles, error) {
mds, err := ExtractAllMetadata(filePaths)
if err != nil {
return nil, err
}
var mfs model.MediaFiles
for _, md := range mds {
2020-01-16 22:53:48 +01:00
mf := s.toMediaFile(md)
mfs = append(mfs, mf)
2020-01-16 22:53:48 +01:00
}
return mfs, nil
2020-01-16 22:53:48 +01:00
}
func (s *TagScanner) toMediaFile(md *Metadata) model.MediaFile {
mf := &model.MediaFile{}
2020-01-16 22:53:48 +01:00
mf.ID = s.trackID(md)
mf.Title = s.mapTrackTitle(md)
mf.Album = md.Album()
mf.AlbumID = s.albumID(md)
mf.Album = s.mapAlbumName(md)
mf.ArtistID = s.artistID(md)
2020-03-25 23:51:13 +01:00
mf.Artist = s.mapArtistName(md)
mf.AlbumArtistID = s.albumArtistID(md)
mf.AlbumArtist = s.mapAlbumArtistName(md)
2020-01-16 22:53:48 +01:00
mf.Genre = md.Genre()
mf.Compilation = md.Compilation()
mf.Year = md.Year()
mf.TrackNumber, _ = md.TrackNumber()
mf.DiscNumber, _ = md.DiscNumber()
2020-05-12 17:17:22 +02:00
mf.DiscSubtitle = md.DiscSubtitle()
2020-01-16 22:53:48 +01:00
mf.Duration = md.Duration()
mf.BitRate = md.BitRate()
mf.Path = md.FilePath()
mf.Suffix = md.Suffix()
mf.Size = md.Size()
2020-01-16 22:53:48 +01:00
mf.HasCoverArt = md.HasPicture()
2020-04-24 16:13:59 +02:00
mf.SortTitle = md.SortTitle()
mf.SortAlbumName = md.SortAlbum()
mf.SortArtistName = md.SortArtist()
mf.SortAlbumArtistName = md.SortAlbumArtist()
mf.OrderAlbumName = sanitizeFieldForSorting(mf.Album)
mf.OrderArtistName = sanitizeFieldForSorting(mf.Artist)
mf.OrderAlbumArtistName = sanitizeFieldForSorting(mf.AlbumArtist)
2020-01-16 22:53:48 +01:00
// TODO Get Creation time. https://github.com/djherbis/times ?
mf.CreatedAt = md.ModificationTime()
mf.UpdatedAt = md.ModificationTime()
return *mf
}
func sanitizeFieldForSorting(originalValue string) string {
v := utils.NoArticle(originalValue)
v = strings.TrimSpace(sanitize.Accents(v))
return utils.NoArticle(v)
2020-01-16 22:53:48 +01:00
}
func (s *TagScanner) mapTrackTitle(md *Metadata) string {
if md.Title() == "" {
s := strings.TrimPrefix(md.FilePath(), s.rootFolder+string(os.PathSeparator))
e := filepath.Ext(s)
return strings.TrimSuffix(s, e)
}
return md.Title()
}
2020-03-25 23:51:13 +01:00
func (s *TagScanner) mapAlbumArtistName(md *Metadata) string {
2020-01-16 22:53:48 +01:00
switch {
case md.Compilation():
return consts.VariousArtists
2020-01-16 22:53:48 +01:00
case md.AlbumArtist() != "":
return md.AlbumArtist()
case md.Artist() != "":
return md.Artist()
default:
return consts.UnknownArtist
2020-01-16 22:53:48 +01:00
}
}
2020-03-25 23:51:13 +01:00
func (s *TagScanner) mapArtistName(md *Metadata) string {
if md.Artist() != "" {
return md.Artist()
}
return consts.UnknownArtist
}
2020-01-16 22:53:48 +01:00
func (s *TagScanner) mapAlbumName(md *Metadata) string {
name := md.Album()
if name == "" {
return "[Unknown Album]"
}
return name
}
func (s *TagScanner) trackID(md *Metadata) string {
return fmt.Sprintf("%x", md5.Sum([]byte(md.FilePath())))
}
func (s *TagScanner) albumID(md *Metadata) string {
2020-03-25 23:51:13 +01:00
albumPath := strings.ToLower(fmt.Sprintf("%s\\%s", s.mapAlbumArtistName(md), s.mapAlbumName(md)))
2020-01-16 22:53:48 +01:00
return fmt.Sprintf("%x", md5.Sum([]byte(albumPath)))
}
func (s *TagScanner) artistID(md *Metadata) string {
return fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(s.mapArtistName(md)))))
}
2020-03-25 23:51:13 +01:00
func (s *TagScanner) albumArtistID(md *Metadata) string {
return fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(s.mapAlbumArtistName(md)))))
}
func LoadAllAudioFiles(dirPath string) (map[string]os.FileInfo, error) {
dir, err := os.Open(dirPath)
if err != nil {
return nil, err
}
files, err := dir.Readdir(-1)
if err != nil {
return nil, err
}
audioFiles := make(map[string]os.FileInfo)
for _, f := range files {
if f.IsDir() {
continue
}
filePath := filepath.Join(dirPath, f.Name())
if !utils.IsAudioFile(filePath) {
continue
}
fi, err := os.Stat(filePath)
if err != nil {
log.Error("Could not stat file", "filePath", filePath, err)
} else {
audioFiles[filePath] = fi
}
}
return audioFiles, nil
}