Files
butterfliu/internal/service/library.go
lzw-723 79d47c81e7
All checks were successful
Go CI / test-and-build (push) Successful in 13s
Web CI / lint-test-build (push) Successful in 31s
实现增量扫描
2026-04-11 14:38:23 +08:00

357 lines
9.5 KiB
Go

package service
import (
"butterfliu/internal/model"
"butterfliu/internal/repository"
"butterfliu/internal/scanner"
"errors"
"fmt"
"log"
"path/filepath"
"strings"
"time"
)
// ScanReport holds the result of a library scan.
type ScanReport struct {
TotalFiles int `json:"total_files"`
Processed int `json:"processed"`
Added int `json:"added"`
Updated int `json:"updated"`
Deleted int `json:"deleted"`
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)
}
files, 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
}
seenAt := time.Now()
seenPaths := make(map[string]struct{}, len(files))
report := &ScanReport{TotalFiles: len(files)}
notifyProgress(report, onProgress)
for _, file := range files {
seenPaths[file.Path] = struct{}{}
existing, found := existingByPath[file.Path]
if found && existing.HasSong && existing.FileSize == file.FileSize && existing.FileMtimeNs == file.FileMtimeNs {
if err := s.mediaRepo.TouchLastSeen(existing.MediaFileID, seenAt); err != nil {
log.Printf("Failed to refresh last_seen_at for %s: %v", filepath.Base(file.Path), err)
report.FailedFiles = append(report.FailedFiles, file.Path)
} else {
report.Skipped++
}
report.Processed++
notifyProgress(report, onProgress)
continue
}
song, err := scanner.ProbeAudioFile(file.Path)
if err != nil {
log.Printf("Failed to probe %s: %v", filepath.Base(file.Path), err)
if found {
if touchErr := s.mediaRepo.TouchLastSeen(existing.MediaFileID, seenAt); touchErr != nil {
log.Printf("Failed to refresh last_seen_at for %s after probe failure: %v", filepath.Base(file.Path), touchErr)
}
}
report.FailedFiles = append(report.FailedFiles, file.Path)
report.Processed++
notifyProgress(report, onProgress)
continue
}
if err := s.upsertScannedSong(song, file, lib.ID, report, seenAt, found, existing); err != nil {
log.Printf("Failed to upsert %s: %v", filepath.Base(song.Path), err)
report.FailedFiles = append(report.FailedFiles, song.Path)
}
report.Processed++
notifyProgress(report, onProgress)
}
deleted, err := s.deleteMissingMediaFiles(existingFiles, seenPaths)
if err != nil {
return nil, fmt.Errorf("delete missing media files: %w", err)
}
report.Deleted = deleted
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,
Updated: report.Updated,
Deleted: report.Deleted,
Skipped: report.Skipped,
}
copyReport.FailedFiles = append([]string(nil), report.FailedFiles...)
onProgress(copyReport)
}
func (s *LibraryService) upsertScannedSong(song scanner.ScannedSong, file scanner.DiscoveredAudioFile, libraryID int, report *ScanReport, seenAt time.Time, hasExistingMedia bool, existing repository.ExistingMediaFile) error {
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)
}
mediaFile := model.MediaFile{
ID: existing.MediaFileID,
Path: song.Path,
LibraryID: libraryID,
FileSize: file.FileSize,
FileMtimeNs: file.FileMtimeNs,
Format: song.Format,
BitRate: song.BitRate,
SampleRate: song.SampleRate,
LastSeenAt: seenAt,
}
if !hasExistingMedia {
mediaFile, err = s.mediaRepo.Create(mediaFile)
if err != nil {
return fmt.Errorf("media file: %w", err)
}
} else if err := s.mediaRepo.UpdateMetadata(mediaFile); err != nil {
return fmt.Errorf("media file: %w", err)
}
if hasExistingMedia && existing.HasSong {
if err := s.songRepo.UpdateByMediaFileID(song.Title, artist.ID, album.ID, song.Duration, mediaFile.ID); err != nil {
return fmt.Errorf("song: %w", err)
}
report.Updated++
return nil
}
if _, err := s.songRepo.Create(song.Title, artist.ID, album.ID, song.Duration, mediaFile.ID); err != nil {
return fmt.Errorf("song: %w", err)
}
if hasExistingMedia {
report.Updated++
} else {
report.Added++
}
return nil
}
func (s *LibraryService) deleteMissingMediaFiles(existingFiles []repository.ExistingMediaFile, seenPaths map[string]struct{}) (int, error) {
deleted := 0
for _, existing := range existingFiles {
if _, ok := seenPaths[existing.Path]; ok {
continue
}
if err := s.mediaRepo.Delete(existing.MediaFileID); err != nil {
return deleted, err
}
deleted++
}
if deleted == 0 {
return 0, nil
}
if err := s.libRepo.CleanupOrphans(); err != nil {
return deleted, err
}
return deleted, nil
}
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)
}