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
|
package main
// Locating a release's cover art and the audio files to stream.
//
// Two sources, in order of preference:
// 1. an image file at the top level of the torrent (cover.jpg and friends);
// 2. the picture embedded in the tags of the first audio file, read with
// exiftool.
//
// The second case reads the file directly from Transmission's download
// directory rather than through our own file endpoint, which is what the
// Haskell version did to keep exiftool fast.
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"os/exec"
"path/filepath"
"strings"
"go.opentelemetry.io/otel/trace"
)
// audioExtensions are the files considered playable, lowercase, with the dot.
var audioExtensions = map[string]bool{
".flac": true, ".mp3": true, ".opus": true, ".ogg": true, ".m4a": true,
".aac": true, ".wma": true, ".wav": true, ".aiff": true, ".ape": true,
".alac": true, ".mka": true, ".tta": true, ".wv": true, ".pcm": true,
".dsd": true, ".dff": true, ".dsf": true, ".mpc": true, ".mpa": true,
".mp2": true, ".mp1": true, ".m4b": true, ".m4p": true,
}
// findAudioFiles returns the indices and entries of the audio files, in torrent
// order. The index is the file id used by the /serve/torrent endpoint.
func findAudioFiles(files []torrentFileEntry) []struct {
Index int
File torrentFileEntry
} {
var out []struct {
Index int
File torrentFileEntry
}
for i, f := range files {
if len(f.Path) == 0 {
continue
}
ext := strings.ToLower(filepath.Ext(f.Path[len(f.Path)-1]))
if audioExtensions[ext] {
out = append(out, struct {
Index int
File torrentFileEntry
}{i, f})
}
}
return out
}
// findCoverArtInDirectory picks the most likely cover image from a list of file
// names, or "" if none looks like one.
//
// The priorities are taken from the Haskell version: exact conventional names
// first, then anything mentioning both "cover" and "front", then "cover", then
// any image at all.
func findCoverArtInDirectory(fileNames []string) string {
best := ""
bestPriority := 0
for _, name := range fileNames {
p := coverArtPriority(name)
if p == 0 {
continue
}
if best == "" || p < bestPriority {
best, bestPriority = name, p
}
}
return best
}
// coverArtPriority returns a lower-is-better score, or 0 for "not cover art".
func coverArtPriority(name string) int {
lower := strings.ToLower(name)
switch lower {
case "cover.jpg", "cover.jpeg", "cover.png",
"folder.jpg", "folder.jpeg", "folder.png":
return 1
}
switch {
case strings.Contains(lower, "cover") && strings.Contains(lower, "front"):
return 2
case strings.Contains(lower, "cover"):
return 3
case strings.HasSuffix(lower, ".jpg"),
strings.HasSuffix(lower, ".jpeg"),
strings.HasSuffix(lower, ".png"):
return 4
default:
return 0
}
}
// coverArt is either a path to a file inside the download directory, or image
// bytes extracted from tags.
type coverArt struct {
// Path is relative to the download directory.
Path string
// MIMEType and Picture are set when the art came from the tags.
MIMEType string
Picture []byte
}
// getTorrentFilePath resolves a file id within a torrent to its path relative to
// the download directory.
func (a *app) getTorrentFilePath(ctx context.Context, torrentID int, fileID int) (string, error) {
return inSpan1(ctx, "getTorrentFilePath", func(ctx context.Context, span trace.Span) (string, error) {
tf, err := a.loadTorrentFile(ctx, torrentID)
if err != nil {
return "", err
}
if tf == nil {
attr(span, "torrent.found", false)
return "", nil
}
attr(span, "torrent.found", true)
if fileID < 0 || fileID >= len(tf.Info.Files) {
return "", nil
}
p := torrentEntryPath(*tf, tf.Info.Files[fileID])
attr(span, "torrent.file", p)
return p, nil
})
}
// torrentEntryPath joins the torrent's directory name with a file's path
// components.
func torrentEntryPath(tf torrentFile, entry torrentFileEntry) string {
parts := append([]string{tf.Info.Name}, entry.Path...)
return filepath.Join(parts...)
}
// getTorrentCoverArt finds cover art for a torrent, if any.
func (a *app) getTorrentCoverArt(ctx context.Context, torrentID int) (*coverArt, error) {
return inSpan1(ctx, "getTorrentCoverArt", func(ctx context.Context, span trace.Span) (*coverArt, error) {
if a.cfg.downloadDirectory == "" {
attr(span, "transmission.downloads.enabled", false)
return nil, nil
}
tf, err := a.loadTorrentFile(ctx, torrentID)
if err != nil {
return nil, err
}
if tf == nil {
attr(span, "torrent.found", false)
return nil, nil
}
attr(span, "torrent.found", true)
attr(span, "torrent.name", tf.Info.Name)
// Only look at the top level: a file deeper in the tree named cover.jpg
// usually belongs to a bonus disc or a scan folder.
var topLevel []string
for _, f := range tf.Info.Files {
if len(f.Path) == 1 {
topLevel = append(topLevel, f.Path[0])
}
}
if name := findCoverArtInDirectory(topLevel); name != "" {
p := filepath.Join(tf.Info.Name, name)
attr(span, "torrent.cover-type", "directory-file")
attr(span, "torrent.cover", p)
return &coverArt{Path: p}, nil
}
// Fall back to the tags of the first audio file.
attr(span, "torrent.cover-type", "exif-metadata")
audio := findAudioFiles(tf.Info.Files)
if len(audio) == 0 {
attr(span, "torrent.cover", nil)
return nil, nil
}
abs := filepath.Join(a.cfg.downloadDirectory, torrentEntryPath(*tf, audio[0].File))
mime, picture, err := a.readEmbeddedCover(ctx, abs)
if err != nil {
// A missing cover is normal, so this is not propagated as a request
// failure; it is recorded on the span and the UI shows no image.
recordError(span, err)
return nil, nil
}
if picture == nil {
attr(span, "exiftool.coverArt.found", false)
return nil, nil
}
attr(span, "exiftool.coverArt.mime-type", mime)
return &coverArt{MIMEType: mime, Picture: picture}, nil
})
}
// readEmbeddedCover runs exiftool and extracts the embedded picture.
//
// -b would give raw bytes but makes the JSON unparseable when several binary
// fields are present, so we ask for base64 (which exiftool marks with a
// "base64:" prefix) and decode it ourselves.
func (a *app) readEmbeddedCover(ctx context.Context, path string) (string, []byte, error) {
if a.cfg.exiftoolPath == "" {
return "", nil, fmt.Errorf("exiftool is not available")
}
return inSpan2(ctx, "run exiftool", func(ctx context.Context, span trace.Span) (string, []byte, error) {
args := []string{"-json", "-all", "-binary", path}
attr(span, "exiftool.cmd", append([]string{a.cfg.exiftoolPath}, args...))
out, err := exec.CommandContext(ctx, a.cfg.exiftoolPath, args...).Output()
if err != nil {
return "", nil, fmt.Errorf("cannot run exiftool: %w", err)
}
var entries []struct {
PictureMIMEType string `json:"PictureMIMEType"`
Picture string `json:"Picture"`
}
if err := json.Unmarshal(out, &entries); err != nil {
return "", nil, fmt.Errorf("cannot decode exiftool stdout as json: %w", err)
}
if len(entries) == 0 || entries[0].Picture == "" {
return "", nil, nil
}
raw := strings.TrimPrefix(entries[0].Picture, "base64:")
picture, err := base64.StdEncoding.DecodeString(strings.TrimSpace(raw))
if err != nil {
return "", nil, fmt.Errorf("cannot decode the embedded picture: %w", err)
}
return entries[0].PictureMIMEType, picture, nil
})
}
// loadTorrentFile reads and decodes a stored .torrent, or returns nil if we do
// not have one.
func (a *app) loadTorrentFile(ctx context.Context, torrentID int) (*torrentFile, error) {
raw, err := a.getTorrentFileByID(ctx, torrentID)
if err != nil {
return nil, err
}
if len(raw) == 0 {
return nil, nil
}
tf, err := parseTorrentFile(raw)
if err != nil {
return nil, fmt.Errorf("torrent %d: %w", torrentID, err)
}
return &tf, nil
}
// inSpan2 is inSpan for an operation returning two values.
func inSpan2[A, B any](ctx context.Context, name string, f func(context.Context, trace.Span) (A, B, error)) (A, B, error) {
ctx, span := tracer.Start(ctx, name)
defer span.End()
a, b, err := f(ctx, span)
if err != nil {
recordError(span, err)
}
return a, b, err
}
|