41fb033d30
- Replace Axum with Chi router - Replace sqlx with pgx for PostgreSQL - Replace tonic/prost with grpc-go - Replace tracing with zerolog - Update flake.nix for Go build with protoc generation - Preserve all existing endpoints and functionality Stack: Chi, pgx, grpc-go, zerolog, yaml.v3
299 lines
7.5 KiB
Go
299 lines
7.5 KiB
Go
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
|
|
}
|