537 lines
14 KiB
Go
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
|
|
}
|