package acquire import ( "context" "errors" "fmt" "os" "strings" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/exchange" "git.fabledsword.com/bvandeusen/minstrel/internal/indexer" ) // TestResult is what the settings card says after Test. type TestResult struct { OK bool `json:"ok"` Detail string `json:"detail"` } // testQuery is a search every music indexer should answer. const testQuery = "Radiohead" // Test reaches the service a row describes, through the same builders real // traffic uses. It runs whether or not the row is switched on: paste the // address, test it, then switch it on. func Test(ctx context.Context, row dbq.AcquisitionService) TestResult { if strings.TrimSpace(row.BaseUrl) == "" { return TestResult{Detail: "No address is set, so there is nothing to reach yet."} } if row.Protocol == ProtocolExchange { return testExchange(ctx, row) } if row.Kind == KindSearch { return testSearch(ctx, row) } return testDownload(ctx, row) } // testExchange runs each job the row has switched on and reports them all. func testExchange(ctx context.Context, row dbq.AcquisitionService) TestResult { var parts []string ok := true run := func(name string, on bool, test func() TestResult) { if !on { return } r := test() ok = ok && r.OK parts = append(parts, name+": "+r.Detail) } run("Downloads", row.ServesDownloads, func() TestResult { return testDownload(ctx, row) }) run("Search", row.ServesSearch, func() TestResult { return testSearch(ctx, row) }) run("Release details", row.ServesEnrichment, func() TestResult { return testEnrichment(ctx, row) }) if len(parts) == 0 { return TestResult{Detail: "Every job of this FabledExchange is switched off, so there is nothing to test."} } return TestResult{OK: ok, Detail: strings.Join(parts, " ")} } // testSearch runs a real search. func testSearch(ctx context.Context, row dbq.AcquisitionService) TestResult { src := SourceFor(row) results, err := src.Search.Search(ctx, indexer.Query{Text: testQuery, Categories: indexer.MusicCategories}) switch { case errors.Is(err, indexer.ErrUnconfigured): return TestResult{Detail: "Add this service's address, and the API key it shows on its own settings page."} case errors.Is(err, indexer.ErrAuth): return TestResult{Detail: indexer.FirstLine(err.Error()) + ". Check it on the service's own settings page."} case err != nil: return TestResult{Detail: indexer.FirstLine(err.Error())} case len(results) == 0: return TestResult{OK: true, Detail: "Connected, but a test search for music found nothing. Check that it has an indexer with music categories."} } return TestResult{OK: true, Detail: fmt.Sprintf("Connected. %d results for a test search.", len(results))} } // testDownload lists the client's torrents, which proves the session and not // only the login, then checks the downloads folder from Minstrel's side. func testDownload(ctx context.Context, row dbq.AcquisitionService) TestResult { c, err := DownloadClient(row) if err != nil { return TestResult{Detail: err.Error()} } ts, err := c.Torrents(ctx) if err != nil { return TestResult{Detail: indexer.FirstLine(err.Error())} } detail := fmt.Sprintf("Connected. %d torrents.", len(ts)) if row.Category != "" { detail = fmt.Sprintf("Connected. %d torrents in the %q category.", len(ts), row.Category) } if up, err := c.Connected(ctx); err == nil && !up { detail += " The client reports it is not connected to any peers, so downloads will stall." } if problem := landingProblem(row.LocalPath); problem != "" { return TestResult{Detail: detail + " " + problem} } return TestResult{OK: true, Detail: detail} } // landingProblem checks Minstrel can see and write the downloads folder: it // moves finished downloads out of it. Only the local path can be checked; // the client's own name for it cannot be seen from here. func landingProblem(dir string) string { info, err := os.Stat(dir) switch { case err != nil: return fmt.Sprintf("But Minstrel cannot see its downloads folder at %s. Mount the download client's downloads folder there, or change where Minstrel looks for it.", dir) case !info.IsDir(): return fmt.Sprintf("But %s is a file, not the downloads folder.", dir) } probe, err := os.CreateTemp(dir, ".minstrel-probe-*") if err != nil { return fmt.Sprintf("But Minstrel cannot write to %s, so it could not move a finished download out of it. Check the mount is not read-only and Minstrel's user can write there.", dir) } name := probe.Name() _ = probe.Close() _ = os.Remove(name) return "" } // testEnrichment asks about a hash no torrent has. A JSON "not crawled" is a // healthy answer; a 404 from something that is not Exchange is not. func testEnrichment(ctx context.Context, row dbq.AcquisitionService) TestResult { _, err := ExchangeFor(row).Torrent(ctx, strings.Repeat("0", 40)) switch { case err == nil, errors.Is(err, exchange.ErrUnknown): return TestResult{OK: true, Detail: "Connected. The index answered."} case errors.Is(err, exchange.ErrUnconfigured): return TestResult{Detail: "No address is set, so there is nothing to reach yet."} } return TestResult{Detail: indexer.FirstLine(err.Error())} }