301 lines
7.8 KiB
Go
301 lines
7.8 KiB
Go
package service
|
|
|
|
import (
|
|
"butterfliu/internal/model"
|
|
"butterfliu/internal/repository"
|
|
"butterfliu/internal/scanner"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// ScanReport holds the result of a library scan.
|
|
type ScanReport struct {
|
|
TotalFiles int `json:"total_files"`
|
|
Processed int `json:"processed"`
|
|
Added int `json:"added"`
|
|
Skipped int `json:"skipped"`
|
|
FailedFiles []string `json:"failed_files"`
|
|
}
|
|
|
|
type ScanProgressFunc func(ScanReport)
|
|
|
|
type LibraryService struct {
|
|
libRepo *repository.LibraryRepository
|
|
artistRepo *repository.ArtistRepository
|
|
albumRepo *repository.AlbumRepository
|
|
songRepo *repository.SongRepository
|
|
mediaRepo *repository.MediaRepository
|
|
}
|
|
|
|
func NewLibraryService(
|
|
libRepo *repository.LibraryRepository,
|
|
artistRepo *repository.ArtistRepository,
|
|
albumRepo *repository.AlbumRepository,
|
|
songRepo *repository.SongRepository,
|
|
mediaRepo *repository.MediaRepository,
|
|
) *LibraryService {
|
|
return &LibraryService{
|
|
libRepo: libRepo,
|
|
artistRepo: artistRepo,
|
|
albumRepo: albumRepo,
|
|
songRepo: songRepo,
|
|
mediaRepo: mediaRepo,
|
|
}
|
|
}
|
|
|
|
func (s *LibraryService) GetAll() ([]model.Library, error) {
|
|
return s.libRepo.GetAll()
|
|
}
|
|
|
|
func (s *LibraryService) GetByID(id int) (model.Library, error) {
|
|
return s.libRepo.GetByID(id)
|
|
}
|
|
|
|
func (s *LibraryService) Create(name, path string) (model.Library, error) {
|
|
if name = strings.TrimSpace(name); name == "" {
|
|
return model.Library{}, errors.New("library name cannot be empty")
|
|
}
|
|
if path = strings.TrimSpace(path); path == "" {
|
|
return model.Library{}, errors.New("library path cannot be empty")
|
|
}
|
|
return s.libRepo.Create(name, path)
|
|
}
|
|
|
|
func (s *LibraryService) UpdateName(id int, name string) error {
|
|
if name = strings.TrimSpace(name); name == "" {
|
|
return errors.New("library name cannot be empty")
|
|
}
|
|
return s.libRepo.UpdateName(id, name)
|
|
}
|
|
|
|
func (s *LibraryService) UpdatePath(id int, path string) error {
|
|
if path = strings.TrimSpace(path); path == "" {
|
|
return errors.New("library path cannot be empty")
|
|
}
|
|
return s.libRepo.UpdatePath(id, path)
|
|
}
|
|
|
|
func (s *LibraryService) Delete(id int) error {
|
|
return s.libRepo.Delete(id)
|
|
}
|
|
|
|
func (s *LibraryService) GetSongs(id int) ([]model.SongDetail, error) {
|
|
return s.mediaRepo.GetSongsByLibraryWithDetails(id)
|
|
}
|
|
|
|
func (s *LibraryService) GetSongsByArtist(id int) ([]model.SongDetail, error) {
|
|
return s.mediaRepo.GetSongsByArtistWithDetails(id)
|
|
}
|
|
|
|
func (s *LibraryService) GetSongsByAlbum(id int) ([]model.SongDetail, error) {
|
|
return s.mediaRepo.GetSongsByAlbumWithDetails(id)
|
|
}
|
|
|
|
func (s *LibraryService) GetArtists() ([]model.Artist, error) {
|
|
return s.artistRepo.GetAll()
|
|
}
|
|
|
|
func (s *LibraryService) GetArtist(id int) (model.ArtistDetail, error) {
|
|
artist, err := s.artistRepo.Get(id)
|
|
if err != nil {
|
|
return model.ArtistDetail{}, err
|
|
}
|
|
albums, err := s.albumRepo.GetIDsByArtist(id)
|
|
if err != nil {
|
|
return model.ArtistDetail{}, err
|
|
}
|
|
songs, err := s.artistRepo.GetSongIDsByArtist(id)
|
|
if err != nil {
|
|
return model.ArtistDetail{}, err
|
|
}
|
|
return model.ArtistDetail{
|
|
ID: artist.ID,
|
|
Name: artist.Name,
|
|
Songs: songs,
|
|
Albums: albums,
|
|
}, nil
|
|
}
|
|
|
|
func (s *LibraryService) GetAlbums() ([]model.Album, error) {
|
|
return s.albumRepo.GetAll()
|
|
}
|
|
|
|
func (s *LibraryService) GetAlbumsByArtistWithDetail(artistID int) ([]model.AlbumDetail, error) {
|
|
albums, err := s.albumRepo.GetByArtist(artistID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
details := []model.AlbumDetail{}
|
|
for _, a := range albums {
|
|
songs, err := s.albumRepo.GetSongIDs(a.ID)
|
|
if err != nil {
|
|
break
|
|
}
|
|
artist, err := s.artistRepo.Get(artistID)
|
|
if err != nil {
|
|
break
|
|
}
|
|
details = append(details, model.AlbumDetail{
|
|
ID: a.ID,
|
|
Title: a.Title,
|
|
Artist: artist.Name,
|
|
Songs: songs,
|
|
})
|
|
}
|
|
return details, nil
|
|
}
|
|
|
|
func (s *LibraryService) GetAlbum(id int) (model.AlbumDetail, error) {
|
|
album, err := s.albumRepo.Get(id)
|
|
if err != nil {
|
|
return model.AlbumDetail{}, err
|
|
}
|
|
songs, err := s.albumRepo.GetSongIDs(id)
|
|
if err != nil {
|
|
return model.AlbumDetail{}, err
|
|
}
|
|
artist, err := s.artistRepo.Get(album.ArtistID)
|
|
if err != nil {
|
|
return model.AlbumDetail{}, err
|
|
}
|
|
return model.AlbumDetail{
|
|
ID: id,
|
|
Title: album.Title,
|
|
Songs: songs,
|
|
Artist: artist.Name,
|
|
}, nil
|
|
}
|
|
|
|
func (s *LibraryService) Scan(id int, onProgress ScanProgressFunc) (*ScanReport, error) {
|
|
lib, err := s.libRepo.GetByID(id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("library %d not found: %w", id, err)
|
|
}
|
|
|
|
paths, err := scanner.ListAudioFiles(lib.Path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan directory %s: %w", lib.Path, err)
|
|
}
|
|
|
|
existingFiles, err := s.mediaRepo.ListExistingByLibrary(lib.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load existing media files: %w", err)
|
|
}
|
|
|
|
existingByPath := make(map[string]repository.ExistingMediaFile, len(existingFiles))
|
|
for _, existing := range existingFiles {
|
|
existingByPath[existing.Path] = existing
|
|
}
|
|
|
|
report := &ScanReport{TotalFiles: len(paths)}
|
|
notifyProgress(report, onProgress)
|
|
|
|
for _, path := range paths {
|
|
existing, found := existingByPath[path]
|
|
if found && existing.HasSong {
|
|
report.Skipped++
|
|
report.Processed++
|
|
notifyProgress(report, onProgress)
|
|
continue
|
|
}
|
|
|
|
song, err := scanner.ProbeAudioFile(path)
|
|
if err != nil {
|
|
log.Printf("Failed to probe %s: %v", filepath.Base(path), err)
|
|
report.FailedFiles = append(report.FailedFiles, path)
|
|
report.Processed++
|
|
notifyProgress(report, onProgress)
|
|
continue
|
|
}
|
|
|
|
if err := s.addScannedSong(song, lib.ID, report, found, existing.MediaFileID); err != nil {
|
|
log.Printf("Failed to add %s: %v", filepath.Base(song.Path), err)
|
|
report.FailedFiles = append(report.FailedFiles, song.Path)
|
|
}
|
|
|
|
report.Processed++
|
|
notifyProgress(report, onProgress)
|
|
}
|
|
|
|
log.Printf("Scan complete for library %d: %+v", id, report)
|
|
return report, nil
|
|
}
|
|
|
|
func notifyProgress(report *ScanReport, onProgress ScanProgressFunc) {
|
|
if onProgress == nil {
|
|
return
|
|
}
|
|
|
|
copyReport := ScanReport{
|
|
TotalFiles: report.TotalFiles,
|
|
Processed: report.Processed,
|
|
Added: report.Added,
|
|
Skipped: report.Skipped,
|
|
}
|
|
copyReport.FailedFiles = append([]string(nil), report.FailedFiles...)
|
|
onProgress(copyReport)
|
|
}
|
|
|
|
// addScannedSong upserts a scanned song and its related artist, album, and media file.
|
|
func (s *LibraryService) addScannedSong(song scanner.ScannedSong, libraryID int, report *ScanReport, hasExistingMedia bool, mediaFileID int) error {
|
|
mediaFile := model.MediaFile{ID: mediaFileID, Path: song.Path, LibraryID: libraryID}
|
|
if !hasExistingMedia {
|
|
var err error
|
|
mediaFile, err = s.getOrCreateMediaFile(song.Path, libraryID)
|
|
if err != nil {
|
|
return fmt.Errorf("media file: %w", err)
|
|
}
|
|
}
|
|
|
|
if hasSong, err := s.songRepo.HasByMediaFileID(mediaFile.ID); err != nil {
|
|
return fmt.Errorf("song lookup: %w", err)
|
|
} else if hasSong {
|
|
report.Skipped++
|
|
return nil
|
|
}
|
|
|
|
artist, err := s.getOrCreateArtist(song.Artist)
|
|
if err != nil {
|
|
return fmt.Errorf("artist: %w", err)
|
|
}
|
|
|
|
album, err := s.getOrCreateAlbum(song.Album, artist.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("album: %w", err)
|
|
}
|
|
|
|
if _, err := s.songRepo.Create(song.Title, artist.ID, album.ID, song.Duration, mediaFile.ID); err != nil {
|
|
return fmt.Errorf("song: %w", err)
|
|
}
|
|
|
|
report.Added++
|
|
return nil
|
|
}
|
|
|
|
func (s *LibraryService) getOrCreateMediaFile(path string, libraryID int) (model.MediaFile, error) {
|
|
mf, err := s.mediaRepo.GetByPath(path)
|
|
if err == nil {
|
|
return mf, nil
|
|
}
|
|
return s.mediaRepo.Create(path, libraryID)
|
|
}
|
|
|
|
func (s *LibraryService) getOrCreateArtist(name string) (model.Artist, error) {
|
|
a, err := s.artistRepo.GetByName(name)
|
|
if err == nil {
|
|
return a, nil
|
|
}
|
|
return s.artistRepo.Create(name)
|
|
}
|
|
|
|
func (s *LibraryService) getOrCreateAlbum(title string, artistID int) (model.Album, error) {
|
|
al, err := s.albumRepo.GetByTitleAndArtist(title, artistID)
|
|
if err == nil {
|
|
return al, nil
|
|
}
|
|
return s.albumRepo.Create(title, artistID)
|
|
}
|