package services import ( "context" "strconv" "strings" "github.com/fujin/music-agregator/internal/database" "github.com/fujin/music-agregator/internal/indexer" "github.com/fujin/music-agregator/internal/metadata" "github.com/rs/zerolog/log" ) type SyncOptions struct { Artist string `json:"artist"` Album *string `json:"album,omitempty"` Download bool `json:"download"` Store bool `json:"store"` } type SyncResult struct { ArtistID string `json:"artist_id"` ArtistName string `json:"artist_name"` TotalAlbums int `json:"total_albums"` AlbumsStored int `json:"albums_stored"` AlbumsDownloaded int `json:"albums_downloaded"` AlbumsNoResults int `json:"albums_no_results"` AlbumsFailed int `json:"albums_failed"` Results []AlbumSyncResult `json:"results,omitempty"` } type AlbumSyncResult struct { AlbumID string `json:"album_id"` AlbumTitle string `json:"album_title"` Stored bool `json:"stored"` DownloadStatus *DownloadStatus `json:"download_status,omitempty"` TorrentHash *string `json:"torrent_hash,omitempty"` Indexer *string `json:"indexer,omitempty"` Error *string `json:"error,omitempty"` } type DownloadStatus string const ( DownloadStatusAdded DownloadStatus = "added" DownloadStatusNoResults DownloadStatus = "noresults" DownloadStatusFailed DownloadStatus = "failed" DownloadStatusSkipped DownloadStatus = "skipped" ) type downloadResult struct { status DownloadStatus torrentHash *string indexer *string err *string } func Sync( ctx context.Context, options SyncOptions, metadataClient *metadata.Client, indexerService *IndexerService, torrentService *TorrentService, db *database.DB, ) (*SyncResult, error) { searchResult, err := metadataClient.SearchArtists(ctx, options.Artist, 1, 0) if err != nil { return nil, err } if len(searchResult.Artists) == 0 { return nil, &NotFoundError{Message: "artist not found: " + options.Artist} } artist := searchResult.Artists[0] var artistMetadataID *string if options.Store && db != nil { dbArtist := &database.Artist{ ID: artist.Id, Name: artist.Name, SortName: artist.SortName, ArtistType: artist.ArtistType, Description: artist.Description, } for _, g := range artist.Genres { dbArtist.Genres = append(dbArtist.Genres, database.Genre{ID: g.Id, Name: g.Name}) } for _, e := range artist.ExternalIds { dbArtist.ExternalIDs = append(dbArtist.ExternalIDs, database.ExternalID{ Source: e.Source, SourceID: e.SourceId, URL: e.Url, }) } id, err := db.UpsertArtistMetadata(ctx, dbArtist) if err != nil { log.Warn().Err(err).Str("artist", artist.Name).Msg("failed to store artist metadata") } else { idStr := id.String() artistMetadataID = &idStr log.Info().Str("artist", artist.Name).Str("id", idStr).Msg("stored artist metadata") } } albumsResponse, err := metadataClient.GetArtistAlbums(ctx, artist.Id, 500, 0) if err != nil { return nil, err } var albumsToProcess = albumsResponse.Albums if options.Album != nil { filterLower := strings.ToLower(*options.Album) var filtered = albumsToProcess[:0] for _, a := range albumsToProcess { if strings.Contains(strings.ToLower(a.Title), filterLower) { filtered = append(filtered, a) } } albumsToProcess = filtered } var results []AlbumSyncResult var albumsStored, albumsDownloaded, albumsNoResults, albumsFailed int for _, album := range albumsToProcess { var stored bool if options.Store && db != nil && artistMetadataID != nil { dbAlbum := &database.Album{ ID: album.Id, Title: album.Title, AlbumType: album.AlbumType, ReleaseDate: album.ReleaseDate, } for _, g := range album.Genres { dbAlbum.Genres = append(dbAlbum.Genres, database.Genre{ID: g.Id, Name: g.Name}) } id, err := parseUUID(*artistMetadataID) if err == nil { if _, err := db.UpsertAlbum(ctx, dbAlbum, id); err != nil { log.Warn().Err(err).Str("album", album.Title).Msg("failed to store album") } else { albumsStored++ stored = true } } } var downloadStatus *DownloadStatus var torrentHash, indexerName, dlError *string if options.Download { var year *uint32 if album.ReleaseDate != "" { parts := strings.Split(album.ReleaseDate, "-") if len(parts) > 0 { if y, err := strconv.ParseUint(parts[0], 10, 32); err == nil { y32 := uint32(y) year = &y32 } } } dlResult := downloadAlbum(ctx, artist.Name, album.Title, year, indexerService, torrentService) downloadStatus = &dlResult.status torrentHash = dlResult.torrentHash indexerName = dlResult.indexer dlError = dlResult.err switch dlResult.status { case DownloadStatusAdded: albumsDownloaded++ case DownloadStatusNoResults: albumsNoResults++ case DownloadStatusFailed, DownloadStatusSkipped: albumsFailed++ } } results = append(results, AlbumSyncResult{ AlbumID: album.Id, AlbumTitle: album.Title, Stored: stored, DownloadStatus: downloadStatus, TorrentHash: torrentHash, Indexer: indexerName, Error: dlError, }) } return &SyncResult{ ArtistID: artist.Id, ArtistName: artist.Name, TotalAlbums: len(albumsToProcess), AlbumsStored: albumsStored, AlbumsDownloaded: albumsDownloaded, AlbumsNoResults: albumsNoResults, AlbumsFailed: albumsFailed, Results: results, }, nil } func downloadAlbum( ctx context.Context, artistName, albumTitle string, year *uint32, indexerService *IndexerService, torrentService *TorrentService, ) downloadResult { albumStr := albumTitle criteria := &indexer.MusicSearchCriteria{ Artist: artistName, Album: &albumStr, Year: year, Limit: 20, Offset: 0, } searchResults, err := indexerService.Search(ctx, criteria, nil) if err != nil { errStr := "indexer search failed: " + err.Error() return downloadResult{ status: DownloadStatusFailed, err: &errStr, } } if len(searchResults) == 0 { return downloadResult{status: DownloadStatusNoResults} } best := selectBestResult(searchResults) if err := torrentService.AddTorrentURL(ctx, best.DownloadURL, nil); err != nil { errStr := "failed to add torrent: " + err.Error() return downloadResult{ status: DownloadStatusFailed, indexer: &best.Indexer, err: &errStr, } } return downloadResult{ status: DownloadStatusAdded, torrentHash: best.Infohash, indexer: &best.Indexer, } } func selectBestResult(results []indexer.SearchResult) *indexer.SearchResult { var best *indexer.SearchResult var bestScore int64 = -1 for i := range results { r := &results[i] seeders := 0 if r.Seeders != nil { seeders = *r.Seeders } score := int64(seeders) if strings.Contains(strings.ToLower(r.Title), "flac") { score += 1000 } if score > bestScore { bestScore = score best = r } } return best } func parseUUID(s string) ([16]byte, error) { var id [16]byte s = strings.ReplaceAll(s, "-", "") if len(s) != 32 { return id, &NotFoundError{Message: "invalid uuid"} } for i := 0; i < 16; i++ { b, err := strconv.ParseUint(s[i*2:i*2+2], 16, 8) if err != nil { return id, err } id[i] = byte(b) } return id, nil } type NotFoundError struct { Message string } func (e *NotFoundError) Error() string { return e.Message }