feat: indexers and download clients you add and configure (M502 #5518)
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
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>
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user