Files
music-agregator/internal/service.go
T

400 lines
12 KiB
Go

package internal
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/riverqueue/river"
"github.com/rs/zerolog/log"
metadataPb "homelab.lan/music-agregator/gen/metadata/v1"
pb "homelab.lan/music-agregator/gen/music_agregator/v1"
"homelab.lan/music-agregator/internal/config"
"homelab.lan/music-agregator/internal/database"
"homelab.lan/music-agregator/internal/indexer"
"homelab.lan/music-agregator/internal/metadata"
"homelab.lan/music-agregator/internal/release"
"homelab.lan/music-agregator/internal/torrent"
torrentParser "homelab.lan/music-agregator/internal/tracker"
"homelab.lan/music-agregator/internal/workers"
)
type parsedItem struct {
item *indexer.SearchItemResult
rel *release.Release
torrentData []byte
}
type MusicAgregatorService struct {
config config.Config
metadata *metadata.MetadataService
indexer *indexer.IndexerService
torrentClient torrent.TorrentClient
magnetResolver *torrentParser.MagnetResolver
riverClient *river.Client[pgx.Tx]
torrents *database.TorrentRepository
downloads *database.DownloadRepository
}
func NewMusicAgregatorService(cfg config.Config, riverClient *river.Client[pgx.Tx], db *database.DB) (*MusicAgregatorService, error) {
idx, err := indexer.NewIndexerService(cfg, riverClient, nil)
if err != nil {
log.Err(err).Msg("failed to create IndexerService")
return nil, err
}
metadataClient, _, err := metadata.NewMetadataClient(cfg.Metadata.Endpoint)
if err != nil {
log.Err(err).Msg("failed to create metadata client")
return nil, err
}
magnetResolver, err := torrentParser.NewMagnetResolver(30 * time.Second)
if err != nil {
log.Err(err).Msg("failed to create magnet resolver")
return nil, err
}
torrentClient, err := torrent.NewTorrentClient(cfg)
if err != nil {
log.Err(err).Msg("failed to create torrent client")
return nil, err
}
return &MusicAgregatorService{
config: cfg,
metadata: metadata.NewMetadataService(metadataClient, db),
indexer: idx,
torrentClient: torrentClient,
magnetResolver: magnetResolver,
riverClient: riverClient,
torrents: database.NewTorrentRepository(db.Pool),
downloads: database.NewDownloadRepository(db.Pool),
}, nil
}
func (s *MusicAgregatorService) Close() {
if s.magnetResolver != nil {
s.magnetResolver.Close()
}
}
func (service *MusicAgregatorService) MonitorAlbum(ctx context.Context, req *pb.MonitorAlbumRequest) (*pb.MonitorAlbumResponse, error) {
album, err := service.metadata.GetAlbum(ctx, req.GetAlbumId())
if err != nil {
log.Error().Err(err).Str("album_id", req.GetAlbumId()).Msg("failed to get album")
return nil, err
}
dbAlbum, _ := service.metadata.GetAlbumByExternalID(ctx, req.GetAlbumId())
if dbAlbum != nil {
qualityStr := normalizeQuality(req.GetQuality(), 0, 0)
owned, err := service.downloads.HasAlbumInQuality(ctx, dbAlbum.ID, req.GetQuality().String(), qualityStr)
if err == nil && owned {
log.Info().Str("album", dbAlbum.Title).Str("quality", qualityStr).Msg("album already owned in requested quality")
return &pb.MonitorAlbumResponse{}, nil
}
}
searchResult, err := service.searchIndexer(album, req.GetIndexerOptions().GetTracker())
if err != nil {
return nil, err
}
parsed := service.parseSearchResults(searchResult, album)
filtered := filterByQuality(parsed, req.GetQuality())
if len(filtered) == 0 {
log.Warn().Str("album", album.GetTitle()).Str("quality", req.GetQuality().String()).Msg("no releases match quality filter")
return &pb.MonitorAlbumResponse{}, nil
}
best := selectBestRelease(filtered)
if err := service.addToTorrentClient(best); err != nil {
return nil, err
}
if dbAlbum != nil {
service.saveTorrentAndDownload(ctx, dbAlbum.ID, best)
} else {
log.Warn().Str("album_id", req.GetAlbumId()).Msg("album not in DB, skipping torrent/download persistence")
}
return &pb.MonitorAlbumResponse{
Release: buildMonitoredRelease(best),
}, nil
}
func (service *MusicAgregatorService) searchIndexer(album *metadataPb.Album, tracker string) (*indexer.SearchResponse, error) {
artistName := ""
if len(album.GetArtists()) > 0 {
artistName = album.GetArtists()[0].GetArtist().GetName()
}
query := album.GetTitle()
if artistName != "" {
query = artistName + " " + query
}
if tracker == "" {
tracker = "all"
}
result, err := service.indexer.Search(query, -1, tracker)
if err != nil {
log.Error().Err(err).Str("query", query).Msg("indexer search failed")
return nil, err
}
log.Debug().Int("results", len(result.Items)).Str("query", query).Msg("indexer search completed")
return result, nil
}
func (service *MusicAgregatorService) parseSearchResults(searchResult *indexer.SearchResponse, album *metadataPb.Album) []parsedItem {
parser := torrentParser.NewGenericParser()
var parsed []parsedItem
for _, item := range searchResult.Items {
if item.DownloadLink == "" {
log.Trace().Str("title", item.Title).Msg("skipping item without download link")
continue
}
if item.Seeders == 0 {
log.Warn().Str("title", item.Title).Str("tracker", item.Tracker).Msg("skipping torrent with no seeders")
continue
}
r, torrentData := service.resolveRelease(parser, item, album)
log.Debug().
Str("title", item.Title).
Str("format", r.Format.String()).
Int("tracks", r.TrackCount).
Bool("lossless", r.Format.IsLossless()).
Int("seeders", item.Seeders).
Str("tracker", item.Tracker).
Msg("release parsed")
parsed = append(parsed, parsedItem{item: item, rel: r, torrentData: torrentData})
}
log.Debug().Int("total", len(searchResult.Items)).Int("parsed", len(parsed)).Msg("parsing complete")
return parsed
}
func (service *MusicAgregatorService) resolveRelease(parser *torrentParser.GenericParser, item *indexer.SearchItemResult, album *metadataPb.Album) (*release.Release, []byte) {
if strings.HasPrefix(item.DownloadLink, "magnet:") {
log.Trace().Str("title", item.Title).Int("reported_seeders", item.Seeders).Msg("resolving magnet")
torrentData, err := service.magnetResolver.Resolve(item.DownloadLink)
if err != nil {
log.Warn().Err(err).Str("title", item.Title).Int("reported_seeders", item.Seeders).Msg("magnet resolve failed, falling back to title parse")
return parser.Parse(item.Title), nil
}
return parser.ParseTorrent(torrentData, album), torrentData
}
torrentData, err := downloadTorrentData(item.DownloadLink)
if err != nil {
log.Warn().Err(err).Str("title", item.Title).Msg("failed to download torrent, falling back to title parse")
return parser.Parse(item.Title), nil
}
return parser.ParseTorrent(torrentData, album), torrentData
}
func filterByQuality(items []parsedItem, quality pb.QualityType) []parsedItem {
var filtered []parsedItem
for _, p := range items {
match := quality == pb.QualityType_QUALITY_UNSPECIFIED ||
(quality == pb.QualityType_QUALITY_LOSSLESS && p.rel.Format.IsLossless()) ||
(quality == pb.QualityType_QUALITY_LOSSY && !p.rel.Format.IsLossless())
if !match {
log.Debug().Str("title", p.item.Title).Str("format", p.rel.Format.String()).Str("wanted", quality.String()).Msg("filtered out by quality")
continue
}
filtered = append(filtered, p)
}
return filtered
}
func selectBestRelease(items []parsedItem) parsedItem {
best := items[0]
for _, p := range items[1:] {
if p.item.Seeders > best.item.Seeders {
best = p
}
}
log.Info().
Str("title", best.item.Title).
Str("format", best.rel.Format.String()).
Int("seeders", best.item.Seeders).
Str("tracker", best.item.Tracker).
Str("hash", best.rel.InfoHash).
Msg("best release selected")
return best
}
func (service *MusicAgregatorService) addToTorrentClient(best parsedItem) error {
if best.rel.InfoHash != "" {
existing, err := service.torrentClient.Find(torrent.FindOptions{Hash: best.rel.InfoHash})
if err == nil && len(existing) > 0 {
log.Info().Str("hash", best.rel.InfoHash).Str("state", existing[0].State).Msg("torrent already exists in client")
return nil
}
}
if strings.HasPrefix(best.item.DownloadLink, "magnet:") {
if err := service.torrentClient.AddMagnet(best.item.DownloadLink); err != nil {
log.Error().Err(err).Str("title", best.item.Title).Msg("failed to add magnet to client")
return err
}
} else {
if len(best.torrentData) == 0 {
log.Error().Str("title", best.item.Title).Msg("no torrent data available")
return fmt.Errorf("no torrent data available for best release")
}
if err := service.torrentClient.AddTorrent(torrent.TorrentFile{
Filename: best.rel.Album + ".torrent",
Data: best.torrentData,
}); err != nil {
log.Error().Err(err).Str("title", best.item.Title).Msg("failed to add torrent to client")
return err
}
}
log.Info().Str("title", best.item.Title).Str("hash", best.rel.InfoHash).Msg("torrent added to client")
return nil
}
func (service *MusicAgregatorService) saveTorrentAndDownload(ctx context.Context, dbAlbumID string, best parsedItem) {
quality := normalizeQuality(pb.QualityType_QUALITY_UNSPECIFIED, best.rel.BitDepth, best.rel.SampleRate)
dbTorrent := &database.Torrent{
AlbumID: dbAlbumID,
InfoHash: best.rel.InfoHash,
Tracker: best.item.Tracker,
Title: best.item.Title,
Format: best.rel.Format.String(),
Quality: quality,
Source: best.rel.Source.String(),
BitDepth: best.rel.BitDepth,
SampleRate: best.rel.SampleRate,
Seeders: best.item.Seeders,
Peers: best.item.Peers,
Size: best.rel.TotalAudioSize,
TrackCount: best.rel.TrackCount,
HasCoverArt: best.rel.HasCoverArt,
HasCueSheet: best.rel.HasCueSheet,
HasRipLog: best.rel.HasRipLog,
DownloadLink: best.item.DownloadLink,
TorrentFile: best.torrentData,
}
if err := service.torrents.Create(ctx, dbTorrent); err != nil {
log.Error().Err(err).Str("hash", best.rel.InfoHash).Msg("failed to save torrent to DB")
return
}
savedTorrent, err := service.torrents.GetByInfoHash(ctx, best.rel.InfoHash)
if err != nil {
log.Error().Err(err).Msg("failed to retrieve saved torrent")
return
}
download := &database.Download{
TorrentID: savedTorrent.ID,
AlbumID: dbAlbumID,
Format: best.rel.Format.String(),
Quality: quality,
State: "downloading",
QbitHash: best.rel.InfoHash,
}
if err := service.downloads.Create(ctx, download); err != nil {
log.Error().Err(err).Msg("failed to save download to DB")
return
}
if service.riverClient != nil {
_, err := service.riverClient.Insert(ctx, workers.PollDownloadArgs{
DownloadID: download.ID,
TorrentHash: best.rel.InfoHash,
CheckInterval: 30 * time.Second,
}, &river.InsertOpts{
ScheduledAt: time.Now().Add(30 * time.Second),
})
if err != nil {
log.Error().Err(err).Msg("failed to schedule download poll job")
} else {
log.Debug().Str("download_id", download.ID).Str("hash", best.rel.InfoHash).Msg("download poll job scheduled")
}
}
log.Info().Str("hash", best.rel.InfoHash).Str("download_id", download.ID).Msg("torrent and download saved to DB")
}
func normalizeQuality(quality pb.QualityType, bitDepth int, sampleRate int) string {
if bitDepth > 0 && sampleRate > 0 {
return fmt.Sprintf("%d-%d", bitDepth, sampleRate/1000)
}
switch quality {
case pb.QualityType_QUALITY_LOSSLESS:
return "16-44"
case pb.QualityType_QUALITY_LOSSY:
return "320"
default:
return ""
}
}
func buildMonitoredRelease(p parsedItem) *pb.MonitoredRelease {
return &pb.MonitoredRelease{
InfoHash: p.rel.InfoHash,
Artist: p.rel.Artist,
Album: p.rel.Album,
Year: int32(p.rel.Year),
Format: p.rel.Format.String(),
Lossless: p.rel.Format.IsLossless(),
BitDepth: int32(p.rel.BitDepth),
SampleRate: int32(p.rel.SampleRate),
Source: p.rel.Source.String(),
TrackCount: int32(p.rel.TrackCount),
TrackNames: p.rel.TrackNames,
HasCoverArt: p.rel.HasCoverArt,
HasCueSheet: p.rel.HasCueSheet,
HasRipLog: p.rel.HasRipLog,
TotalAudioSize: p.rel.TotalAudioSize,
DownloadLink: p.item.DownloadLink,
Seeders: int32(p.item.Seeders),
Tracker: p.item.Tracker,
}
}
func downloadTorrentData(url string) ([]byte, error) {
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(url)
if err != nil {
return nil, fmt.Errorf("downloading torrent: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("torrent download returned HTTP %d", resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("reading torrent data: %w", err)
}
return data, nil
}