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
}
+84
View File
@@ -0,0 +1,84 @@
package services
import (
"context"
"fmt"
"strings"
"github.com/fujin/music-agregator/internal/config"
"github.com/fujin/music-agregator/internal/indexer"
)
type IndexerService struct {
indexers []*indexer.TorznabIndexer
}
type IndexerInfo struct {
Name string `json:"name"`
URL string `json:"url"`
Healthy bool `json:"healthy"`
}
func NewIndexerService(configs []config.IndexerConfig) (*IndexerService, error) {
var indexers []*indexer.TorznabIndexer
for _, cfg := range configs {
url := buildTorznabURL(cfg)
idx, err := indexer.NewTorznabIndexer(cfg.Name, url, cfg.APIKey)
if err != nil {
return nil, fmt.Errorf("failed to create indexer %s: %w", cfg.Name, err)
}
indexers = append(indexers, idx)
}
return &IndexerService{indexers: indexers}, nil
}
func buildTorznabURL(cfg config.IndexerConfig) string {
url := strings.TrimRight(cfg.URL, "/")
switch cfg.IndexerType {
case config.IndexerTypeJackett:
if !strings.Contains(url, "/api/") {
url = fmt.Sprintf("%s/api/v2.0/indexers/all/results/torznab", url)
}
case config.IndexerTypeProwlarr:
if !strings.Contains(url, "/api/") {
url = fmt.Sprintf("%s/api/v1/indexer/all/newznab", url)
}
}
return url
}
func (s *IndexerService) Search(ctx context.Context, criteria *indexer.MusicSearchCriteria, indexerName *string) ([]indexer.SearchResult, error) {
var results []indexer.SearchResult
for _, idx := range s.indexers {
if indexerName != nil && idx.Name() != *indexerName {
continue
}
r, err := idx.Search(ctx, criteria)
if err != nil {
continue
}
results = append(results, r...)
}
return results, nil
}
func (s *IndexerService) GetIndexers(ctx context.Context) []IndexerInfo {
var infos []IndexerInfo
for _, idx := range s.indexers {
healthy := idx.TestConnection(ctx) == nil
infos = append(infos, IndexerInfo{
Name: idx.Name(),
Healthy: healthy,
})
}
return infos
}
+98
View File
@@ -0,0 +1,98 @@
package services
import (
"context"
"github.com/fujin/music-agregator/internal/config"
"github.com/fujin/music-agregator/internal/torrent"
)
type TorrentService struct {
client torrent.Client
}
func NewTorrentService(cfg config.TorrentConfig) (*TorrentService, error) {
var client torrent.Client
switch cfg.ClientType {
case config.TorrentClientQBittorrent:
c, err := torrent.NewQBittorrentClient(cfg.URL, cfg.Username, cfg.Password)
if err != nil {
return nil, err
}
client = c
case config.TorrentClientStub:
client = torrent.NewStubClient(cfg.LogPath, cfg.SavePath)
default:
return &TorrentService{client: nil}, nil
}
return &TorrentService{client: client}, nil
}
func (s *TorrentService) Connect(ctx context.Context) error {
if s.client == nil {
return nil
}
return s.client.Connect(ctx)
}
func (s *TorrentService) Disconnect(ctx context.Context) error {
if s.client == nil {
return nil
}
return s.client.Disconnect(ctx)
}
func (s *TorrentService) ListTorrents(ctx context.Context) ([]torrent.TorrentInfo, error) {
if s.client == nil {
return []torrent.TorrentInfo{}, nil
}
return s.client.ListTorrents(ctx)
}
func (s *TorrentService) GetTorrent(ctx context.Context, hash string) (*torrent.TorrentInfo, error) {
if s.client == nil {
return nil, torrent.ErrTorrentNotFound
}
return s.client.GetTorrent(ctx, hash)
}
func (s *TorrentService) AddTorrentURL(ctx context.Context, url string, savePath *string) error {
if s.client == nil {
return nil
}
return s.client.AddTorrentURL(ctx, url, savePath)
}
func (s *TorrentService) AddTorrentFile(ctx context.Context, data []byte, savePath *string) error {
if s.client == nil {
return nil
}
return s.client.AddTorrentFile(ctx, data, savePath)
}
func (s *TorrentService) RemoveTorrent(ctx context.Context, hash string, deleteFiles bool) error {
if s.client == nil {
return nil
}
return s.client.RemoveTorrent(ctx, hash, deleteFiles)
}
func (s *TorrentService) PauseTorrent(ctx context.Context, hash string) error {
if s.client == nil {
return nil
}
return s.client.PauseTorrent(ctx, hash)
}
func (s *TorrentService) ResumeTorrent(ctx context.Context, hash string) error {
if s.client == nil {
return nil
}
return s.client.ResumeTorrent(ctx, hash)
}
func (s *TorrentService) IsConfigured() bool {
return s.client != nil
}