1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
|
package main
// whatcd-resolver, Go port.
//
// A read-mostly web UI over the Redacted API: it mirrors search results into
// PostgreSQL, ranks the torrents of each release group by a seeding weight
// computed in the database, and hands the chosen .torrent to a local
// Transmission daemon, which downloads it into a directory this server can then
// stream files (and cover art) from.
//
// See whatcd-resolver.1 for configuration and operation.
//
// This is a port of the Haskell implementation that still lives beside it in
// this directory (Main.hs, src/*.hs). Both are built from the same directory but
// share no files except static/; the Nix build lists sources explicitly, so
// neither language's build sees the other's files.
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/exec"
"os/signal"
"strings"
"syscall"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// config is the fully resolved runtime configuration.
type config struct {
// listenAddr is where the HTTP server binds.
listenAddr string
// databaseURL points at an already-running PostgreSQL.
databaseURL string
// redactedAPIKey authenticates against the Redacted API.
redactedAPIKey string
// transmissionHost/Port address the Transmission RPC endpoint.
transmissionHost string
transmissionPort int
// downloadDirectory is Transmission's download directory. Empty disables
// file streaming and cover art, exactly as in the Haskell version.
downloadDirectory string
// staticFileEndpoint is the URL prefix under which downloadDirectory is
// served.
staticFileEndpoint string
// exiftoolPath is used to extract embedded cover art.
exiftoolPath string
}
// app is the application context, the equivalent of the Haskell `Context`
// record threaded through AppT.
type app struct {
cfg config
pool *pgxpool.Pool
redacted *redactedClient
transmission *transmissionClient
assets *assetBundle
// uniqueRunID lets the browser notice that the server restarted; served by
// the /autorefresh endpoint.
uniqueRunID string
}
func main() {
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelInfo,
})))
if err := run(); err != nil {
slog.Error("fatal", "err", err)
os.Exit(1)
}
}
func run() error {
// Signal handling is installed first so that a slow startup (the asset
// prefetch does network I/O) is still interruptible.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
cfg, err := loadConfig()
if err != nil {
return err
}
shutdownTracing, err := initTracing(ctx)
if err != nil {
return err
}
defer func() {
// Use a fresh context: ctx is already cancelled during shutdown, and
// flushing the last spans is the whole point of this call.
flushCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := shutdownTracing(flushCtx); err != nil {
slog.Warn("flushing traces failed", "err", err)
}
}()
pool, err := connectDB(ctx, cfg.databaseURL)
if err != nil {
return err
}
defer pool.Close()
slog.Info("connected to database", "url", redactDSN(cfg.databaseURL))
if err := migrate(ctx, pool); err != nil {
return err
}
a := &app{
cfg: cfg,
pool: pool,
redacted: newRedactedClient(cfg.redactedAPIKey),
uniqueRunID: newUniqueRunID(),
}
a.transmission = newTransmissionClient(cfg.transmissionHost, cfg.transmissionPort)
// Fetch the frontend assets and compute their SRI hashes, as the Haskell
// version did on startup. This needs network access; see the CAVEATS
// section of the manpage.
assets, err := prefetchAssets(ctx)
if err != nil {
return fmt.Errorf("prefetching frontend assets: %w", err)
}
a.assets = assets
if cfg.downloadDirectory == "" {
slog.Info("no download directory configured, file streaming disabled")
} else {
slog.Info("streaming torrent files", "dir", cfg.downloadDirectory)
}
srv := &http.Server{
Addr: cfg.listenAddr,
Handler: a.routes(),
// Torrent search can legitimately take a long time (the Redacted API is
// rate limited and paged), so no write timeout; the handler's own
// context governs.
ReadHeaderTimeout: 10 * time.Second,
}
errc := make(chan error, 1)
go func() {
slog.Info("listening", "addr", cfg.listenAddr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errc <- err
}
}()
select {
case err := <-errc:
return err
case <-ctx.Done():
slog.Info("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return srv.Shutdown(shutdownCtx)
}
}
// loadConfig reads the environment.
//
// The variable names are kept from the Haskell version so an existing
// environment file keeps working, with the addition of
// WHATCD_RESOLVER_DATABASE_URL (the Haskell version started its own database
// and so needed no such setting).
func loadConfig() (config, error) {
cfg := config{
listenAddr: envOr("WHATCD_RESOLVER_LISTEN", "127.0.0.1:9094"),
databaseURL: os.Getenv("WHATCD_RESOLVER_DATABASE_URL"),
transmissionHost: envOr("WHATCD_RESOLVER_TRANSMISSION_HOST", "localhost"),
transmissionPort: envIntOr("WHATCD_RESOLVER_TRANSMISSION_PORT", 9091),
downloadDirectory: os.Getenv("WHATCD_RESOLVER_TRANSMISSION_DOWNLOAD_DIRECTORY"),
staticFileEndpoint: "/files",
}
if cfg.databaseURL == "" {
return cfg, errors.New("WHATCD_RESOLVER_DATABASE_URL is not set (the Go port does not start its own PostgreSQL; see whatcd-resolver(1))")
}
key, err := loadRedactedAPIKey()
if err != nil {
return cfg, err
}
cfg.redactedAPIKey = key
// The download directory is optional, but if it is set and wrong we say so
// rather than failing later on every cover-art request.
if cfg.downloadDirectory != "" {
if st, err := os.Stat(cfg.downloadDirectory); err != nil || !st.IsDir() {
slog.Warn("WHATCD_RESOLVER_TRANSMISSION_DOWNLOAD_DIRECTORY is not a directory, file streaming disabled",
"dir", cfg.downloadDirectory)
cfg.downloadDirectory = ""
}
}
cfg.exiftoolPath = findTool("exiftool")
return cfg, nil
}
// loadRedactedAPIKey reads the API key from the environment, falling back to
// `pass`, matching the Haskell version's behaviour.
func loadRedactedAPIKey() (string, error) {
if k := os.Getenv("WHATCD_RESOLVER_REDACTED_API_KEY"); k != "" {
return strings.TrimSpace(k), nil
}
slog.Info("WHATCD_RESOLVER_REDACTED_API_KEY was not set, trying pass")
out, err := exec.Command("pass", "internet/redacted/api-keys/whatcd-resolver").Output()
if err != nil {
return "", fmt.Errorf("could not get the Redacted API key from pass (set WHATCD_RESOLVER_REDACTED_API_KEY instead): %w", err)
}
key := strings.TrimSpace(string(out))
if key == "" {
return "", errors.New("the Redacted API key from pass was empty")
}
return key, nil
}
// findTool resolves a helper binary.
//
// WHATCD_RESOLVER_TOOLS is a directory of symlinks, as assembled by the Nix
// wrapper; falling back to $PATH keeps `go run` working during development.
func findTool(name string) string {
if dir := os.Getenv("WHATCD_RESOLVER_TOOLS"); dir != "" {
p := dir + "/" + name
if _, err := os.Stat(p); err == nil {
return p
}
}
if p, err := exec.LookPath(name); err == nil {
return p
}
slog.Warn("tool not found, features using it will be unavailable", "tool", name)
return ""
}
func envOr(name, def string) string {
if v := os.Getenv(name); v != "" {
return v
}
return def
}
func envIntOr(name string, def int) int {
v := os.Getenv(name)
if v == "" {
return def
}
var n int
if _, err := fmt.Sscanf(v, "%d", &n); err != nil {
slog.Warn("ignoring unparseable environment variable", "name", name, "value", v)
return def
}
return n
}
// redactDSN strips the password so the DSN can be logged.
func redactDSN(dsn string) string {
at := strings.LastIndex(dsn, "@")
scheme := strings.Index(dsn, "://")
if at < 0 || scheme < 0 || at < scheme {
return dsn
}
userinfo := dsn[scheme+3 : at]
if i := strings.Index(userinfo, ":"); i >= 0 {
userinfo = userinfo[:i] + ":***"
}
return dsn[:scheme+3] + userinfo + dsn[at:]
}
|