184 lines
3.3 KiB
Go
184 lines
3.3 KiB
Go
package scanner
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
|
|
"coda/internal/config"
|
|
)
|
|
|
|
type Scanner struct {
|
|
musicPath string
|
|
mu sync.RWMutex
|
|
cache []Album
|
|
hasCached bool
|
|
}
|
|
|
|
func New(cfg *config.Config) *Scanner {
|
|
return &Scanner{
|
|
musicPath: filepath.Clean(cfg.MusicPath),
|
|
}
|
|
}
|
|
|
|
type Entry struct {
|
|
Name string
|
|
RelPath string
|
|
HasFlac bool
|
|
Dirs int
|
|
}
|
|
|
|
func (s *Scanner) Browse(relPath string) ([]Entry, error) {
|
|
resolved, err := s.Resolve(relPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
fis, err := os.ReadDir(resolved)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read dir %s: %w", resolved, err)
|
|
}
|
|
|
|
var entries []Entry
|
|
|
|
for _, fi := range fis {
|
|
if !fi.IsDir() {
|
|
continue
|
|
}
|
|
|
|
entryPath := filepath.Join(resolved, fi.Name())
|
|
subFis, err := os.ReadDir(entryPath)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
e := Entry{
|
|
Name: fi.Name(),
|
|
RelPath: filepath.Join(relPath, fi.Name()),
|
|
}
|
|
|
|
for _, sub := range subFis {
|
|
if sub.IsDir() {
|
|
e.Dirs++
|
|
}
|
|
if !sub.IsDir() && isFlac(sub.Name()) {
|
|
e.HasFlac = true
|
|
}
|
|
}
|
|
|
|
entries = append(entries, e)
|
|
}
|
|
|
|
sort.Slice(entries, func(i, j int) bool {
|
|
return strings.ToLower(entries[i].Name) < strings.ToLower(entries[j].Name)
|
|
})
|
|
|
|
return entries, nil
|
|
}
|
|
|
|
type Album struct {
|
|
RelPath string
|
|
AbsPath string
|
|
Artist string
|
|
Album string
|
|
FlacCount int
|
|
}
|
|
|
|
func (s *Scanner) Scan() ([]Album, error) {
|
|
type flacDir struct {
|
|
absPath string
|
|
count int
|
|
}
|
|
|
|
flacDirs := map[string]*flacDir{}
|
|
|
|
err := filepath.WalkDir(s.musicPath, func(path string, d os.DirEntry, err error) error {
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if d.IsDir() {
|
|
return nil
|
|
}
|
|
if !isFlac(d.Name()) {
|
|
return nil
|
|
}
|
|
|
|
dir := filepath.Dir(path)
|
|
fd, ok := flacDirs[dir]
|
|
if !ok {
|
|
fd = &flacDir{absPath: dir}
|
|
flacDirs[dir] = fd
|
|
}
|
|
fd.count++
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("walk: %w", err)
|
|
}
|
|
|
|
for dir := range flacDirs {
|
|
for parent := filepath.Dir(dir); parent != s.musicPath && parent != "."; parent = filepath.Dir(parent) {
|
|
if _, ok := flacDirs[parent]; ok {
|
|
delete(flacDirs, dir)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
var albums []Album
|
|
for absPath, fd := range flacDirs {
|
|
relPath, _ := filepath.Rel(s.musicPath, absPath)
|
|
albumName := filepath.Base(absPath)
|
|
artistName := filepath.Base(filepath.Dir(absPath))
|
|
|
|
albums = append(albums, Album{
|
|
RelPath: relPath,
|
|
AbsPath: absPath,
|
|
Artist: artistName,
|
|
Album: albumName,
|
|
FlacCount: fd.count,
|
|
})
|
|
}
|
|
|
|
sort.Slice(albums, func(i, j int) bool {
|
|
return albums[i].RelPath < albums[j].RelPath
|
|
})
|
|
|
|
s.mu.Lock()
|
|
s.cache = albums
|
|
s.hasCached = true
|
|
s.mu.Unlock()
|
|
|
|
return albums, nil
|
|
}
|
|
|
|
func (s *Scanner) GetCached() ([]Album, bool) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.cache, s.hasCached
|
|
}
|
|
|
|
func (s *Scanner) Resolve(relPath string) (string, error) {
|
|
resolved := filepath.Join(s.musicPath, relPath)
|
|
resolved = filepath.Clean(resolved)
|
|
|
|
if !strings.HasPrefix(resolved, s.musicPath) {
|
|
return "", fmt.Errorf("path %q escapes music path", relPath)
|
|
}
|
|
|
|
if resolved != s.musicPath && !strings.HasPrefix(resolved, s.musicPath+string(filepath.Separator)) {
|
|
return "", fmt.Errorf("path %q escapes music path", relPath)
|
|
}
|
|
|
|
return resolved, nil
|
|
}
|
|
|
|
func isFlac(name string) bool {
|
|
ext := strings.ToLower(filepath.Ext(name))
|
|
return ext == ".flac"
|
|
}
|