package services import ( "bytes" "context" "fmt" "io" "net/http" "strconv" "strings" "time" "github.com/anacrolix/torrent/metainfo" "github.com/fujin/music-agregator/internal/database" "github.com/fujin/music-agregator/internal/indexer" "github.com/fujin/music-agregator/internal/metadata" "github.com/google/uuid" "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"` JobID *string `json:"job_id,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 queueID *string } type downloadContext struct { artistName string albumTitle string year *uint32 artistID *uuid.UUID albumID *uuid.UUID indexerService *IndexerService torrentService *TorrentService db *database.DB } 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") if _, err := db.UpsertArtist(ctx, id); err != nil { log.Warn().Err(err).Str("artist", artist.Name).Msg("failed to create artist library entry") } } } 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, jobID *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 } } } var artistUUID, albumUUID *uuid.UUID if artistMetadataID != nil { if id, err := uuid.Parse(*artistMetadataID); err == nil { artistUUID = &id if artistRow, err := db.GetArtistByForeignID(ctx, artist.Id); err == nil { artistUUID = &artistRow.ID } } } if albumID, err := uuid.Parse(album.Id); err == nil { if albumRow, err := db.GetAlbumByID(ctx, albumID); err == nil { albumUUID = &albumRow.ID } } dlCtx := &downloadContext{ artistName: artist.Name, albumTitle: album.Title, year: year, artistID: artistUUID, albumID: albumUUID, indexerService: indexerService, torrentService: torrentService, db: db, } dlResult := downloadAlbum(ctx, dlCtx) downloadStatus = &dlResult.status torrentHash = dlResult.torrentHash indexerName = dlResult.indexer dlError = dlResult.err jobID = dlResult.queueID 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, JobID: jobID, 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, dlCtx *downloadContext) downloadResult { albumStr := dlCtx.albumTitle criteria := &indexer.MusicSearchCriteria{ Artist: dlCtx.artistName, Album: &albumStr, Year: dlCtx.year, Limit: 20, Offset: 0, } log.Info(). Str("artist", dlCtx.artistName). Str("album", dlCtx.albumTitle). Interface("year", dlCtx.year). Msg("[DOWNLOAD] searching indexers") searchResults, err := dlCtx.indexerService.Search(ctx, criteria, nil) if err != nil { errStr := "indexer search failed: " + err.Error() log.Error().Err(err).Str("artist", dlCtx.artistName).Str("album", dlCtx.albumTitle).Msg("[DOWNLOAD] indexer search failed") return downloadResult{ status: DownloadStatusFailed, err: &errStr, } } log.Info(). Int("results", len(searchResults)). Str("artist", dlCtx.artistName). Str("album", dlCtx.albumTitle). Msg("[DOWNLOAD] search completed") if len(searchResults) == 0 { log.Warn().Str("artist", dlCtx.artistName).Str("album", dlCtx.albumTitle).Msg("[DOWNLOAD] no results found") return downloadResult{status: DownloadStatusNoResults} } best := selectBestResult(searchResults) seeders := 0 if best.Seeders != nil { seeders = *best.Seeders } log.Info(). Str("title", best.Title). Str("indexer", best.Indexer). Int("seeders", seeders). Uint64("size_bytes", best.Size). Interface("infohash", best.Infohash). Msg("[DOWNLOAD] selected best result") log.Info().Str("url", best.DownloadURL).Msg("[DOWNLOAD] fetching torrent file") torrent, err := fetchTorrentFile(ctx, best.DownloadURL) if err != nil { errStr := "failed to fetch torrent file: " + err.Error() log.Error().Err(err).Str("url", best.DownloadURL).Msg("[DOWNLOAD] failed to fetch torrent file") return downloadResult{ status: DownloadStatusFailed, indexer: &best.Indexer, err: &errStr, } } log.Info().Int("size_bytes", len(torrent.Data)).Str("infohash", torrent.InfoHash).Msg("[DOWNLOAD] adding torrent file to client") if err := dlCtx.torrentService.AddTorrentFile(ctx, torrent.Data, nil); err != nil { errStr := "failed to add torrent: " + err.Error() log.Error().Err(err).Msg("[DOWNLOAD] failed to add torrent") return downloadResult{ status: DownloadStatusFailed, indexer: &best.Indexer, err: &errStr, } } log.Info().Str("indexer", best.Indexer).Str("hash", torrent.InfoHash).Msg("[DOWNLOAD] torrent added successfully") infoHash := torrent.InfoHash var queueIDStr *string if dlCtx.db != nil { title := dlCtx.artistName + " - " + dlCtx.albumTitle size := int64(best.Size) queueID, err := dlCtx.db.AddToDownloadQueue(ctx, title, size, &infoHash, &best.Indexer, dlCtx.albumID, dlCtx.artistID) if err != nil { log.Warn().Err(err).Str("title", title).Msg("[DOWNLOAD] failed to add to download queue") } else { idStr := queueID.String() queueIDStr = &idStr log.Info().Str("queue_id", idStr).Str("title", title).Str("hash", infoHash).Msg("[DOWNLOAD] added to download queue") } } return downloadResult{ status: DownloadStatusAdded, torrentHash: &infoHash, indexer: &best.Indexer, queueID: queueIDStr, } } 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 } type torrentFile struct { Data []byte InfoHash string } // fetchTorrentFile downloads a .torrent file from the given URL and extracts infohash. // This is necessary because the torrent client may be on a different network // (e.g., behind VPN) and cannot access the indexer directly. func fetchTorrentFile(ctx context.Context, url string) (*torrentFile, error) { client := &http.Client{Timeout: 30 * time.Second} req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, fmt.Errorf("create request: %w", err) } resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("fetch torrent: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode) } data, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read response: %w", err) } mi, err := metainfo.Load(bytes.NewReader(data)) if err != nil { return nil, fmt.Errorf("parse torrent: %w", err) } hash := mi.HashInfoBytes().HexString() return &torrentFile{Data: data, InfoHash: hash}, nil } 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 RefreshResult struct { ArtistID string `json:"artist_id"` ArtistName string `json:"artist_name"` AlbumsUpdated int `json:"albums_updated"` AlbumsAdded int `json:"albums_added"` } func RefreshArtist( ctx context.Context, foreignArtistID string, metadataClient *metadata.Client, db *database.DB, ) (*RefreshResult, error) { if db == nil { return nil, &NotFoundError{Message: "database not available"} } existingArtist, err := db.GetArtistMetadataByForeignID(ctx, foreignArtistID) if err != nil { return nil, &NotFoundError{Message: "artist not found: " + foreignArtistID} } artist, err := metadataClient.GetArtist(ctx, foreignArtistID) if err != nil { return nil, err } 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, }) } artistMetadataID, err := db.UpsertArtistMetadata(ctx, dbArtist) if err != nil { return nil, err } existingAlbumCount, _ := db.CountAlbumsByArtist(ctx, existingArtist.ID) albumsResponse, err := metadataClient.GetArtistAlbums(ctx, foreignArtistID, 500, 0) if err != nil { return nil, err } var albumsUpdated int for _, album := range albumsResponse.Albums { 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}) } if _, err := db.UpsertAlbum(ctx, dbAlbum, artistMetadataID); err != nil { log.Warn().Err(err).Str("album", album.Title).Msg("failed to upsert album during refresh") } else { albumsUpdated++ } } newAlbumCount, _ := db.CountAlbumsByArtist(ctx, artistMetadataID) albumsAdded := int(newAlbumCount - existingAlbumCount) if albumsAdded < 0 { albumsAdded = 0 } return &RefreshResult{ ArtistID: foreignArtistID, ArtistName: artist.Name, AlbumsUpdated: albumsUpdated, AlbumsAdded: albumsAdded, }, nil } type NotFoundError struct { Message string } func (e *NotFoundError) Error() string { return e.Message }