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:
Alexander
2026-04-29 10:45:05 +02:00
parent f24543f401
commit 41fb033d30
48 changed files with 2306 additions and 6652 deletions
+298
View File
@@ -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
}