Files
bvandeusenandClaude Opus 5.5 a2e1890d4c
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
feat: indexers and download clients you add and configure (M502 #5518)
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>
2026-10-09 20:34:51 -04:00

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)
}