release / govulncheck (push) Successful in 34s
release / web (push) Successful in 1m13s
release / go (push) Successful in 2m12s
release / integration (push) Successful in 4m6s
release / Build signed APK (releases and dev) (push) Successful in 8m41s
release / android (push) Successful in 9m47s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 59s
release / Verify release artifacts (tag releases only) (push) Skipped
Admin → Integrations gains "Indexers and download clients": two lists of
connections the operator adds, Fabled Librarian's model. Nothing is seeded
or copied from Lidarr.
- Indexers: Prowlarr (its native /api/v1/search, which asks every indexer
at once; Prowlarr has no aggregate Torznab feed) and any Torznab feed.
- Download clients: qBittorrent (WebUI v2, with re-login on an expired
session) and FabledExchange, one entry with a switch per job:
downloads (/api/downloads), search (/api/search) and release details
(/api/torrents/{infohash}). All three start on; its key is optional.
- Each entry: enable, name, address, a write-only key or password, Test.
A download client's Test lists its torrents and checks the downloads
folder is visible and writable from Minstrel; an Exchange's Test
reports each job switched on.
- Download clients carry a path mapping (Minstrel's /downloads vs the
client's own name for it); search sources carry searches per hour.
Migration 0079 adds acquisition_services with its CHECKs. Ported from
Fabled Librarian: internal/indexer (Torznab, 429 Retry-After, magnet
redirects), internal/prowlarr, internal/exchange, internal/downloader,
and internal/acquire's one builder per kind, which Test already uses
so a passing Test vouches for the path a grab will take.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
108 lines
2.7 KiB
Go
108 lines
2.7 KiB
Go
package downloader
|
|
|
|
import (
|
|
"context"
|
|
"path"
|
|
"strings"
|
|
)
|
|
|
|
// PathMap is one downloads folder under two names: where Minstrel has it
|
|
// mounted (Local) and where the client sees it (Remote). The two usually run
|
|
// in different containers. Every path crossing between them swaps one prefix
|
|
// for the other.
|
|
type PathMap struct {
|
|
Local string
|
|
Remote string
|
|
}
|
|
|
|
// Active says the two names differ, so there is something to translate.
|
|
func (m PathMap) Active() bool {
|
|
return m.Remote != "" && m.Local != "" && path.Clean(m.Remote) != path.Clean(m.Local)
|
|
}
|
|
|
|
// ToRemote is a Minstrel path as the client names it.
|
|
func (m PathMap) ToRemote(p string) string { return swap(p, m.Local, m.Remote) }
|
|
|
|
// ToLocal is a client's path as Minstrel names it.
|
|
func (m PathMap) ToLocal(p string) string { return swap(p, m.Remote, m.Local) }
|
|
|
|
// swap replaces the prefix from with to on segment boundaries
|
|
// (/downloads2 is not inside /downloads). A path outside from is unchanged.
|
|
func swap(p, from, to string) string {
|
|
if p == "" || from == "" || to == "" {
|
|
return p
|
|
}
|
|
from, to = path.Clean(from), path.Clean(to)
|
|
clean := path.Clean(p)
|
|
if clean == from {
|
|
return to
|
|
}
|
|
if from == "/" {
|
|
return path.Join(to, clean)
|
|
}
|
|
if rest, ok := strings.CutPrefix(clean, from+"/"); ok {
|
|
return path.Join(to, rest)
|
|
}
|
|
return p
|
|
}
|
|
|
|
// Mapped wraps d so every path it is given or answers with is translated.
|
|
// File indices and in-torrent paths need no translation.
|
|
func Mapped(d Driver, m PathMap) Driver {
|
|
if !m.Active() {
|
|
return d
|
|
}
|
|
return &mapped{Driver: d, m: m}
|
|
}
|
|
|
|
type mapped struct {
|
|
Driver
|
|
m PathMap
|
|
}
|
|
|
|
func (c *mapped) AddTorrent(ctx context.Context, a Add) error {
|
|
a.SavePath = c.m.ToRemote(a.SavePath)
|
|
return c.Driver.AddTorrent(ctx, a)
|
|
}
|
|
|
|
func (c *mapped) Torrents(ctx context.Context, hashes ...string) ([]Torrent, error) {
|
|
ts, err := c.Driver.Torrents(ctx, hashes...)
|
|
for i := range ts {
|
|
ts[i].SavePath = c.m.ToLocal(ts[i].SavePath)
|
|
ts[i].ContentPath = c.m.ToLocal(ts[i].ContentPath)
|
|
}
|
|
return ts, err
|
|
}
|
|
|
|
func (c *mapped) SkipFiles(ctx context.Context, hash string, indices []int) error {
|
|
fs, ok := c.Driver.(FileSkipper)
|
|
if !ok {
|
|
return ErrUnsupported
|
|
}
|
|
return fs.SkipFiles(ctx, hash, indices)
|
|
}
|
|
|
|
func (c *mapped) KeepFiles(ctx context.Context, hash string, indices []int) error {
|
|
fs, ok := c.Driver.(FileSkipper)
|
|
if !ok {
|
|
return ErrUnsupported
|
|
}
|
|
return fs.KeepFiles(ctx, hash, indices)
|
|
}
|
|
|
|
func (c *mapped) Files(ctx context.Context, hash string) ([]File, error) {
|
|
fl, ok := c.Driver.(FileLister)
|
|
if !ok {
|
|
return nil, ErrUnsupported
|
|
}
|
|
return fl.Files(ctx, hash)
|
|
}
|
|
|
|
func (c *mapped) FileStates(ctx context.Context, hash string) ([]FileState, error) {
|
|
fp, ok := c.Driver.(FileProgress)
|
|
if !ok {
|
|
return nil, ErrUnsupported
|
|
}
|
|
return fp.FileStates(ctx, hash)
|
|
}
|