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) }