This commit is contained in:
Alexander
2026-04-29 17:29:58 +02:00
parent 3ecc6aee62
commit 945aab82c2
24 changed files with 2038 additions and 822 deletions
+31
View File
@@ -8,6 +8,7 @@ import (
"github.com/fujin/music-agregator/internal/database"
"github.com/fujin/music-agregator/internal/indexer"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
)
type RankedSearchResult struct {
@@ -30,11 +31,16 @@ func SearchAlbum(
db *database.DB,
indexerService *IndexerService,
) (*AlbumSearchResult, error) {
log.Info().Str("album_id", albumID.String()).Msg("[ALBUM_SEARCH] starting search")
album, err := db.GetAlbumDetailByID(ctx, albumID)
if err != nil {
log.Error().Err(err).Str("album_id", albumID.String()).Msg("[ALBUM_SEARCH] album not found")
return nil, err
}
log.Info().Str("artist", album.ArtistName).Str("album", album.Title).Msg("[ALBUM_SEARCH] searching for album")
var year *uint32
if album.ReleaseDate != nil {
y := uint32(album.ReleaseDate.Year())
@@ -51,13 +57,18 @@ func SearchAlbum(
results, err := indexerService.Search(ctx, criteria, nil)
if err != nil {
log.Error().Err(err).Msg("[ALBUM_SEARCH] indexer search failed")
return nil, err
}
log.Info().Int("raw_results", len(results)).Msg("[ALBUM_SEARCH] got raw results from indexers")
var rankedResults []RankedSearchResult
var blockedCount int
for _, r := range results {
blocked, _ := db.IsBlocklisted(ctx, r.Title, r.Infohash)
if blocked {
blockedCount++
continue
}
@@ -71,10 +82,30 @@ func SearchAlbum(
})
}
if blockedCount > 0 {
log.Info().Int("blocked", blockedCount).Msg("[ALBUM_SEARCH] filtered blocklisted results")
}
sort.Slice(rankedResults, func(i, j int) bool {
return rankedResults[i].Score > rankedResults[j].Score
})
if len(rankedResults) > 0 {
best := rankedResults[0]
seeders := 0
if best.Seeders != nil {
seeders = *best.Seeders
}
log.Info().
Str("title", best.Title).
Str("quality", best.Quality).
Float64("score", best.Score).
Int("seeders", seeders).
Msg("[ALBUM_SEARCH] best result")
}
log.Info().Int("total_results", len(rankedResults)).Msg("[ALBUM_SEARCH] search completed")
return &AlbumSearchResult{
AlbumID: albumID.String(),
AlbumTitle: album.Title,
+160 -16
View File
@@ -1,13 +1,20 @@
package services
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/anacrolix/torrent/metainfo"
"github.com/fujin/music-agregator/internal/database"
"github.com/fujin/music-agregator/internal/indexer"
"github.com/fujin/music-agregator/internal/metadata"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
)
@@ -36,6 +43,7 @@ type AlbumSyncResult struct {
DownloadStatus *DownloadStatus `json:"download_status,omitempty"`
TorrentHash *string `json:"torrent_hash,omitempty"`
Indexer *string `json:"indexer,omitempty"`
JobID *string `json:"job_id,omitempty"`
Error *string `json:"error,omitempty"`
}
@@ -53,6 +61,18 @@ type downloadResult struct {
torrentHash *string
indexer *string
err *string
queueID *string
}
type downloadContext struct {
artistName string
albumTitle string
year *uint32
artistID *uuid.UUID
albumID *uuid.UUID
indexerService *IndexerService
torrentService *TorrentService
db *database.DB
}
func Sync(
@@ -153,7 +173,7 @@ func Sync(
}
var downloadStatus *DownloadStatus
var torrentHash, indexerName, dlError *string
var torrentHash, indexerName, dlError, jobID *string
if options.Download {
var year *uint32
@@ -167,11 +187,38 @@ func Sync(
}
}
dlResult := downloadAlbum(ctx, artist.Name, album.Title, year, indexerService, torrentService)
var artistUUID, albumUUID *uuid.UUID
if artistMetadataID != nil {
if id, err := uuid.Parse(*artistMetadataID); err == nil {
artistUUID = &id
if artistRow, err := db.GetArtistByForeignID(ctx, artist.Id); err == nil {
artistUUID = &artistRow.ID
}
}
}
if albumID, err := uuid.Parse(album.Id); err == nil {
if albumRow, err := db.GetAlbumByID(ctx, albumID); err == nil {
albumUUID = &albumRow.ID
}
}
dlCtx := &downloadContext{
artistName: artist.Name,
albumTitle: album.Title,
year: year,
artistID: artistUUID,
albumID: albumUUID,
indexerService: indexerService,
torrentService: torrentService,
db: db,
}
dlResult := downloadAlbum(ctx, dlCtx)
downloadStatus = &dlResult.status
torrentHash = dlResult.torrentHash
indexerName = dlResult.indexer
dlError = dlResult.err
jobID = dlResult.queueID
switch dlResult.status {
case DownloadStatusAdded:
@@ -190,6 +237,7 @@ func Sync(
DownloadStatus: downloadStatus,
TorrentHash: torrentHash,
Indexer: indexerName,
JobID: jobID,
Error: dlError,
})
}
@@ -206,39 +254,63 @@ func Sync(
}, nil
}
func downloadAlbum(
ctx context.Context,
artistName, albumTitle string,
year *uint32,
indexerService *IndexerService,
torrentService *TorrentService,
) downloadResult {
albumStr := albumTitle
func downloadAlbum(ctx context.Context, dlCtx *downloadContext) downloadResult {
albumStr := dlCtx.albumTitle
criteria := &indexer.MusicSearchCriteria{
Artist: artistName,
Artist: dlCtx.artistName,
Album: &albumStr,
Year: year,
Year: dlCtx.year,
Limit: 20,
Offset: 0,
}
searchResults, err := indexerService.Search(ctx, criteria, nil)
log.Info().
Str("artist", dlCtx.artistName).
Str("album", dlCtx.albumTitle).
Interface("year", dlCtx.year).
Msg("[DOWNLOAD] searching indexers")
searchResults, err := dlCtx.indexerService.Search(ctx, criteria, nil)
if err != nil {
errStr := "indexer search failed: " + err.Error()
log.Error().Err(err).Str("artist", dlCtx.artistName).Str("album", dlCtx.albumTitle).Msg("[DOWNLOAD] indexer search failed")
return downloadResult{
status: DownloadStatusFailed,
err: &errStr,
}
}
log.Info().
Int("results", len(searchResults)).
Str("artist", dlCtx.artistName).
Str("album", dlCtx.albumTitle).
Msg("[DOWNLOAD] search completed")
if len(searchResults) == 0 {
log.Warn().Str("artist", dlCtx.artistName).Str("album", dlCtx.albumTitle).Msg("[DOWNLOAD] no results found")
return downloadResult{status: DownloadStatusNoResults}
}
best := selectBestResult(searchResults)
if err := torrentService.AddTorrentURL(ctx, best.DownloadURL, nil); err != nil {
errStr := "failed to add torrent: " + err.Error()
seeders := 0
if best.Seeders != nil {
seeders = *best.Seeders
}
log.Info().
Str("title", best.Title).
Str("indexer", best.Indexer).
Int("seeders", seeders).
Uint64("size_bytes", best.Size).
Interface("infohash", best.Infohash).
Msg("[DOWNLOAD] selected best result")
log.Info().Str("url", best.DownloadURL).Msg("[DOWNLOAD] fetching torrent file")
torrent, err := fetchTorrentFile(ctx, best.DownloadURL)
if err != nil {
errStr := "failed to fetch torrent file: " + err.Error()
log.Error().Err(err).Str("url", best.DownloadURL).Msg("[DOWNLOAD] failed to fetch torrent file")
return downloadResult{
status: DownloadStatusFailed,
indexer: &best.Indexer,
@@ -246,10 +318,41 @@ func downloadAlbum(
}
}
log.Info().Int("size_bytes", len(torrent.Data)).Str("infohash", torrent.InfoHash).Msg("[DOWNLOAD] adding torrent file to client")
if err := dlCtx.torrentService.AddTorrentFile(ctx, torrent.Data, nil); err != nil {
errStr := "failed to add torrent: " + err.Error()
log.Error().Err(err).Msg("[DOWNLOAD] failed to add torrent")
return downloadResult{
status: DownloadStatusFailed,
indexer: &best.Indexer,
err: &errStr,
}
}
log.Info().Str("indexer", best.Indexer).Str("hash", torrent.InfoHash).Msg("[DOWNLOAD] torrent added successfully")
infoHash := torrent.InfoHash
var queueIDStr *string
if dlCtx.db != nil {
title := dlCtx.artistName + " - " + dlCtx.albumTitle
size := int64(best.Size)
queueID, err := dlCtx.db.AddToDownloadQueue(ctx, title, size, &infoHash, &best.Indexer, dlCtx.albumID, dlCtx.artistID)
if err != nil {
log.Warn().Err(err).Str("title", title).Msg("[DOWNLOAD] failed to add to download queue")
} else {
idStr := queueID.String()
queueIDStr = &idStr
log.Info().Str("queue_id", idStr).Str("title", title).Str("hash", infoHash).Msg("[DOWNLOAD] added to download queue")
}
}
return downloadResult{
status: DownloadStatusAdded,
torrentHash: best.Infohash,
torrentHash: &infoHash,
indexer: &best.Indexer,
queueID: queueIDStr,
}
}
@@ -277,6 +380,47 @@ func selectBestResult(results []indexer.SearchResult) *indexer.SearchResult {
return best
}
type torrentFile struct {
Data []byte
InfoHash string
}
// fetchTorrentFile downloads a .torrent file from the given URL and extracts infohash.
// This is necessary because the torrent client may be on a different network
// (e.g., behind VPN) and cannot access the indexer directly.
func fetchTorrentFile(ctx context.Context, url string) (*torrentFile, error) {
client := &http.Client{Timeout: 30 * time.Second}
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch torrent: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
mi, err := metainfo.Load(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("parse torrent: %w", err)
}
hash := mi.HashInfoBytes().HexString()
return &torrentFile{Data: data, InfoHash: hash}, nil
}
func parseUUID(s string) ([16]byte, error) {
var id [16]byte
s = strings.ReplaceAll(s, "-", "")
+234
View File
@@ -0,0 +1,234 @@
package services
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/fujin/music-agregator/internal/database"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
)
type ImportResult struct {
QueueID string `json:"queue_id"`
ArtistName string `json:"artist_name"`
AlbumTitle string `json:"album_title"`
TargetPath string `json:"target_path"`
FilesCopied int `json:"files_copied"`
TotalSize int64 `json:"total_size"`
Files []string `json:"files"`
}
func ImportCompletedDownload(
ctx context.Context,
queueID uuid.UUID,
basePath string,
db *database.DB,
torrentService *TorrentService,
) (*ImportResult, error) {
log.Info().Str("queue_id", queueID.String()).Str("base_path", basePath).Msg("[IMPORT] starting import")
item, err := db.GetDownloadQueueItem(ctx, queueID)
if err != nil {
log.Error().Err(err).Str("queue_id", queueID.String()).Msg("[IMPORT] queue item not found")
return nil, fmt.Errorf("queue item not found: %w", err)
}
log.Info().Str("title", item.Title).Str("status", item.Status).Msg("[IMPORT] found queue item")
if item.Status != "completed" && item.Status != "seeding" {
log.Error().Str("status", item.Status).Msg("[IMPORT] download not completed")
return nil, fmt.Errorf("download not completed, status: %s", item.Status)
}
if item.TorrentHash == nil {
log.Error().Msg("[IMPORT] no torrent hash for queue item")
return nil, fmt.Errorf("no torrent hash for queue item")
}
log.Info().Str("hash", *item.TorrentHash).Msg("[IMPORT] fetching torrent info")
torrent, err := torrentService.GetTorrent(ctx, *item.TorrentHash)
if err != nil {
log.Error().Err(err).Str("hash", *item.TorrentHash).Msg("[IMPORT] torrent not found")
return nil, fmt.Errorf("torrent not found: %w", err)
}
log.Info().Str("name", torrent.Name).Str("save_path", torrent.SavePath).Msg("[IMPORT] torrent info retrieved")
var artistName, albumTitle string
if item.AlbumID != nil {
album, err := db.GetAlbumDetailByID(ctx, *item.AlbumID)
if err == nil {
artistName = album.ArtistName
albumTitle = album.Title
log.Info().Str("artist", artistName).Str("album", albumTitle).Msg("[IMPORT] resolved from database")
}
}
if artistName == "" || albumTitle == "" {
parts := strings.SplitN(item.Title, " - ", 2)
if len(parts) == 2 {
artistName = parts[0]
albumTitle = parts[1]
} else {
artistName = "Unknown Artist"
albumTitle = item.Title
}
log.Info().Str("artist", artistName).Str("album", albumTitle).Msg("[IMPORT] parsed from title")
}
artistName = sanitizePath(artistName)
albumTitle = sanitizePath(albumTitle)
targetDir := filepath.Join(basePath, artistName, albumTitle)
log.Info().Str("target_dir", targetDir).Msg("[IMPORT] creating target directory")
if err := os.MkdirAll(targetDir, 0755); err != nil {
log.Error().Err(err).Str("target_dir", targetDir).Msg("[IMPORT] failed to create target directory")
return nil, fmt.Errorf("failed to create target directory: %w", err)
}
sourcePath := filepath.Join(torrent.SavePath, torrent.Name)
log.Info().Str("source_path", sourcePath).Msg("[IMPORT] checking source path")
var filesCopied int
var totalSize int64
var copiedFiles []string
sourceInfo, err := os.Stat(sourcePath)
if err != nil {
log.Error().Err(err).Str("source_path", sourcePath).Msg("[IMPORT] source path not found")
return nil, fmt.Errorf("source path not found: %w", err)
}
if sourceInfo.IsDir() {
log.Info().Str("source_path", sourcePath).Msg("[IMPORT] source is directory, walking files")
err = filepath.Walk(sourcePath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
if !isAudioFile(info.Name()) {
log.Debug().Str("file", info.Name()).Msg("[IMPORT] skipping non-audio file")
return nil
}
relPath, _ := filepath.Rel(sourcePath, path)
targetPath := filepath.Join(targetDir, relPath)
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
return err
}
log.Info().Str("src", path).Str("dst", targetPath).Msg("[IMPORT] copying file")
if err := copyFile(path, targetPath); err != nil {
log.Warn().Err(err).Str("file", path).Msg("[IMPORT] failed to copy file")
return nil
}
filesCopied++
totalSize += info.Size()
copiedFiles = append(copiedFiles, relPath)
return nil
})
if err != nil {
log.Error().Err(err).Msg("[IMPORT] failed to copy files")
return nil, fmt.Errorf("failed to copy files: %w", err)
}
} else {
if isAudioFile(sourceInfo.Name()) {
targetPath := filepath.Join(targetDir, sourceInfo.Name())
log.Info().Str("src", sourcePath).Str("dst", targetPath).Msg("[IMPORT] copying single file")
if err := copyFile(sourcePath, targetPath); err != nil {
log.Error().Err(err).Msg("[IMPORT] failed to copy file")
return nil, fmt.Errorf("failed to copy file: %w", err)
}
filesCopied = 1
totalSize = sourceInfo.Size()
copiedFiles = append(copiedFiles, sourceInfo.Name())
}
}
log.Info().Int("files_copied", filesCopied).Int64("total_size", totalSize).Msg("[IMPORT] file copy completed")
log.Info().Msg("[IMPORT] updating queue status to imported")
if err := db.UpdateDownloadQueueStatus(ctx, queueID, "imported", nil); err != nil {
log.Warn().Err(err).Msg("[IMPORT] failed to update queue status to imported")
}
if item.AlbumID != nil {
log.Info().Msg("[IMPORT] removing from wanted albums")
db.RemoveFromWantedAlbums(ctx, *item.AlbumID)
}
log.Info().
Str("artist", artistName).
Str("album", albumTitle).
Str("target_path", targetDir).
Int("files_copied", filesCopied).
Msg("[IMPORT] import completed successfully")
return &ImportResult{
QueueID: queueID.String(),
ArtistName: artistName,
AlbumTitle: albumTitle,
TargetPath: targetDir,
FilesCopied: filesCopied,
TotalSize: totalSize,
Files: copiedFiles,
}, nil
}
var pathSanitizeRegex = regexp.MustCompile(`[<>:"/\\|?*]`)
func sanitizePath(s string) string {
s = pathSanitizeRegex.ReplaceAllString(s, "_")
s = strings.TrimSpace(s)
if s == "" {
s = "Unknown"
}
return s
}
func isAudioFile(name string) bool {
ext := strings.ToLower(filepath.Ext(name))
audioExts := map[string]bool{
".flac": true,
".mp3": true,
".m4a": true,
".aac": true,
".ogg": true,
".opus": true,
".wav": true,
".wma": true,
".alac": true,
}
return audioExts[ext]
}
func copyFile(src, dst string) error {
sourceFile, err := os.Open(src)
if err != nil {
return err
}
defer sourceFile.Close()
destFile, err := os.Create(dst)
if err != nil {
return err
}
defer destFile.Close()
if _, err := io.Copy(destFile, sourceFile); err != nil {
return err
}
return destFile.Sync()
}
+39 -1
View File
@@ -7,6 +7,7 @@ import (
"github.com/fujin/music-agregator/internal/config"
"github.com/fujin/music-agregator/internal/indexer"
"github.com/rs/zerolog/log"
)
type IndexerService struct {
@@ -31,7 +32,25 @@ func NewIndexerService(configs []config.IndexerConfig) (*IndexerService, error)
indexers = append(indexers, idx)
}
return &IndexerService{indexers: indexers}, nil
svc := &IndexerService{indexers: indexers}
svc.checkHealth(context.Background())
return svc, nil
}
func (s *IndexerService) checkHealth(ctx context.Context) {
for _, idx := range s.indexers {
if err := idx.TestConnection(ctx); err != nil {
log.Warn().
Str("indexer", idx.Name()).
Err(err).
Msg("[INDEXER] failed to connect to indexer")
} else {
log.Info().
Str("indexer", idx.Name()).
Msg("[INDEXER] connected successfully")
}
}
}
func buildTorznabURL(cfg config.IndexerConfig) string {
@@ -54,18 +73,37 @@ func buildTorznabURL(cfg config.IndexerConfig) string {
func (s *IndexerService) Search(ctx context.Context, criteria *indexer.MusicSearchCriteria, indexerName *string) ([]indexer.SearchResult, error) {
var results []indexer.SearchResult
log.Info().
Str("artist", criteria.Artist).
Interface("album", criteria.Album).
Interface("year", criteria.Year).
Msg("[INDEXER] searching indexers")
for _, idx := range s.indexers {
if indexerName != nil && idx.Name() != *indexerName {
continue
}
log.Debug().Str("indexer", idx.Name()).Msg("[INDEXER] querying indexer")
r, err := idx.Search(ctx, criteria)
if err != nil {
log.Warn().
Str("indexer", idx.Name()).
Err(err).
Msg("[INDEXER] search failed")
continue
}
log.Info().
Str("indexer", idx.Name()).
Int("results", len(r)).
Msg("[INDEXER] search completed")
results = append(results, r...)
}
log.Info().Int("total_results", len(results)).Msg("[INDEXER] search finished")
return results, nil
}
+148 -7
View File
@@ -2,10 +2,12 @@ package services
import (
"context"
"strings"
"github.com/fujin/music-agregator/internal/database"
"github.com/fujin/music-agregator/internal/torrent"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
)
type QueueSyncResult struct {
@@ -14,52 +16,106 @@ type QueueSyncResult struct {
}
func SyncDownloadQueue(ctx context.Context, db *database.DB, torrentService *TorrentService) (*QueueSyncResult, error) {
log.Info().Msg("[QUEUE_SYNC] starting queue sync")
if !torrentService.IsConfigured() {
log.Warn().Msg("[QUEUE_SYNC] torrent service not configured, skipping")
return &QueueSyncResult{}, nil
}
torrents, err := torrentService.ListTorrents(ctx)
if err != nil {
log.Error().Err(err).Msg("[QUEUE_SYNC] failed to list torrents")
return nil, err
}
log.Info().Int("torrent_count", len(torrents)).Msg("[QUEUE_SYNC] fetched torrents from client")
torrentMap := make(map[string]torrent.TorrentInfo)
torrentByName := make(map[string]torrent.TorrentInfo)
for _, t := range torrents {
torrentMap[t.Hash] = t
nameLower := strings.ToLower(t.Name)
torrentByName[nameLower] = t
log.Debug().
Str("hash", t.Hash).
Str("name", t.Name).
Str("state", string(t.State)).
Float64("progress", t.Progress).
Msg("[QUEUE_SYNC] torrent info")
}
queueItems, err := db.ListDownloadQueue(ctx, nil)
if err != nil {
log.Error().Err(err).Msg("[QUEUE_SYNC] failed to list queue items")
return nil, err
}
log.Info().Int("queue_count", len(queueItems)).Msg("[QUEUE_SYNC] fetched queue items from database")
var synced, updated int
for _, item := range queueItems {
if item.TorrentHash == nil {
var t torrent.TorrentInfo
var exists bool
if item.TorrentHash != nil {
t, exists = torrentMap[*item.TorrentHash]
if !exists {
log.Debug().Str("hash", *item.TorrentHash).Str("title", item.Title).Msg("[QUEUE_SYNC] torrent not found by hash")
}
}
if !exists {
titleLower := strings.ToLower(item.Title)
for name, torr := range torrentByName {
if strings.Contains(name, titleLower) || strings.Contains(titleLower, name) {
t = torr
exists = true
hash := t.Hash
if item.TorrentHash == nil {
log.Info().Str("title", item.Title).Str("matched_name", t.Name).Str("hash", hash).Msg("[QUEUE_SYNC] matched by title, updating hash")
if err := db.UpdateDownloadQueueHash(ctx, item.ID, hash); err != nil {
log.Error().Err(err).Msg("[QUEUE_SYNC] failed to update hash")
}
}
break
}
}
}
if !exists {
log.Debug().Str("title", item.Title).Msg("[QUEUE_SYNC] no matching torrent found")
continue
}
synced++
t, exists := torrentMap[*item.TorrentHash]
if !exists {
continue
}
newStatus := mapTorrentState(t.State)
sizeLeft := int64(float64(item.Size) * (1 - t.Progress))
if newStatus != item.Status || item.Progress != float32(t.Progress) {
log.Info().
Str("title", item.Title).
Str("old_status", item.Status).
Str("new_status", newStatus).
Float32("old_progress", item.Progress).
Float64("new_progress", t.Progress).
Msg("[QUEUE_SYNC] updating queue item")
if err := db.UpdateDownloadQueueProgress(ctx, item.ID, float32(t.Progress), sizeLeft, newStatus); err != nil {
log.Error().Err(err).Str("title", item.Title).Msg("[QUEUE_SYNC] failed to update queue item")
continue
}
updated++
if newStatus == "completed" && item.AlbumID != nil {
log.Info().Str("title", item.Title).Msg("[QUEUE_SYNC] download completed, removing from wanted albums")
db.RemoveFromWantedAlbums(ctx, *item.AlbumID)
}
}
}
log.Info().Int("synced", synced).Int("updated", updated).Msg("[QUEUE_SYNC] sync completed")
return &QueueSyncResult{Synced: synced, Updated: updated}, nil
}
@@ -83,27 +139,37 @@ func mapTorrentState(state torrent.TorrentState) string {
}
func HandleFailedDownload(ctx context.Context, db *database.DB, queueID uuid.UUID, errorMessage string) error {
log.Info().Str("queue_id", queueID.String()).Str("error", errorMessage).Msg("[FAILED_DOWNLOAD] handling failed download")
item, err := db.GetDownloadQueueItem(ctx, queueID)
if err != nil {
log.Error().Err(err).Str("queue_id", queueID.String()).Msg("[FAILED_DOWNLOAD] failed to get queue item")
return err
}
log.Info().Str("title", item.Title).Msg("[FAILED_DOWNLOAD] marking as failed")
if err := db.UpdateDownloadQueueStatus(ctx, queueID, "failed", &errorMessage); err != nil {
log.Error().Err(err).Msg("[FAILED_DOWNLOAD] failed to update status")
return err
}
if item.ArtistID != nil && item.AlbumID != nil {
log.Info().Str("title", item.Title).Msg("[FAILED_DOWNLOAD] adding to blocklist")
if err := db.AddToBlocklist(ctx, *item.ArtistID, *item.AlbumID, item.Title, item.TorrentHash, item.Indexer); err != nil {
log.Error().Err(err).Msg("[FAILED_DOWNLOAD] failed to add to blocklist")
return err
}
}
if item.AlbumID != nil {
log.Info().Str("title", item.Title).Msg("[FAILED_DOWNLOAD] re-adding to wanted albums for retry")
if err := db.AddToWantedAlbums(ctx, *item.AlbumID); err != nil {
log.Error().Err(err).Msg("[FAILED_DOWNLOAD] failed to add to wanted albums")
return err
}
}
log.Info().Str("title", item.Title).Msg("[FAILED_DOWNLOAD] handling complete")
return nil
}
@@ -112,12 +178,78 @@ type BlocklistResult struct {
Removed bool `json:"removed"`
}
func BlocklistAndRemove(ctx context.Context, db *database.DB, torrentService *TorrentService, queueID uuid.UUID) (*BlocklistResult, error) {
item, err := db.GetDownloadQueueItem(ctx, queueID)
type JobStatus struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
Progress float32 `json:"progress"`
Size int64 `json:"size"`
SizeLeft int64 `json:"size_left"`
TorrentHash *string `json:"torrent_hash,omitempty"`
Indexer *string `json:"indexer,omitempty"`
ErrorMessage *string `json:"error_message,omitempty"`
CreatedAt string `json:"created_at"`
CompletedAt *string `json:"completed_at,omitempty"`
}
func GetJobStatus(ctx context.Context, db *database.DB, torrentService *TorrentService, jobID uuid.UUID) (*JobStatus, error) {
log.Info().Str("job_id", jobID.String()).Msg("[JOB_STATUS] fetching job status")
item, err := db.GetDownloadQueueItem(ctx, jobID)
if err != nil {
log.Error().Err(err).Str("job_id", jobID.String()).Msg("[JOB_STATUS] job not found")
return nil, err
}
status := &JobStatus{
ID: item.ID.String(),
Title: item.Title,
Status: item.Status,
Progress: item.Progress,
Size: item.Size,
SizeLeft: item.SizeLeft,
TorrentHash: item.TorrentHash,
Indexer: item.Indexer,
ErrorMessage: item.ErrorMessage,
CreatedAt: item.AddedAt.Format("2006-01-02T15:04:05Z07:00"),
}
if item.CompletedAt != nil {
completedStr := item.CompletedAt.Format("2006-01-02T15:04:05Z07:00")
status.CompletedAt = &completedStr
}
if (item.Status == "downloading" || item.Status == "queued") && item.TorrentHash != nil && torrentService.IsConfigured() {
log.Debug().Str("hash", *item.TorrentHash).Msg("[JOB_STATUS] fetching torrent progress")
torrent, err := torrentService.GetTorrent(ctx, *item.TorrentHash)
if err == nil {
status.Progress = float32(torrent.Progress)
status.SizeLeft = int64(float64(item.Size) * (1 - torrent.Progress))
status.Status = mapTorrentState(torrent.State)
log.Info().
Str("status", status.Status).
Float32("progress", status.Progress).
Msg("[JOB_STATUS] updated from torrent client")
} else {
log.Warn().Err(err).Str("hash", *item.TorrentHash).Msg("[JOB_STATUS] failed to get torrent info")
}
}
log.Info().Str("status", status.Status).Float32("progress", status.Progress).Msg("[JOB_STATUS] returning status")
return status, nil
}
func BlocklistAndRemove(ctx context.Context, db *database.DB, torrentService *TorrentService, queueID uuid.UUID) (*BlocklistResult, error) {
log.Info().Str("queue_id", queueID.String()).Msg("[BLOCKLIST] starting blocklist and remove")
item, err := db.GetDownloadQueueItem(ctx, queueID)
if err != nil {
log.Error().Err(err).Str("queue_id", queueID.String()).Msg("[BLOCKLIST] failed to get queue item")
return nil, err
}
log.Info().Str("title", item.Title).Interface("torrent_hash", item.TorrentHash).Msg("[BLOCKLIST] processing item")
result := &BlocklistResult{}
if item.ArtistID != nil {
@@ -125,23 +257,32 @@ func BlocklistAndRemove(ctx context.Context, db *database.DB, torrentService *To
if albumID == nil {
albumID = &uuid.Nil
}
log.Info().Str("title", item.Title).Msg("[BLOCKLIST] adding to blocklist")
if err := db.AddToBlocklist(ctx, *item.ArtistID, *albumID, item.Title, item.TorrentHash, item.Indexer); err == nil {
result.Blocklisted = true
log.Info().Str("title", item.Title).Msg("[BLOCKLIST] added to blocklist")
} else {
log.Warn().Err(err).Str("title", item.Title).Msg("[BLOCKLIST] failed to add to blocklist")
}
}
if item.TorrentHash != nil && torrentService.IsConfigured() {
log.Info().Str("hash", *item.TorrentHash).Msg("[BLOCKLIST] removing torrent from client")
torrentService.RemoveTorrent(ctx, *item.TorrentHash, true)
}
log.Info().Str("title", item.Title).Msg("[BLOCKLIST] deleting from queue")
if err := db.DeleteDownloadQueueItem(ctx, queueID); err != nil {
log.Error().Err(err).Msg("[BLOCKLIST] failed to delete queue item")
return nil, err
}
result.Removed = true
if item.AlbumID != nil {
log.Info().Str("title", item.Title).Msg("[BLOCKLIST] re-adding album to wanted list")
db.AddToWantedAlbums(ctx, *item.AlbumID)
}
log.Info().Bool("blocklisted", result.Blocklisted).Bool("removed", result.Removed).Msg("[BLOCKLIST] completed")
return result, nil
}
+7
View File
@@ -96,3 +96,10 @@ func (s *TorrentService) ResumeTorrent(ctx context.Context, hash string) error {
func (s *TorrentService) IsConfigured() bool {
return s.client != nil
}
func (s *TorrentService) GetStubClient() *torrent.StubClient {
if stub, ok := s.client.(*torrent.StubClient); ok {
return stub
}
return nil
}