refactor: rewrite project from Rust to Go
- 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
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user