Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/gamarr/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ func main() {
if url == "" && result.InfoHash != "" {
url = fmt.Sprintf("magnet:?xt=urn:btih:%s", result.InfoHash)
}
return mgr.DownloadTorrent(url, result.Title, result.Platform, result.PlatformSlug, result.IsPC)
return mgr.DownloadTorrent(url, result.InfoHash, result.Title, result.Platform, result.PlatformSlug, result.IsPC)
}

webhookFn := func() []webhook.WebhookConfig {
Expand Down
2 changes: 1 addition & 1 deletion internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -639,7 +639,7 @@ func (s *Server) handleDownload(w http.ResponseWriter, r *http.Request) {
url = fmt.Sprintf("magnet:?xt=urn:btih:%s", req.InfoHash)
}

jobID, err := s.mgr.DownloadTorrent(url, req.Title, req.Platform, req.PlatformSlug, req.IsPC)
jobID, err := s.mgr.DownloadTorrent(url, req.InfoHash, req.Title, req.Platform, req.PlatformSlug, req.IsPC)
if err != nil {
writeError(w, 400, err.Error())
return
Expand Down
2 changes: 1 addition & 1 deletion internal/api/requests.go
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,7 @@ func (s *Server) handleDownloadForRequest(w http.ResponseWriter, r *http.Request
url = fmt.Sprintf("magnet:?xt=urn:btih:%s", body.InfoHash)
}
var dlErr error
jobID, dlErr = s.mgr.DownloadTorrent(url, body.Title,
jobID, dlErr = s.mgr.DownloadTorrent(url, body.InfoHash, body.Title,
body.Platform, body.PlatformSlug, body.IsPC)
if dlErr != nil {
writeError(w, http.StatusBadRequest, dlErr.Error())
Expand Down
13 changes: 8 additions & 5 deletions internal/download/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,14 +88,15 @@ func newJobID() string {

// DownloadTorrent starts a torrent download.
// Tries clients in order: qBittorrent -> Transmission -> Deluge (first available).
func (m *Manager) DownloadTorrent(url, title, platf, platSlug string, isPC bool) (string, error) {
func (m *Manager) DownloadTorrent(url, infoHash, title, platf, platSlug string, isPC bool) (string, error) {
if url == "" {
return "", fmt.Errorf("no download URL")
}
jobID := newJobID()
m.jobs.Set(jobID, map[string]interface{}{
"status": "downloading",
"title": title,
"info_hash": infoHash,
"platform": platf,
"platform_slug": platSlug,
"is_pc": isPC,
Expand Down Expand Up @@ -156,7 +157,7 @@ func (m *Manager) DownloadTorrent(url, title, platf, platSlug string, isPC bool)
m.jobs.Update(jobID, "detail", fmt.Sprintf("Downloading via %s...", clientUsed))
slog.Info("torrent added", "client", clientUsed, "title", title)

go m.watchGameTorrent(jobID, title, platf, platSlug, isPC)
go m.watchGameTorrent(jobID, infoHash, title, platf, platSlug, isPC)
return jobID, nil
}

Expand Down Expand Up @@ -208,7 +209,7 @@ func (m *Manager) OrganizeTorrent(hash, platf, platSlug string, isPC bool) (stri
return jobID, nil
}

func (m *Manager) watchGameTorrent(jobID, title, platf, platSlug string, isPC bool) {
func (m *Manager) watchGameTorrent(jobID, infoHash, title, platf, platSlug string, isPC bool) {
slog.Info("watching game torrent", "title", title, "platform", platf)
maxWait := 7 * 24 * time.Hour
start := time.Now()
Expand All @@ -218,7 +219,7 @@ func (m *Manager) watchGameTorrent(jobID, title, platf, platSlug string, isPC bo
torrents := m.qb.GetTorrents(m.cfg.QBCategory)
for _, t := range torrents {
tName := t.Name
if !titlesMatch(title, tName) {
if !jobMatchesTorrent(infoHash, title, t.Hash, tName) {
continue
}

Expand Down Expand Up @@ -974,6 +975,7 @@ func (m *Manager) RecoverOrphanedTorrents() {
m.jobs.Set(jobID, map[string]interface{}{
"status": "completed_unorganized",
"title": t.Name,
"info_hash": t.Hash,
"platform": platf,
"platform_slug": platSlug,
"is_pc": isPC,
Expand All @@ -985,13 +987,14 @@ func (m *Manager) RecoverOrphanedTorrents() {
m.jobs.Set(jobID, map[string]interface{}{
"status": "downloading",
"title": t.Name,
"info_hash": t.Hash,
"platform": platf,
"platform_slug": platSlug,
"is_pc": isPC,
"error": nil,
"detail": "Recovered - watching download...",
})
go m.watchGameTorrent(jobID, t.Name, platf, platSlug, isPC)
go m.watchGameTorrent(jobID, t.Hash, t.Name, platf, platSlug, isPC)
slog.Info("recovered in-progress torrent", "name", t.Name, "progress", fmt.Sprintf("%.0f%%", t.Progress*100))
}
}
Expand Down
53 changes: 46 additions & 7 deletions internal/download/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ func TestNewManager(t *testing.T) {
func TestDownloadTorrentValidation(t *testing.T) {
cfg := newTestConfig(t)
m := New(cfg, newTestJobs(t), nil)
if _, err := m.DownloadTorrent("", "Title", "PC", "", true); err == nil {
if _, err := m.DownloadTorrent("", "", "Title", "PC", "", true); err == nil {
t.Fatal("empty URL should return an error")
}
}
Expand All @@ -80,7 +80,7 @@ func TestDownloadTorrentNoClientAvailable(t *testing.T) {
jobs := newTestJobs(t)
m := New(cfg, jobs, nil)

jobID, err := m.DownloadTorrent("magnet:x", "Some Game", "PC", "", true)
jobID, err := m.DownloadTorrent("magnet:x", "", "Some Game", "PC", "", true)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
Expand Down Expand Up @@ -117,7 +117,7 @@ func TestDownloadTorrentQBitFullFlow(t *testing.T) {
}})

m := New(cfg, jobs, qm.client())
jobID, err := m.DownloadTorrent("magnet:x", "Super Game (USA)", "SNES", "snes", false)
jobID, err := m.DownloadTorrent("magnet:x", "", "Super Game (USA)", "SNES", "snes", false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
Expand Down Expand Up @@ -149,6 +149,45 @@ func TestDownloadTorrentQBitFullFlow(t *testing.T) {
}
}

func TestDownloadTorrentTracksRenamedTorrentByHash(t *testing.T) {
// The release title we grabbed and the name the tracker gives the torrent
// need not contain one another, so titlesMatch alone loses the download.
cfg := newTestConfig(t)
jobs := newTestJobs(t)
cfg.QBURL = "configured"

content := filepath.Join(t.TempDir(), "Chrono Quest")
writeFileT(t, filepath.Join(content, "game.sfc"), []byte("rom-data"))

qm := newQbitMock(t)
qm.setFiles([]qbit.TorrentFile{{Name: "Chrono Quest/game.sfc"}})
qm.setTorrents([]qbit.Torrent{{
Name: "Chrono Quest [Repack]",
Hash: "hash-renamed",
Progress: 1.0,
ContentPath: content,
}})

title := "Chrono Quest (v1.2 + Bonus OST, MULTi9) [Repack]"
if titlesMatch(title, "Chrono Quest [Repack]") {
t.Fatal("fixture no longer exercises a title mismatch")
}

m := New(cfg, jobs, qm.client())
jobID, err := m.DownloadTorrent("magnet:x", "hash-renamed", title, "SNES", "snes", false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

job := waitJobStatus(t, jobs, jobID, "completed", 10*time.Second)
if got, _ := job["info_hash"].(string); got != "hash-renamed" {
t.Errorf("job info_hash = %q, want hash-renamed", got)
}
if !jobs.LibraryHasSourceID("torrent:hash-renamed") {
t.Error("library item not tracked")
}
}

func TestDownloadTorrentBlocksDangerousFiles(t *testing.T) {
cfg := newTestConfig(t)
jobs := newTestJobs(t)
Expand All @@ -163,7 +202,7 @@ func TestDownloadTorrentBlocksDangerousFiles(t *testing.T) {
}})

m := New(cfg, jobs, qm.client())
jobID, err := m.DownloadTorrent("magnet:x", "Evil Game", "PC", "", true)
jobID, err := m.DownloadTorrent("magnet:x", "", "Evil Game", "PC", "", true)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
Expand Down Expand Up @@ -199,7 +238,7 @@ func TestDownloadTorrentFallbacks(t *testing.T) {
cfg.TransmissionURL = trSrv.URL

m := New(cfg, jobs, qm.client())
jobID, err := m.DownloadTorrent("magnet:x", "Fallback Game", "PC", "", true)
jobID, err := m.DownloadTorrent("magnet:x", "", "Fallback Game", "PC", "", true)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
Expand Down Expand Up @@ -228,7 +267,7 @@ func TestDownloadTorrentFallbacks(t *testing.T) {
cfg.DelugeURL = dlSrv.URL

m := New(cfg, jobs, qm.client())
jobID, err := m.DownloadTorrent("magnet:x", "Fallback Game 2", "PC", "", true)
jobID, err := m.DownloadTorrent("magnet:x", "", "Fallback Game 2", "PC", "", true)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
Expand All @@ -250,7 +289,7 @@ func TestDownloadTorrentFallbacks(t *testing.T) {
cfg.DelugeURL = deadSrv.URL

m := New(cfg, jobs, qm.client())
jobID, err := m.DownloadTorrent("magnet:x", "Doomed Game", "PC", "", true)
jobID, err := m.DownloadTorrent("magnet:x", "", "Doomed Game", "PC", "", true)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
Expand Down
16 changes: 13 additions & 3 deletions internal/download/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,7 @@ func sanitizeLog(s string) string {

// titlesMatch reports whether a tracked job title and a torrent name refer to
// the same release. Trackers often rename torrents, so it matches
// case-insensitively when either string contains the other. Shared by
// Manager.watchGameTorrent and Watcher.hasMatchingJob so the two cannot
// disagree (a disagreement lets the watcher double-import a job's torrent).
// case-insensitively when either string contains the other.
func titlesMatch(title, torrentName string) bool {
if title == "" || torrentName == "" {
return false
Expand All @@ -56,3 +54,15 @@ func titlesMatch(title, torrentName string) bool {
b := strings.ToLower(torrentName)
return strings.Contains(a, b) || strings.Contains(b, a)
}

// jobMatchesTorrent reports whether a tracked job refers to this torrent. Jobs
// recorded with an infohash match on that alone; rows from before it was stored
// fall back to the title. Shared by Manager.watchGameTorrent and
// Watcher.hasMatchingJob so the two cannot disagree (a disagreement lets the
// watcher double-import a job's torrent).
func jobMatchesTorrent(infoHash, title, torrentHash, torrentName string) bool {
if infoHash != "" {
return strings.EqualFold(infoHash, torrentHash)
}
return titlesMatch(title, torrentName)
}
27 changes: 27 additions & 0 deletions internal/download/util_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,33 @@ func TestTitlesMatch(t *testing.T) {
}
}

func TestJobMatchesTorrent(t *testing.T) {
const terraria = "Terraria (v1.4.5.0 + Bonus OST, MULTi9) [FitGirl Repack]"

tests := []struct {
name string
infoHash, title string
torrentHash, torrentName string
want bool
}{
{"hash matches a renamed torrent", "abc123", terraria, "abc123", "Terraria [FitGirl Repack]", true},
{"hash is case insensitive", "ABC123", terraria, "abc123", "Terraria [FitGirl Repack]", true},
{"hash mismatch is not rescued by the title", "abc123", "Super Game", "def456", "Super Game", false},
{"hashless job falls back to the title", "", "Super Game", "abc123", "Super Game (USA)", true},
{"hashless job with an unrelated title", "", "Super Game", "abc123", "Other Thing", false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := jobMatchesTorrent(tt.infoHash, tt.title, tt.torrentHash, tt.torrentName)
if got != tt.want {
t.Errorf("jobMatchesTorrent(%q, %q, %q, %q) = %v, want %v",
tt.infoHash, tt.title, tt.torrentHash, tt.torrentName, got, tt.want)
}
})
}
}

func TestPlatformNameFromSlug(t *testing.T) {
tests := []struct {
slug string
Expand Down
9 changes: 5 additions & 4 deletions internal/download/watcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,14 @@ func (w *Watcher) checkCompleted() {
}
}

// hasMatchingJob checks if there's already a job tracking this torrent. It
// uses the same fuzzy match as Manager.watchGameTorrent so a torrent renamed
// by the tracker (still claimed by an active job) is not double-imported.
// hasMatchingJob checks if there's already a job tracking this torrent. It uses
// the same match as Manager.watchGameTorrent so a torrent renamed by the tracker
// (still claimed by an active job) is not double-imported.
func (w *Watcher) hasMatchingJob(t qbit.Torrent) bool {
for _, item := range w.mgr.Jobs().Items() {
title, _ := item.Data["title"].(string)
if titlesMatch(title, t.Name) {
infoHash, _ := item.Data["info_hash"].(string)
if jobMatchesTorrent(infoHash, title, t.Hash, t.Name) {
return true
}
}
Expand Down
Loading