Implement MonitorAlbum: search, parse, filter by quality, add best to qbittorrent
This commit is contained in:
+68
-31
@@ -2,10 +2,12 @@ package indexer
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"strconv"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
pb "homelab.lan/music-agregator/gen/music_agregator/indexer/v1"
|
||||
"homelab.lan/music-agregator/internal/release"
|
||||
"homelab.lan/music-agregator/internal/tracker/rutracker"
|
||||
)
|
||||
|
||||
@@ -42,40 +44,80 @@ type TorznabAttr struct {
|
||||
Value string `xml:"value,attr"`
|
||||
}
|
||||
|
||||
type SearchItemResult struct {
|
||||
Title string
|
||||
DownloadLink string
|
||||
TorrentPageUrl string
|
||||
PubDate string
|
||||
Size int64
|
||||
Description string
|
||||
Categories []string
|
||||
Tracker string
|
||||
Seeders int
|
||||
Peers int
|
||||
Attrs map[string]string
|
||||
Release *release.Release
|
||||
}
|
||||
|
||||
func (si *SearchItemResult) ToProto() *pb.SearchItem {
|
||||
var pbAttrs []*pb.TorznabAttr
|
||||
for k, v := range si.Attrs {
|
||||
pbAttrs = append(pbAttrs, &pb.TorznabAttr{Name: k, Value: v})
|
||||
}
|
||||
|
||||
return &pb.SearchItem{
|
||||
Title: si.Title,
|
||||
DownloadLink: si.DownloadLink,
|
||||
TorrentPageUrl: si.TorrentPageUrl,
|
||||
PubDate: si.PubDate,
|
||||
Size: si.Size,
|
||||
Description: si.Description,
|
||||
Categories: si.Categories,
|
||||
TorznabAttrs: pbAttrs,
|
||||
Release: si.Release.ToProto(),
|
||||
}
|
||||
}
|
||||
|
||||
type SearchResponse struct {
|
||||
Items []*SearchItemResult
|
||||
}
|
||||
|
||||
func (sr *SearchResponse) ToProto() *pb.SearchResponse {
|
||||
pbItems := make([]*pb.SearchItem, len(sr.Items))
|
||||
for i, item := range sr.Items {
|
||||
pbItems[i] = item.ToProto()
|
||||
}
|
||||
return &pb.SearchResponse{Result: pbItems}
|
||||
}
|
||||
|
||||
var (
|
||||
rutrackerParserFactory = rutracker.NewRuTrackerParserFactory()
|
||||
)
|
||||
|
||||
func (sr *SearchResult) ToProto() *pb.SearchResponse {
|
||||
var pbItems []*pb.SearchItem
|
||||
var skipped int
|
||||
func (sr *SearchResult) ToSearchResponse() *SearchResponse {
|
||||
var items []*SearchItemResult
|
||||
|
||||
for _, item := range sr.Items {
|
||||
release := rutrackerParserFactory.GetParser(item.Categories).Parse(item.Title)
|
||||
rel := rutrackerParserFactory.GetParser(item.Categories).Parse(item.Title)
|
||||
|
||||
log.Trace().
|
||||
Str("tracker", item.JackettIndexer.ID).
|
||||
Str("title", item.Title).
|
||||
Str("artist", release.Artist).
|
||||
Str("album", release.Album).
|
||||
Int("year", release.Year).
|
||||
Bool("parsed", release.ParsedSuccessfully).
|
||||
Str("artist", rel.Artist).
|
||||
Str("album", rel.Album).
|
||||
Int("year", rel.Year).
|
||||
Bool("parsed", rel.ParsedSuccessfully).
|
||||
Msg("parsed item")
|
||||
|
||||
if !release.ParsedSuccessfully {
|
||||
skipped++
|
||||
continue
|
||||
attrs := make(map[string]string, len(item.TorznabAttrs))
|
||||
for _, attr := range item.TorznabAttrs {
|
||||
attrs[attr.Name] = attr.Value
|
||||
}
|
||||
|
||||
pbAttrs := make([]*pb.TorznabAttr, len(item.TorznabAttrs))
|
||||
for j, attr := range item.TorznabAttrs {
|
||||
pbAttrs[j] = &pb.TorznabAttr{
|
||||
Name: attr.Name,
|
||||
Value: attr.Value,
|
||||
}
|
||||
}
|
||||
seeders, _ := strconv.Atoi(attrs["seeders"])
|
||||
peers, _ := strconv.Atoi(attrs["peers"])
|
||||
|
||||
pbItems = append(pbItems, &pb.SearchItem{
|
||||
items = append(items, &SearchItemResult{
|
||||
Title: item.Title,
|
||||
DownloadLink: item.Link,
|
||||
TorrentPageUrl: item.Guid,
|
||||
@@ -83,23 +125,18 @@ func (sr *SearchResult) ToProto() *pb.SearchResponse {
|
||||
Size: item.Size,
|
||||
Description: item.Description,
|
||||
Categories: item.Categories,
|
||||
Enclosure: &pb.Enclosure{
|
||||
Url: item.Enclosure.URL,
|
||||
Length: item.Enclosure.Length,
|
||||
Type: item.Enclosure.Type,
|
||||
},
|
||||
TorznabAttrs: pbAttrs,
|
||||
Release: release.ToProto(),
|
||||
Tracker: item.JackettIndexer.ID,
|
||||
Seeders: seeders,
|
||||
Peers: peers,
|
||||
Attrs: attrs,
|
||||
Release: rel,
|
||||
})
|
||||
}
|
||||
|
||||
log.Trace().
|
||||
Int("total", len(sr.Items)).
|
||||
Int("parsed", len(pbItems)).
|
||||
Int("skipped", skipped).
|
||||
Int("items", len(items)).
|
||||
Msg("conversion complete")
|
||||
|
||||
return &pb.SearchResponse{
|
||||
Result: pbItems,
|
||||
}
|
||||
return &SearchResponse{Items: items}
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ func (server *IndexerServer) Search(ctx context.Context, req *pb.SearchRequest)
|
||||
|
||||
log.Debug().
|
||||
Str("query", req.GetQuery()).
|
||||
Int("results", len(resp.GetResult())).
|
||||
Int("results", len(resp.Items)).
|
||||
Msg("search completed")
|
||||
|
||||
return resp, nil
|
||||
return resp.ToProto(), nil
|
||||
}
|
||||
|
||||
func (server *IndexerServer) Capabilities(ctx context.Context, req *pb.CapabilitiesRequest) (*pb.CapabilitiesResponse, error) {
|
||||
|
||||
@@ -34,17 +34,16 @@ func NewIndexerService(cfg config.Config, riverClient *river.Client[pgx.Tx]) (*I
|
||||
return &IndexerService{indexer: idx}, nil
|
||||
}
|
||||
|
||||
func (service *IndexerService) Search(query string, limit int32, indexer string) (*pb.SearchResponse, error) {
|
||||
|
||||
func (service *IndexerService) Search(query string, limit int32, indexer string) (*SearchResponse, error) {
|
||||
searchResult, err := service.indexer.Search(query, limit, indexer)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to search in indexer")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Trace().Int("raw_items", len(searchResult.Items)).Msg("indexer returned results, converting to proto")
|
||||
log.Trace().Int("raw_items", len(searchResult.Items)).Msg("indexer returned results")
|
||||
|
||||
return searchResult.ToProto(), nil
|
||||
return searchResult.ToSearchResponse(), nil
|
||||
}
|
||||
|
||||
func (service *IndexerService) Capabilities(req *pb.CapabilitiesRequest) (*pb.CapabilitiesResponse, error) {
|
||||
|
||||
@@ -165,6 +165,15 @@ type Release struct {
|
||||
Label string
|
||||
CatalogNum string
|
||||
|
||||
InfoHash string
|
||||
TrackCount int
|
||||
TrackNames []string
|
||||
AudioFileCount int
|
||||
TotalAudioSize int64
|
||||
HasCoverArt bool
|
||||
HasCueSheet bool
|
||||
HasRipLog bool
|
||||
|
||||
ParsedSuccessfully bool
|
||||
ParseErrors []string
|
||||
}
|
||||
|
||||
+208
-6
@@ -2,6 +2,11 @@ package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/riverqueue/river"
|
||||
@@ -14,13 +19,24 @@ import (
|
||||
"homelab.lan/music-agregator/internal/config"
|
||||
"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"
|
||||
)
|
||||
|
||||
type parsedItem struct {
|
||||
item *indexer.SearchItemResult
|
||||
rel *release.Release
|
||||
torrentData []byte
|
||||
}
|
||||
|
||||
type MusicAgregatorService struct {
|
||||
config config.Config
|
||||
metadataClient metadataPb.MetadataServiceClient
|
||||
metadataConn *grpc.ClientConn
|
||||
indexer *indexer.IndexerService
|
||||
torrentClient torrent.TorrentClient
|
||||
magnetResolver *torrentParser.MagnetResolver
|
||||
}
|
||||
|
||||
func NewMusicAgregatorService(cfg config.Config, riverClient *river.Client[pgx.Tx]) (*MusicAgregatorService, error) {
|
||||
@@ -36,11 +52,25 @@ func NewMusicAgregatorService(cfg config.Config, riverClient *river.Client[pgx.T
|
||||
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,
|
||||
metadataClient: metadataClient,
|
||||
metadataConn: conn,
|
||||
indexer: indexer,
|
||||
torrentClient: torrentClient,
|
||||
magnetResolver: magnetResolver,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -48,6 +78,9 @@ func (s *MusicAgregatorService) Close() {
|
||||
if s.metadataConn != nil {
|
||||
s.metadataConn.Close()
|
||||
}
|
||||
if s.magnetResolver != nil {
|
||||
s.magnetResolver.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (service *MusicAgregatorService) MonitorAlbum(ctx context.Context, req *pb.MonitorAlbumRequest) (*pb.MonitorAlbumResponse, error) {
|
||||
@@ -75,17 +108,186 @@ func (service *MusicAgregatorService) MonitorAlbum(ctx context.Context, req *pb.
|
||||
query = artistName + " " + query
|
||||
}
|
||||
|
||||
tracker := req.GetIndexerOptions().GetTracker()
|
||||
if tracker == "" {
|
||||
tracker = "all"
|
||||
trackerName := req.GetIndexerOptions().GetTracker()
|
||||
if trackerName == "" {
|
||||
trackerName = "all"
|
||||
}
|
||||
|
||||
searchResult, err := service.indexer.Search(query, -1, tracker)
|
||||
searchResult, err := service.indexer.Search(query, -1, trackerName)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("query", query).Msg("indexer search album failed")
|
||||
return nil, err
|
||||
}
|
||||
log.Debug().Int("results", len(searchResult.GetResult())).Str("query", query).Msg("indexer search completed")
|
||||
log.Debug().Int("results", len(searchResult.Items)).Str("query", query).Msg("indexer search completed")
|
||||
|
||||
return &pb.MonitorAlbumResponse{}, nil
|
||||
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
|
||||
}
|
||||
|
||||
var r *release.Release
|
||||
var torrentData []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")
|
||||
r = parser.Parse(item.Title)
|
||||
} else {
|
||||
r = parser.ParseTorrent(torrentData, album)
|
||||
}
|
||||
} else {
|
||||
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")
|
||||
r = parser.Parse(item.Title)
|
||||
} else {
|
||||
r = parser.ParseTorrent(torrentData, 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")
|
||||
|
||||
qualityType := req.GetQuality()
|
||||
var filtered []parsedItem
|
||||
for _, p := range parsed {
|
||||
match := qualityType == pb.QualityType_QUALITY_UNSPECIFIED ||
|
||||
(qualityType == pb.QualityType_QUALITY_LOSSLESS && p.rel.Format.IsLossless()) ||
|
||||
(qualityType == 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", qualityType.String()).Msg("filtered out by quality")
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, p)
|
||||
}
|
||||
|
||||
if len(filtered) == 0 {
|
||||
log.Warn().Str("query", query).Str("quality", qualityType.String()).Msg("no releases match quality filter")
|
||||
return &pb.MonitorAlbumResponse{}, nil
|
||||
}
|
||||
|
||||
best := filtered[0]
|
||||
for _, p := range filtered[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, adding to torrent client")
|
||||
|
||||
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).
|
||||
Str("artist", best.rel.Artist).
|
||||
Str("album", best.rel.Album).
|
||||
Str("format", best.rel.Format.String()).
|
||||
Int("tracks", best.rel.TrackCount).
|
||||
Msg("torrent already exists in client")
|
||||
resp := buildMonitoredRelease(best)
|
||||
log.Trace().Interface("response", resp).Msg("returning existing torrent response")
|
||||
return &pb.MonitorAlbumResponse{
|
||||
Release: resp,
|
||||
}, 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 nil, err
|
||||
}
|
||||
} else {
|
||||
if len(best.torrentData) == 0 {
|
||||
log.Error().Str("title", best.item.Title).Msg("no torrent data available")
|
||||
return nil, 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 nil, err
|
||||
}
|
||||
}
|
||||
|
||||
log.Info().Str("title", best.item.Title).Str("hash", best.rel.InfoHash).Msg("torrent added to client")
|
||||
|
||||
return &pb.MonitorAlbumResponse{
|
||||
Release: buildMonitoredRelease(best),
|
||||
}, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -43,5 +43,6 @@ type TorrentClient interface {
|
||||
Login(username string, password string) (string, error)
|
||||
List() ([]TorrentInfo, error)
|
||||
Find(opts FindOptions) ([]TorrentInfo, error)
|
||||
Add(file TorrentFile) error
|
||||
AddTorrent(file TorrentFile) error
|
||||
AddMagnet(magnetURI string) error
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package torrent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"homelab.lan/music-agregator/internal/config"
|
||||
)
|
||||
|
||||
func NewTorrentClient(cfg config.Config) (TorrentClient, error) {
|
||||
var client TorrentClient
|
||||
|
||||
switch cfg.Torrent.ClientType {
|
||||
case config.TorrentClientQbittorrent:
|
||||
client = NewQbittorrentClient(cfg.Torrent.Url)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown torrent client type: %s", cfg.Torrent.ClientType)
|
||||
}
|
||||
|
||||
if _, err := client.Login(cfg.Torrent.Username, cfg.Torrent.Password); err != nil {
|
||||
return nil, fmt.Errorf("torrent client login failed: %w", err)
|
||||
}
|
||||
|
||||
log.Info().Str("client", string(cfg.Torrent.ClientType)).Str("url", cfg.Torrent.Url).Msg("torrent client connected")
|
||||
|
||||
return client, nil
|
||||
}
|
||||
@@ -173,8 +173,8 @@ func filterLocally(torrents []TorrentInfo, opts FindOptions) []TorrentInfo {
|
||||
return result
|
||||
}
|
||||
|
||||
func (c *QbittorrentClient) Add(file TorrentFile) error {
|
||||
log.Trace().Str("filename", file.Filename).Int("size", len(file.Data)).Msg("qbittorrent adding torrent")
|
||||
func (c *QbittorrentClient) AddTorrent(file TorrentFile) error {
|
||||
log.Trace().Str("filename", file.Filename).Int("size", len(file.Data)).Msg("qbittorrent adding torrent file")
|
||||
|
||||
var buf bytes.Buffer
|
||||
writer := multipart.NewWriter(&buf)
|
||||
@@ -202,6 +202,29 @@ func (c *QbittorrentClient) Add(file TorrentFile) error {
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.AddCookie(&http.Cookie{Name: "SID", Value: c.sid})
|
||||
|
||||
return c.doAdd(req, file.Filename)
|
||||
}
|
||||
|
||||
func (c *QbittorrentClient) AddMagnet(magnetURI string) error {
|
||||
truncated := magnetURI
|
||||
if len(truncated) > 80 {
|
||||
truncated = truncated[:80] + "..."
|
||||
}
|
||||
log.Trace().Str("magnet", truncated).Msg("qbittorrent adding magnet")
|
||||
|
||||
data := url.Values{"urls": {magnetURI}}
|
||||
req, err := http.NewRequest("POST", c.baseURL+"/api/v2/torrents/add", strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("qbittorrent creating magnet add request failed")
|
||||
return fmt.Errorf("creating magnet add request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(&http.Cookie{Name: "SID", Value: c.sid})
|
||||
|
||||
return c.doAdd(req, truncated)
|
||||
}
|
||||
|
||||
func (c *QbittorrentClient) doAdd(req *http.Request, label string) error {
|
||||
start := time.Now()
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
@@ -219,12 +242,11 @@ func (c *QbittorrentClient) Add(file TorrentFile) error {
|
||||
log.Trace().Int("status", resp.StatusCode).Dur("duration", time.Since(start)).Msg("qbittorrent add response")
|
||||
|
||||
if resp.StatusCode != http.StatusOK || string(body) != "Ok." {
|
||||
log.Error().Int("status", resp.StatusCode).Str("body", string(body)).Msg("qbittorrent add torrent failed")
|
||||
return fmt.Errorf("add torrent failed: status %d, body: %s", resp.StatusCode, string(body))
|
||||
log.Error().Int("status", resp.StatusCode).Str("body", string(body)).Msg("qbittorrent add failed")
|
||||
return fmt.Errorf("add failed: status %d, body: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
log.Info().Str("filename", file.Filename).Msg("qbittorrent torrent added")
|
||||
|
||||
log.Info().Str("label", label).Msg("qbittorrent torrent added")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ func (service *TorrentService) Add(req *pb.AddRequest) (*pb.AddResponse, error)
|
||||
return nil, fmt.Errorf("either torrent_data or download_url must be provided")
|
||||
}
|
||||
|
||||
if err := service.client.Add(file); err != nil {
|
||||
if err := service.client.AddTorrent(file); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
package tracker
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/anacrolix/torrent/metainfo"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
metadataPb "homelab.lan/music-agregator/gen/metadata/v1"
|
||||
"homelab.lan/music-agregator/internal/release"
|
||||
)
|
||||
|
||||
var (
|
||||
bitratePattern = regexp.MustCompile(`(?i)(\d{2,3})\s*kbps`)
|
||||
hiResPatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?i)(\d{1,2})\s*[Bb]it\s*[-/]?\s*(\d{2,3}(?:\.\d)?)\s*[kK][Hh][Zz]`),
|
||||
regexp.MustCompile(`(?i)\[?\s*(?:FLAC|Flac)\s+(\d{1,2})\s*[-/]\s*(\d{2,3}(?:\.\d)?)\s*\]?`),
|
||||
regexp.MustCompile(`(?i)\[?\s*(\d{1,2})\s*[Bb]it\s*\]?`),
|
||||
}
|
||||
sourcePattern = regexp.MustCompile(`(?i)\[(CD|WEB|Vinyl|LP|Cassette|MC|DVD|Blu-?Ray|SACD|DAT)\]`)
|
||||
ripTypePattern = regexp.MustCompile(`(?i)(vinyl\s*rip|SACD[- ]?R|HDCD|DSD\d*|tape\s*rip)`)
|
||||
)
|
||||
|
||||
var audioExtensions = map[string]release.AudioFormat{
|
||||
".flac": release.FormatFLAC,
|
||||
".mp3": release.FormatMP3,
|
||||
".aac": release.FormatAAC,
|
||||
".m4a": release.FormatAAC,
|
||||
".ape": release.FormatAPE,
|
||||
".wv": release.FormatWavPack,
|
||||
".alac": release.FormatALAC,
|
||||
".ogg": release.FormatOGG,
|
||||
".wav": release.FormatWAV,
|
||||
}
|
||||
|
||||
type GenericParser struct{}
|
||||
|
||||
func NewGenericParser() *GenericParser {
|
||||
return &GenericParser{}
|
||||
}
|
||||
|
||||
func (p *GenericParser) ParseTorrent(torrentData []byte, album *metadataPb.Album) *release.Release {
|
||||
r := &release.Release{}
|
||||
|
||||
p.fillFromMetadata(r, album)
|
||||
p.fillFromTorrent(r, torrentData)
|
||||
|
||||
r.ParsedSuccessfully = r.Artist != "" && r.Album != ""
|
||||
if !r.ParsedSuccessfully {
|
||||
r.ParseErrors = append(r.ParseErrors, "missing artist or album")
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func (p *GenericParser) Parse(title string) *release.Release {
|
||||
r := &release.Release{RawTitle: title}
|
||||
|
||||
p.fillFromTitle(r, title)
|
||||
|
||||
r.ParsedSuccessfully = r.Artist != "" && r.Album != ""
|
||||
if !r.ParsedSuccessfully {
|
||||
r.ParseErrors = append(r.ParseErrors, "missing artist or album")
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func (p *GenericParser) fillFromMetadata(r *release.Release, album *metadataPb.Album) {
|
||||
if album == nil {
|
||||
return
|
||||
}
|
||||
|
||||
r.Album = album.GetTitle()
|
||||
|
||||
if len(album.GetArtists()) > 0 {
|
||||
r.Artist = album.GetArtists()[0].GetArtist().GetName()
|
||||
}
|
||||
|
||||
if album.GetReleaseDate() != "" {
|
||||
if year, err := strconv.Atoi(album.GetReleaseDate()[:4]); err == nil {
|
||||
r.Year = year
|
||||
}
|
||||
}
|
||||
|
||||
switch strings.ToLower(album.GetAlbumType()) {
|
||||
case "album":
|
||||
r.Type = release.TypeAlbum
|
||||
case "ep":
|
||||
r.Type = release.TypeEP
|
||||
case "single":
|
||||
r.Type = release.TypeSingle
|
||||
case "compilation":
|
||||
r.Type = release.TypeCompilation
|
||||
case "soundtrack":
|
||||
r.Type = release.TypeSoundtrack
|
||||
case "live":
|
||||
r.Type = release.TypeLive
|
||||
}
|
||||
|
||||
for _, g := range album.GetGenres() {
|
||||
r.Genres = append(r.Genres, g.GetName())
|
||||
}
|
||||
|
||||
if album.GetLabel() != nil {
|
||||
r.Label = album.GetLabel().GetName()
|
||||
}
|
||||
|
||||
r.TrackCount = int(album.GetTotalTracks())
|
||||
r.ReleaseCount = int(album.GetTotalDiscs())
|
||||
|
||||
log.Trace().
|
||||
Str("artist", r.Artist).
|
||||
Str("album", r.Album).
|
||||
Int("year", r.Year).
|
||||
Str("type", r.Type.String()).
|
||||
Msg("filled from metadata")
|
||||
}
|
||||
|
||||
func (p *GenericParser) fillFromTorrent(r *release.Release, torrentData []byte) {
|
||||
if len(torrentData) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
mi, err := metainfo.Load(strings.NewReader(string(torrentData)))
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to parse torrent data")
|
||||
r.ParseErrors = append(r.ParseErrors, "failed to parse torrent: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
info, err := mi.UnmarshalInfo()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to unmarshal torrent info")
|
||||
r.ParseErrors = append(r.ParseErrors, "failed to unmarshal torrent info: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
r.RawTitle = info.Name
|
||||
r.InfoHash = mi.HashInfoBytes().HexString()
|
||||
|
||||
formatCounts := make(map[release.AudioFormat]int)
|
||||
formatSizes := make(map[release.AudioFormat]int64)
|
||||
|
||||
if len(info.Files) == 0 {
|
||||
ext := strings.ToLower(filepath.Ext(info.Name))
|
||||
if fmt, ok := audioExtensions[ext]; ok {
|
||||
r.Format = fmt
|
||||
r.AudioFileCount = 1
|
||||
r.TotalAudioSize = info.Length
|
||||
}
|
||||
} else {
|
||||
for _, f := range info.Files {
|
||||
path := filepath.Join(f.Path...)
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
name := strings.TrimSuffix(filepath.Base(path), ext)
|
||||
|
||||
if fmt, ok := audioExtensions[ext]; ok {
|
||||
formatCounts[fmt]++
|
||||
formatSizes[fmt] += f.Length
|
||||
r.TrackNames = append(r.TrackNames, cleanTrackName(name))
|
||||
}
|
||||
|
||||
switch ext {
|
||||
case ".jpg", ".jpeg", ".png":
|
||||
r.HasCoverArt = true
|
||||
case ".cue":
|
||||
r.HasCueSheet = true
|
||||
case ".log":
|
||||
r.HasRipLog = true
|
||||
}
|
||||
}
|
||||
|
||||
var dominantFormat release.AudioFormat
|
||||
var maxCount int
|
||||
for fmt, count := range formatCounts {
|
||||
if count > maxCount {
|
||||
maxCount = count
|
||||
dominantFormat = fmt
|
||||
}
|
||||
}
|
||||
r.Format = dominantFormat
|
||||
r.AudioFileCount = maxCount
|
||||
r.TotalAudioSize = formatSizes[dominantFormat]
|
||||
}
|
||||
|
||||
if r.HasRipLog {
|
||||
r.Source = release.SourceCD
|
||||
}
|
||||
|
||||
if r.TrackCount == 0 {
|
||||
r.TrackCount = r.AudioFileCount
|
||||
}
|
||||
|
||||
p.fillFromTitle(r, info.Name)
|
||||
|
||||
log.Trace().
|
||||
Str("hash", r.InfoHash).
|
||||
Str("format", r.Format.String()).
|
||||
Int("audio_files", r.AudioFileCount).
|
||||
Int64("audio_size", r.TotalAudioSize).
|
||||
Bool("cover", r.HasCoverArt).
|
||||
Bool("cue", r.HasCueSheet).
|
||||
Bool("log", r.HasRipLog).
|
||||
Msg("filled from torrent")
|
||||
}
|
||||
|
||||
func (p *GenericParser) fillFromTitle(r *release.Release, title string) {
|
||||
if title == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if m := bitratePattern.FindStringSubmatch(title); len(m) > 1 {
|
||||
r.Bitrate = m[1] + " kbps"
|
||||
}
|
||||
|
||||
for _, pattern := range hiResPatterns {
|
||||
m := pattern.FindStringSubmatch(title)
|
||||
if len(m) < 2 {
|
||||
continue
|
||||
}
|
||||
if r.BitDepth == 0 {
|
||||
if bd, err := strconv.Atoi(m[1]); err == nil {
|
||||
r.BitDepth = bd
|
||||
}
|
||||
}
|
||||
if len(m) > 2 && r.SampleRate == 0 {
|
||||
if sr, err := strconv.ParseFloat(m[2], 64); err == nil {
|
||||
r.SampleRate = int(sr * 1000)
|
||||
}
|
||||
}
|
||||
if r.BitDepth > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if m := sourcePattern.FindStringSubmatch(title); len(m) > 1 && r.Source == release.SourceUnknown {
|
||||
switch strings.ToUpper(m[1]) {
|
||||
case "CD":
|
||||
r.Source = release.SourceCD
|
||||
case "WEB":
|
||||
r.Source = release.SourceWEB
|
||||
case "VINYL", "LP":
|
||||
r.Source = release.SourceVinyl
|
||||
case "CASSETTE", "MC":
|
||||
r.Source = release.SourceCassette
|
||||
case "DVD":
|
||||
r.Source = release.SourceDVD
|
||||
case "BLU-RAY", "BLURAY":
|
||||
r.Source = release.SourceBluRay
|
||||
}
|
||||
}
|
||||
|
||||
if m := ripTypePattern.FindStringSubmatch(title); len(m) > 1 {
|
||||
r.RipType = m[1]
|
||||
}
|
||||
|
||||
log.Trace().
|
||||
Str("bitrate", r.Bitrate).
|
||||
Int("bit_depth", r.BitDepth).
|
||||
Int("sample_rate", r.SampleRate).
|
||||
Str("source", r.Source.String()).
|
||||
Str("rip_type", r.RipType).
|
||||
Msg("filled from title")
|
||||
}
|
||||
|
||||
var trackNumberPrefix = regexp.MustCompile(`^\d{1,3}[\s.\-]+`)
|
||||
|
||||
func cleanTrackName(name string) string {
|
||||
cleaned := trackNumberPrefix.ReplaceAllString(name, "")
|
||||
if cleaned == "" {
|
||||
return name
|
||||
}
|
||||
return strings.TrimSpace(cleaned)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package tracker
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
metadataPb "homelab.lan/music-agregator/gen/metadata/v1"
|
||||
"homelab.lan/music-agregator/internal/release"
|
||||
)
|
||||
|
||||
func TestGenericParser_Parse(t *testing.T) {
|
||||
p := NewGenericParser()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
title string
|
||||
wantBitrate string
|
||||
wantBitDepth int
|
||||
wantSampleRate int
|
||||
wantSource release.Source
|
||||
wantRipType string
|
||||
}{
|
||||
{
|
||||
name: "discography no hires",
|
||||
title: "System Of A Down - Discography [FLAC Songs] [PMEDIA]",
|
||||
},
|
||||
{
|
||||
name: "hiphop hires 24-44",
|
||||
title: "Snoop Dogg - 10 Til' Midnight (2026 Hip Hop Rap) [Flac 24-44]",
|
||||
wantBitDepth: 24,
|
||||
wantSampleRate: 44000,
|
||||
},
|
||||
{
|
||||
name: "pop hires 24bit",
|
||||
title: "Sabrina Carpenter - Short n' Sweet [Deluxe] [2025] [Hi-Res FLAC 24bit]-Sc4r3cr0w",
|
||||
wantBitDepth: 24,
|
||||
},
|
||||
{
|
||||
name: "rock hires 24bit",
|
||||
title: "Linkin Park - From Zero [Deluxe Edition] [2025] [Hi-Res] [FLAC-24bit]-Sc4r3cr0w",
|
||||
},
|
||||
{
|
||||
name: "rock hires 24-48",
|
||||
title: "Linkin Park - From Zero (2024) [24Bit-48kHz] FLAC [PMEDIA]",
|
||||
wantBitDepth: 24,
|
||||
wantSampleRate: 48000,
|
||||
},
|
||||
{
|
||||
name: "hiphop hires 24-96",
|
||||
title: "J. Cole - The Fall-Off (2026 Hip Hop Rap) [Flac 24-96]",
|
||||
wantBitDepth: 24,
|
||||
wantSampleRate: 96000,
|
||||
},
|
||||
{
|
||||
name: "minimal format",
|
||||
title: "Bjork-Bastards.2012.FLAC-NewAlbumReleases",
|
||||
},
|
||||
{
|
||||
name: "vinyl hires",
|
||||
title: "Gorillaz - Demon Days [Live From The Apollo Theater] [2025] [Vinyl Hi-Res] [FLAC-24bit]-Sc4r3cr0w",
|
||||
},
|
||||
{
|
||||
name: "cd with log",
|
||||
title: "Linkin Park - Meteora (Tracks, Log, Cue, Scans) (2003) [FLAC] 88",
|
||||
},
|
||||
{
|
||||
name: "rock 16-44",
|
||||
title: "Heart - Jupiters Darling (2004 Rock) [Flac 16-44]",
|
||||
wantBitDepth: 16,
|
||||
wantSampleRate: 44000,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := p.Parse(tt.title)
|
||||
|
||||
if tt.wantBitrate != "" && r.Bitrate != tt.wantBitrate {
|
||||
t.Errorf("Bitrate = %q, want %q", r.Bitrate, tt.wantBitrate)
|
||||
}
|
||||
if tt.wantBitDepth != 0 && r.BitDepth != tt.wantBitDepth {
|
||||
t.Errorf("BitDepth = %d, want %d", r.BitDepth, tt.wantBitDepth)
|
||||
}
|
||||
if tt.wantSampleRate != 0 && r.SampleRate != tt.wantSampleRate {
|
||||
t.Errorf("SampleRate = %d, want %d", r.SampleRate, tt.wantSampleRate)
|
||||
}
|
||||
if tt.wantSource != release.SourceUnknown && r.Source != tt.wantSource {
|
||||
t.Errorf("Source = %v, want %v", r.Source, tt.wantSource)
|
||||
}
|
||||
if tt.wantRipType != "" && r.RipType != tt.wantRipType {
|
||||
t.Errorf("RipType = %q, want %q", r.RipType, tt.wantRipType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenericParser_ParseTorrent(t *testing.T) {
|
||||
torrentData, err := os.ReadFile("/tmp/metallica.torrent")
|
||||
if err != nil {
|
||||
t.Skip("metallica.torrent not available")
|
||||
}
|
||||
|
||||
album := &metadataPb.Album{
|
||||
Title: "72 Seasons",
|
||||
AlbumType: "Album",
|
||||
ReleaseDate: "2023-04-14",
|
||||
TotalTracks: 12,
|
||||
TotalDiscs: 1,
|
||||
Artists: []*metadataPb.ArtistCredit{
|
||||
{Artist: &metadataPb.Artist{Name: "Metallica"}},
|
||||
},
|
||||
Genres: []*metadataPb.Genre{
|
||||
{Name: "Thrash Metal"},
|
||||
{Name: "Heavy Metal"},
|
||||
},
|
||||
Label: &metadataPb.Label{Name: "Blackened Recordings"},
|
||||
}
|
||||
|
||||
p := NewGenericParser()
|
||||
r := p.ParseTorrent(torrentData, album)
|
||||
|
||||
t.Logf("Artist: %s", r.Artist)
|
||||
t.Logf("Album: %s", r.Album)
|
||||
t.Logf("Year: %d", r.Year)
|
||||
t.Logf("Type: %s", r.Type)
|
||||
t.Logf("Genres: %v", r.Genres)
|
||||
t.Logf("Format: %s", r.Format)
|
||||
t.Logf("Source: %s", r.Source)
|
||||
t.Logf("Label: %s", r.Label)
|
||||
t.Logf("InfoHash: %s", r.InfoHash)
|
||||
t.Logf("TrackCount: %d", r.TrackCount)
|
||||
t.Logf("AudioFiles: %d", r.AudioFileCount)
|
||||
t.Logf("AudioSize: %d bytes", r.TotalAudioSize)
|
||||
t.Logf("HasCover: %v", r.HasCoverArt)
|
||||
t.Logf("HasCue: %v", r.HasCueSheet)
|
||||
t.Logf("HasLog: %v", r.HasRipLog)
|
||||
t.Logf("TrackNames: %v", r.TrackNames)
|
||||
t.Logf("Parsed OK: %v", r.ParsedSuccessfully)
|
||||
t.Logf("Errors: %v", r.ParseErrors)
|
||||
|
||||
if r.Artist != "Metallica" {
|
||||
t.Errorf("Artist = %q, want Metallica", r.Artist)
|
||||
}
|
||||
if r.Album != "72 Seasons" {
|
||||
t.Errorf("Album = %q, want 72 Seasons", r.Album)
|
||||
}
|
||||
if r.Year != 2023 {
|
||||
t.Errorf("Year = %d, want 2023", r.Year)
|
||||
}
|
||||
if r.Format != release.FormatFLAC {
|
||||
t.Errorf("Format = %v, want FLAC", r.Format)
|
||||
}
|
||||
if r.AudioFileCount != 12 {
|
||||
t.Errorf("AudioFileCount = %d, want 12", r.AudioFileCount)
|
||||
}
|
||||
if !r.HasCoverArt {
|
||||
t.Error("expected HasCoverArt")
|
||||
}
|
||||
if !r.HasCueSheet {
|
||||
t.Error("expected HasCueSheet")
|
||||
}
|
||||
if !r.HasRipLog {
|
||||
t.Error("expected HasRipLog")
|
||||
}
|
||||
if r.Source != release.SourceCD {
|
||||
t.Errorf("Source = %v, want CD (inferred from log)", r.Source)
|
||||
}
|
||||
if !r.ParsedSuccessfully {
|
||||
t.Errorf("ParsedSuccessfully = false, errors: %v", r.ParseErrors)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package tracker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/anacrolix/torrent"
|
||||
"github.com/anacrolix/torrent/bencode"
|
||||
"github.com/anacrolix/torrent/metainfo"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type MagnetResolver struct {
|
||||
client *torrent.Client
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
func NewMagnetResolver(timeout time.Duration) (*MagnetResolver, error) {
|
||||
cfg := torrent.NewDefaultClientConfig()
|
||||
cfg.DataDir = ""
|
||||
cfg.NoDHT = false
|
||||
cfg.NoUpload = true
|
||||
cfg.Seed = false
|
||||
cfg.ListenPort = 0
|
||||
|
||||
client, err := torrent.NewClient(cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating torrent client: %w", err)
|
||||
}
|
||||
|
||||
log.Info().Dur("timeout", timeout).Msg("magnet resolver initialized")
|
||||
|
||||
return &MagnetResolver{
|
||||
client: client,
|
||||
timeout: timeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *MagnetResolver) Resolve(magnetURI string) ([]byte, error) {
|
||||
truncated := magnetURI
|
||||
if len(truncated) > 80 {
|
||||
truncated = truncated[:80] + "..."
|
||||
}
|
||||
log.Trace().Str("magnet", truncated).Msg("resolving magnet")
|
||||
|
||||
t, err := r.client.AddMagnet(magnetURI)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("adding magnet: %w", err)
|
||||
}
|
||||
defer t.Drop()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), r.timeout)
|
||||
defer cancel()
|
||||
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
noActiveSince := time.Now()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-t.GotInfo():
|
||||
ticker.Stop()
|
||||
goto resolved
|
||||
case <-ctx.Done():
|
||||
stats := t.Stats()
|
||||
log.Warn().
|
||||
Str("hash", t.InfoHash().HexString()).
|
||||
Int("total_peers", stats.TotalPeers).
|
||||
Int("active_peers", stats.ActivePeers).
|
||||
Int("pending_peers", stats.PendingPeers).
|
||||
Int("half_open_peers", stats.HalfOpenPeers).
|
||||
Int("connected_seeders", stats.ConnectedSeeders).
|
||||
Msg("magnet resolve timed out")
|
||||
return nil, fmt.Errorf("timeout resolving magnet after %s: peers=%d active=%d seeders=%d",
|
||||
r.timeout, stats.TotalPeers, stats.ActivePeers, stats.ConnectedSeeders)
|
||||
case <-ticker.C:
|
||||
stats := t.Stats()
|
||||
log.Trace().
|
||||
Str("hash", t.InfoHash().HexString()).
|
||||
Int("total_peers", stats.TotalPeers).
|
||||
Int("active_peers", stats.ActivePeers).
|
||||
Int("connected_seeders", stats.ConnectedSeeders).
|
||||
Msg("magnet resolve waiting")
|
||||
|
||||
if stats.ActivePeers > 0 {
|
||||
noActiveSince = time.Now()
|
||||
}
|
||||
|
||||
if stats.TotalPeers > 0 && time.Since(noActiveSince) > 15*time.Second {
|
||||
log.Warn().
|
||||
Str("hash", t.InfoHash().HexString()).
|
||||
Int("total_peers", stats.TotalPeers).
|
||||
Int("active_peers", stats.ActivePeers).
|
||||
Msg("magnet has peers but none active for 15s, giving up early")
|
||||
return nil, fmt.Errorf("no active peers after 15s: total=%d active=%d", stats.TotalPeers, stats.ActivePeers)
|
||||
}
|
||||
}
|
||||
}
|
||||
resolved:
|
||||
|
||||
info := t.Info()
|
||||
log.Debug().
|
||||
Str("name", info.Name).
|
||||
Int("files", len(info.Files)).
|
||||
Int64("size", info.TotalLength()).
|
||||
Msg("magnet resolved")
|
||||
|
||||
mi := t.Metainfo()
|
||||
data, err := bencode.Marshal(metainfo.MetaInfo{
|
||||
InfoBytes: mi.InfoBytes,
|
||||
Announce: mi.Announce,
|
||||
AnnounceList: mi.AnnounceList,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshaling torrent data: %w", err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (r *MagnetResolver) Close() {
|
||||
r.client.Close()
|
||||
}
|
||||
Reference in New Issue
Block a user