Add album/track releases with audio analysis, AnalyzeAlbumRelease RPC, Docker path auto-resolution, release parsing decision tree

This commit is contained in:
Alexander
2026-05-09 23:16:59 +02:00
parent 1e8506f146
commit 2740585261
25 changed files with 1841 additions and 125 deletions
+151 -5
View File
@@ -15,6 +15,7 @@ import (
metadataPb "homelab.lan/music-agregator/gen/metadata/v1"
pb "homelab.lan/music-agregator/gen/music_agregator/v1"
"homelab.lan/music-agregator/internal/analysis"
"homelab.lan/music-agregator/internal/config"
"homelab.lan/music-agregator/internal/database"
"homelab.lan/music-agregator/internal/indexer"
@@ -38,13 +39,17 @@ type MusicAgregatorService struct {
torrentClient torrent.TorrentClient
magnetResolver torrentParser.Resolver
riverClient *river.Client[pgx.Tx]
pathMapper *torrent.PathMapper
torrents *database.TorrentRepository
downloads *database.DownloadRepository
artists *database.ArtistRepository
downloadFiles *database.DownloadFileRepository
albumReleases *database.AlbumReleaseRepository
trackReleases *database.TrackReleaseRepository
analyzer *analysis.ReleaseAnalyzer
}
func NewMusicAgregatorService(cfg config.Config, riverClient *river.Client[pgx.Tx], torrentClient torrent.TorrentClient, db *database.DB) (*MusicAgregatorService, error) {
func NewMusicAgregatorService(cfg config.Config, riverClient *river.Client[pgx.Tx], torrentClient torrent.TorrentClient, pathMapper *torrent.PathMapper, db *database.DB) (*MusicAgregatorService, error) {
idx, err := indexer.NewIndexerService(cfg, riverClient, nil)
if err != nil {
log.Err(err).Msg("failed to create IndexerService")
@@ -70,10 +75,14 @@ func NewMusicAgregatorService(cfg config.Config, riverClient *river.Client[pgx.T
torrentClient: torrentClient,
magnetResolver: magnetResolver,
riverClient: riverClient,
pathMapper: pathMapper,
torrents: database.NewTorrentRepository(db.Pool),
downloads: database.NewDownloadRepository(db.Pool),
artists: database.NewArtistRepository(db.Pool),
downloadFiles: database.NewDownloadFileRepository(db.Pool),
albumReleases: database.NewAlbumReleaseRepository(db.Pool),
trackReleases: database.NewTrackReleaseRepository(db.Pool),
analyzer: analysis.NewReleaseAnalyzer(db),
}, nil
}
@@ -83,6 +92,7 @@ func NewMusicAgregatorServiceWithDeps(
torrentClient torrent.TorrentClient,
magnetResolver torrentParser.Resolver,
riverClient *river.Client[pgx.Tx],
pathMapper *torrent.PathMapper,
db *database.DB,
) *MusicAgregatorService {
return &MusicAgregatorService{
@@ -91,10 +101,14 @@ func NewMusicAgregatorServiceWithDeps(
torrentClient: torrentClient,
magnetResolver: magnetResolver,
riverClient: riverClient,
pathMapper: pathMapper,
torrents: database.NewTorrentRepository(db.Pool),
downloads: database.NewDownloadRepository(db.Pool),
artists: database.NewArtistRepository(db.Pool),
downloadFiles: database.NewDownloadFileRepository(db.Pool),
albumReleases: database.NewAlbumReleaseRepository(db.Pool),
trackReleases: database.NewTrackReleaseRepository(db.Pool),
analyzer: analysis.NewReleaseAnalyzer(db),
}
}
@@ -204,6 +218,15 @@ func (service *MusicAgregatorService) GetAlbum(ctx context.Context, req *pb.GetA
return nil, fmt.Errorf("album not found: %w", err)
}
info, err := service.buildAlbumInfo(ctx, dbAlbum)
if err != nil {
return nil, err
}
return &pb.GetAlbumResponse{Info: info}, nil
}
func (service *MusicAgregatorService) buildAlbumInfo(ctx context.Context, dbAlbum *database.Album) (*pb.AlbumInfo, error) {
metadataAlbum, err := service.metadata.GetAlbum(ctx, dbAlbum.ExternalID)
if err != nil {
log.Error().Err(err).Str("album_id", dbAlbum.ExternalID).Msg("failed to get album from metadata")
@@ -299,10 +322,128 @@ func (service *MusicAgregatorService) GetAlbum(ctx context.Context, req *pb.GetA
tracks = append(tracks, td)
}
return &pb.GetAlbumResponse{
info := &pb.AlbumInfo{
Album: album,
Tracks: tracks,
}, nil
}
albumReleases, err := service.albumReleases.GetByAlbumID(ctx, dbAlbum.ID)
if err == nil && len(albumReleases) > 0 {
ar := albumReleases[0]
releaseDetail := &pb.AlbumReleaseDetail{
Id: ar.ID,
Format: ar.Format,
Channels: int32(ar.Channels),
IsLossless: ar.IsLossless,
TotalSize: ar.TotalSize,
TotalDurationMs: int32(ar.TotalDurationMs),
TrackCount: int32(ar.TrackCount),
HasCoverArt: ar.HasCoverArt,
HasCueSheet: ar.HasCueSheet,
HasRipLog: ar.HasRipLog,
Path: ar.Path,
}
if ar.BitDepth != nil {
releaseDetail.BitDepth = int32(*ar.BitDepth)
}
if ar.SampleRate != nil {
releaseDetail.SampleRate = int32(*ar.SampleRate)
}
if ar.Source != nil {
releaseDetail.Source = *ar.Source
}
trackReleases, err := service.trackReleases.GetByAlbumReleaseID(ctx, ar.ID)
if err == nil {
for _, tr := range trackReleases {
trd := &pb.TrackReleaseDetail{
Id: tr.ID,
Title: tr.Title,
TrackNumber: int32(tr.TrackNumber),
DiscNumber: int32(tr.DiscNumber),
Format: tr.Format,
Channels: int32(tr.Channels),
FileSize: tr.FileSize,
FilePath: tr.FilePath,
}
if tr.TrackID != nil {
trd.TrackId = *tr.TrackID
}
if tr.DurationMs != nil {
trd.DurationMs = int32(*tr.DurationMs)
}
if tr.BitDepth != nil {
trd.BitDepth = int32(*tr.BitDepth)
}
if tr.SampleRate != nil {
trd.SampleRate = int32(*tr.SampleRate)
}
if tr.BitrateKbps != nil {
trd.BitrateKbps = int32(*tr.BitrateKbps)
}
releaseDetail.Tracks = append(releaseDetail.Tracks, trd)
}
}
info.Release = releaseDetail
}
return info, nil
}
func (service *MusicAgregatorService) AnalyzeAlbumRelease(ctx context.Context, req *pb.AnalyzeAlbumReleaseRequest) (*pb.AnalyzeAlbumReleaseResponse, error) {
dbAlbum, err := service.metadata.GetAlbumByID(ctx, req.GetAlbumId())
if err != nil {
return nil, fmt.Errorf("album not found: %w", err)
}
downloads, err := service.downloads.GetByAlbumID(ctx, dbAlbum.ID)
if err != nil || len(downloads) == 0 {
return nil, fmt.Errorf("no downloads found for album")
}
var download *database.Download
for _, d := range downloads {
if d.State == "completed" || d.State == "seeding" {
download = d
break
}
}
if download == nil {
return nil, fmt.Errorf("no completed download found for album")
}
contentPath := ""
existingRelease, err := service.albumReleases.GetByDownloadID(ctx, download.ID)
if err == nil {
contentPath = existingRelease.Path
}
if contentPath == "" && download.QbitHash != "" {
torrents, err := service.torrentClient.Find(torrent.FindOptions{Hash: download.QbitHash})
if err == nil && len(torrents) > 0 {
contentPath = torrents[0].ContentPath
if service.pathMapper != nil {
contentPath = service.pathMapper.ToHost(contentPath)
}
}
}
if contentPath == "" {
return nil, fmt.Errorf("cannot determine content path for download")
}
_, _, err = service.analyzer.AnalyzeAndPersist(ctx, download.ID, contentPath)
if err != nil {
return nil, fmt.Errorf("analyzing release: %w", err)
}
info, err := service.buildAlbumInfo(ctx, dbAlbum)
if err != nil {
return nil, err
}
return &pb.AnalyzeAlbumReleaseResponse{Info: info}, nil
}
func (service *MusicAgregatorService) MonitorAlbum(ctx context.Context, req *pb.MonitorAlbumRequest) (*pb.MonitorAlbumResponse, error) {
@@ -475,8 +616,13 @@ func (service *MusicAgregatorService) addToTorrentClient(best parsedItem) error
}
}
savePath := ""
if service.pathMapper != nil {
savePath = service.pathMapper.ContainerDownloadPath()
}
if strings.HasPrefix(best.item.DownloadLink, "magnet:") {
if err := service.torrentClient.AddMagnet(best.item.DownloadLink); err != nil {
if err := service.torrentClient.AddMagnet(best.item.DownloadLink, savePath); err != nil {
log.Error().Err(err).Str("title", best.item.Title).Msg("failed to add magnet to client")
return err
}
@@ -488,7 +634,7 @@ func (service *MusicAgregatorService) addToTorrentClient(best parsedItem) error
if err := service.torrentClient.AddTorrent(torrent.TorrentFile{
Filename: best.rel.Album + ".torrent",
Data: best.torrentData,
}); err != nil {
}, savePath); err != nil {
log.Error().Err(err).Str("title", best.item.Title).Msg("failed to add torrent to client")
return err
}