Files
music-agregator/internal/services/download.go
T
Alexander 945aab82c2 WIP
2026-04-29 17:29:58 +02:00

537 lines
14 KiB
Go

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
}