From eed1b3989bf0333c884dffa34b0b8a24624c325e Mon Sep 17 00:00:00 2001 From: DiheChen Date: Mon, 2 Nov 2026 22:35:40 +0800 Subject: [PATCH] fix: cap media file sizes in the prepare pipeline --- internal/adapter/out/ffmpeg/ffmpeg.go | 18 ++++- internal/adapter/out/ffmpeg/ffmpeg_test.go | 54 +++++++++++++++ internal/adapter/out/ytdlp/ytdlp.go | 24 ++++++- internal/adapter/out/ytdlp/ytdlp_test.go | 80 ++++++++++++++++++---- internal/app/port/bilibili.go | 5 +- internal/app/port/link.go | 14 +++- internal/app/prepare/media.go | 33 +++++++++ internal/app/prepare/media_test.go | 37 ++++++++++ internal/bootstrap/metrics.go | 2 +- 9 files changed, 246 insertions(+), 21 deletions(-) diff --git a/internal/adapter/out/ffmpeg/ffmpeg.go b/internal/adapter/out/ffmpeg/ffmpeg.go index f1e73c6..ac94c79 100644 --- a/internal/adapter/out/ffmpeg/ffmpeg.go +++ b/internal/adapter/out/ffmpeg/ffmpeg.go @@ -9,6 +9,7 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "time" "github.com/dihemoe/alisten/internal/app/port" @@ -55,7 +56,10 @@ func (t *Transcoder) ToMP3(ctx context.Context, audio port.LinkFile) (port.LinkF func (t *Transcoder) transcode(ctx context.Context, audio port.LinkFile, dir string) (port.LinkFile, error) { // 先复制成文件再交给 ffmpeg:MP4 的 moov 可能在文件末尾,从管道读不了。文件名不带扩展名,ffmpeg 只能按内容识别格式。 input := filepath.Join(dir, "input") - if err := copyFile(input, audio); err != nil { + switch err := copyFile(input, audio); { + case errors.Is(err, port.ErrAudioTooLarge): + return nil, &port.RejectedError{Err: err} + case err != nil: return nil, fmt.Errorf("复制输入的音频:%w", err) } output := filepath.Join(dir, "audio.mp3") @@ -83,6 +87,16 @@ func copyFile(dst string, src port.LinkFile) error { return err } defer func() { _ = in.Close() }() + size, err := in.Seek(0, io.SeekEnd) + if err != nil { + return err + } + if size > port.MaxAudioSize { + return port.ErrAudioTooLarge + } + if _, err := in.Seek(0, io.SeekStart); err != nil { + return err + } out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) if err != nil { return err @@ -105,6 +119,8 @@ func args(input, output string) []string { "-map_metadata", "-1", "-map_chapters", "-1", // 与 yt-dlp 的 --audio-format mp3 默认质量一致。 "-c:a", "libmp3lame", "-q:a", "5", + // ffmpeg 写到不少于 -fs 的字节数就停止并正常退出。多一个字节,截断的输出才一定超过上限,由准备流程拒绝。 + "-fs", strconv.Itoa(port.MaxAudioSize + 1), output, } } diff --git a/internal/adapter/out/ffmpeg/ffmpeg_test.go b/internal/adapter/out/ffmpeg/ffmpeg_test.go index dd94302..b4ca346 100644 --- a/internal/adapter/out/ffmpeg/ffmpeg_test.go +++ b/internal/adapter/out/ffmpeg/ffmpeg_test.go @@ -252,6 +252,60 @@ func TestToMP3FailsWithoutAudio(t *testing.T) { } } +func TestToMP3RejectsInputOverLimit(t *testing.T) { + tests := []struct { + size int + rejected bool + logged int + }{ + // 内容全是零,复制完之后 ffmpeg 认不出格式:转码失败,但不是因为超过上限。 + {port.MaxAudioSize, false, 1}, + // 超过上限时不运行 ffmpeg,也就没有转码失败的日志。 + {port.MaxAudioSize + 1, true, 0}, + } + for _, tt := range tests { + root := t.TempDir() + logger, records := recorder() + _, release, err := New(root, logger).ToMP3(t.Context(), porttest.LinkFile(make([]byte, tt.size))) + if err == nil { + release() + t.Fatalf("%d 字节的零转码成功了", tt.size) + } + var rejected *port.RejectedError + if errors.As(err, &rejected) != tt.rejected || tt.rejected && err.Error() != port.ErrAudioTooLarge.Error() { + t.Errorf("输入 %d 字节时 = %v,期望不能准备 = %v,原因是 %q", tt.size, err, tt.rejected, port.ErrAudioTooLarge) + } + requireEmpty(t, root) + if got := records(t); len(got) != tt.logged { + t.Errorf("输入 %d 字节时的日志 = %+v,期望 %d 条", tt.size, got, tt.logged) + } + } +} + +// 把 -fs 换成很小的值,看真实的 ffmpeg 怎样处理:截断时正常退出,输出不少于 -fs 的字节数。 +func TestOutputStopsAtFileSizeLimit(t *testing.T) { + input := generate(t, "long.m4a", "-f", "lavfi", "-i", "sine=frequency=440:duration=10", "-c:a", "aac") + output := filepath.Join(t.TempDir(), "audio.mp3") + argv := args(input, output) + i := slices.Index(argv, "-fs") + if i < 0 || argv[i+1] != strconv.Itoa(port.MaxAudioSize+1) { + t.Fatalf("参数 = %q,期望 -fs %d", argv, port.MaxAudioSize+1) + } + // 10 秒的 mp3 大约 40 KB。 + const limit = 10000 + argv[i+1] = strconv.Itoa(limit) + if stderr, err := run(t.Context(), argv); err != nil { + t.Fatalf("ffmpeg 截断输出时失败:%v\n%s", err, stderr) + } + info, err := os.Stat(output) + if err != nil { + t.Fatal(err) + } + if info.Size() < limit || info.Size() > 2*limit { + t.Errorf("截断的输出有 %d 字节,期望不少于 -fs 的 %d 字节,并且在它附近截断", info.Size(), limit) + } +} + // 握手完成的连接留在 accept 队列里,不必先 Accept 也不会漏掉。 func listenLocal(t *testing.T) (address string, connected func() bool) { t.Helper() diff --git a/internal/adapter/out/ytdlp/ytdlp.go b/internal/adapter/out/ytdlp/ytdlp.go index 079d54c..cd6d838 100644 --- a/internal/adapter/out/ytdlp/ytdlp.go +++ b/internal/adapter/out/ytdlp/ytdlp.go @@ -17,6 +17,7 @@ import ( "path/filepath" "regexp" "slices" + "strconv" "strings" "syscall" "time" @@ -31,12 +32,14 @@ const ( waitDelay = 5 * time.Second outputLimit = 64 << 10 reasonLimit = 300 + metaFile = "meta.json" ) // 下载失败的原因,用作指标的标签。 const ( failureUnsupported = "unsupported" failureBlocked = "blocked" + failureTooLarge = "too_large" failureTimeout = "timeout" failureError = "error" ) @@ -127,17 +130,22 @@ func args(link, dir, proxy, cookies string) []string { "--postprocessor-args", "FixupM4a+ffmpeg_i:-protocol_whitelist file", // 来源文件里的标签是用户提交的内容,转码时 ffmpeg 默认把它们复制成 mp3 的标签,所以不复制。 // 来源已经是 mp3 时 yt-dlp 不转码,文件连同标签原样保留,存储的音频仍然不可信。 - "--postprocessor-args", "ExtractAudio+ffmpeg_o:-map_metadata -1", + // 转出的 mp3 写到上限多一个字节就停止,截断的文件由准备流程拒绝。 + "--postprocessor-args", "ExtractAudio+ffmpeg_o:-map_metadata -1 -fs " + strconv.Itoa(port.MaxAudioSize+1), // --no-playlist 只对同时指向视频和播放列表的链接有效;对播放列表本身,--playlist-items 1 只下载第一项。 "--no-playlist", "--playlist-items", "1", "--format", "bestaudio/best", + // 只在服务端给出 Content-Length 时生效;准备流程上传之前还会检查大小。 + "--max-filesize", strconv.Itoa(port.MaxAudioSize), "--extract-audio", "--audio-format", "mp3", "--write-thumbnail", "--paths", dir, "--output", "audio.%(ext)s", "--output", "thumbnail:cover.%(ext)s", "--no-progress", - "--print", "after_move:%(.{title,artist,uploader,album,duration})j", + // 屏幕输出里有页面可控的标题,元数据混在其中无法可靠地取出,所以写进单独的文件。不用 --print 也是因为它带上 --quiet, + // --max-filesize 中止下载时的提示就不会输出。 + "--print-to-file", "after_move:%(.{title,artist,uploader,album,duration})j", metaFile, // 链接以 - 开头时会被当作选项。 "--", link, } @@ -217,10 +225,18 @@ var errNoAudio = errors.New("yt-dlp 没有下载到音频") func collect(dir string, stdout []byte) (port.LinkDownload, error) { audio := filepath.Join(dir, "audio.mp3") if _, err := os.Stat(audio); err != nil { + // 服务端给出的长度超过 --max-filesize 时,yt-dlp 只输出这条提示就结束,退出码为 0。 + if bytes.Contains(stdout, []byte("File is larger than max-filesize")) { + return port.LinkDownload{}, port.ErrAudioTooLarge + } return port.LinkDownload{}, errNoAudio } + meta, err := os.ReadFile(filepath.Join(dir, metaFile)) + if err != nil { + return port.LinkDownload{}, fmt.Errorf("读取 yt-dlp 输出的元数据:%w", err) + } var i info - if err := json.Unmarshal(bytes.TrimSpace(stdout), &i); err != nil { + if err := json.Unmarshal(bytes.TrimSpace(meta), &i); err != nil { return port.LinkDownload{}, fmt.Errorf("解析 yt-dlp 输出的元数据:%w", err) } d := port.LinkDownload{ @@ -254,6 +270,8 @@ func classify(ctx context.Context, err error, stderr []byte, refused bool) (stri return failureTimeout, fmt.Errorf("yt-dlp 下载超时:%w", ctx.Err()) case refused: return failureBlocked, &port.RejectedError{Err: errors.New("链接指向的地址不是公网地址,不能下载")} + case errors.Is(err, port.ErrAudioTooLarge): + return failureTooLarge, &port.RejectedError{Err: port.ErrAudioTooLarge} case errors.Is(err, errNoAudio) || bytes.Contains(stderr, []byte("ERROR: Unsupported URL: ")): return failureUnsupported, &port.RejectedError{Err: errors.New("yt-dlp 不支持这个链接")} } diff --git a/internal/adapter/out/ytdlp/ytdlp_test.go b/internal/adapter/out/ytdlp/ytdlp_test.go index f4bf918..e217601 100644 --- a/internal/adapter/out/ytdlp/ytdlp_test.go +++ b/internal/adapter/out/ytdlp/ytdlp_test.go @@ -95,8 +95,8 @@ func TestYtdlpDownloadsAudioAndCover(t *testing.T) { if requests.Load() < 3 { t.Errorf("网站收到 %d 个请求,期望网页、音频和封面都经代理请求", requests.Load()) } - if entries, _ := os.ReadDir(dir); len(entries) != 2 { - t.Errorf("目录中有 %d 个文件,期望只有音频和封面", len(entries)) + if entries, _ := os.ReadDir(dir); len(entries) != 3 { + t.Errorf("目录中有 %d 个文件,期望只有音频、封面和元数据", len(entries)) } } @@ -131,6 +131,41 @@ func TestYtdlpRejectsUnsupportedLink(t *testing.T) { } } +type zeros struct{} + +func (zeros) ReadAt(p []byte, _ int64) (int, error) { + clear(p) + return len(p), nil +} + +// 音频的内容全是零:yt-dlp 如果没有在看到长度时中止,就会下载完再转码失败。 +func TestYtdlpRejectsAudioOverLimit(t *testing.T) { + link, _ := startPaths(t, map[string]http.Handler{ + "/page": http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = io.WriteString(w, `太长`) + }), + "/big.wav": http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "audio/wav") + http.ServeContent(w, r, "big.wav", time.Time{}, io.NewSectionReader(zeros{}, 0, port.MaxAudioSize+1)) + }), + }) + p := siteProxy(t) + dir := t.TempDir() + stdout, stderr, err := run(t.Context(), dir, args(link("/page"), dir, p.url, "")) + if err == nil { + _, err = collect(dir, stdout) + } + failure, err := classify(t.Context(), err, stderr, p.wasRefused()) + var rejected *port.RejectedError + if failure != failureTooLarge || !errors.As(err, &rejected) || err.Error() != port.ErrAudioTooLarge.Error() { + t.Errorf("音频超过上限时 = %q, %v,期望不能准备,原因是 %q\nstdout:\n%s\nstderr:\n%s", failure, err, port.ErrAudioTooLarge, stdout, stderr) + } + if entries, _ := os.ReadDir(dir); len(entries) != 0 { + t.Errorf("目录中有 %v,期望 yt-dlp 什么都没写", entries) + } +} + type observed struct { mu sync.Mutex failures []string @@ -175,7 +210,7 @@ func fakeYtdlp(t *testing.T, script string) { } func TestDownloadKeepsFilesUntilReleased(t *testing.T) { - fakeYtdlp(t, "#!/bin/sh\nprintf mp3 > audio.mp3\necho '{}'\n") + fakeYtdlp(t, "#!/bin/sh\nprintf mp3 > audio.mp3\necho '{}' > meta.json\n") root := t.TempDir() d, release, err := New(root, net.DefaultResolver, noCookies{}, func(time.Duration, string) {}, slog.New(slog.DiscardHandler)).Download(t.Context(), "https://example.com/page") if err != nil { @@ -283,6 +318,14 @@ func TestArgsEndWithLink(t *testing.T) { } } +// 转出超限的 mp3 要几百 MB 的输入,测不起;ffmpeg 遇到 -fs 时怎样截断由 ffmpeg 适配器的测试确认。 +func TestArgsCapExtractedAudio(t *testing.T) { + want := "ExtractAudio+ffmpeg_o:-map_metadata -1 -fs " + strconv.Itoa(port.MaxAudioSize+1) + if got := args("http://example.com/a", "/tmp/d", "http://127.0.0.1:1", ""); !slices.Contains(got, want) { + t.Errorf("参数 = %q,期望转码时限制 mp3 的大小:%q", got, want) + } +} + func TestClassify(t *testing.T) { exited := errors.New("exit status 1") expired, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) @@ -302,6 +345,7 @@ func TestClassify(t *testing.T) { {t.Context(), exited, "ERROR: [generic] a: HTTP Error 403: Forbidden\n", true, failureBlocked, true, "链接指向的地址不是公网地址,不能下载"}, {t.Context(), exited, "WARNING: x\nERROR: Unsupported URL: http://example.com/\n", false, failureUnsupported, true, "yt-dlp 不支持这个链接"}, {t.Context(), errNoAudio, "", false, failureUnsupported, true, "yt-dlp 不支持这个链接"}, + {t.Context(), port.ErrAudioTooLarge, "", false, failureTooLarge, true, port.ErrAudioTooLarge.Error()}, {t.Context(), exited, "ERROR: 第一条\nWARNING: x\nERROR: [youtube] x: Video unavailable\n", false, failureError, false, "yt-dlp 下载失败:[youtube] x: Video unavailable"}, {t.Context(), exited, "ERROR: " + long, false, failureError, false, "yt-dlp 下载失败:" + strings.Repeat("长", reasonLimit) + "…"}, {t.Context(), exited, "", false, failureError, false, "yt-dlp 下载失败:exit status 1"}, @@ -317,21 +361,31 @@ func TestClassify(t *testing.T) { func TestCollect(t *testing.T) { dir := t.TempDir() - if _, err := collect(dir, []byte(`{"title": "晴天"}`)); !errors.Is(err, errNoAudio) { - t.Errorf("没有音频时 = %v", err) - } - for _, name := range []string{"audio.mp3", "cover.WEBP"} { - if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o600); err != nil { + writeFile := func(name, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil { t.Fatal(err) } } - d, err := collect(dir, []byte("{\"title\": \"晴天\", \"uploader\": \"周杰伦\", \"album\": \"叶惠美\", \"duration\": 269.5}\n")) + writeFile(metaFile, `{"title": "晴天"}`) + if _, err := collect(dir, nil); !errors.Is(err, errNoAudio) { + t.Errorf("没有音频时 = %v", err) + } + if _, err := collect(dir, []byte("[info] x: Downloading 1 format(s): 0\n\r[download] File is larger than max-filesize (209715201 bytes > 209715200 bytes). Aborting.\n")); !errors.Is(err, port.ErrAudioTooLarge) { + t.Errorf("因为音频超过上限没有下载时 = %v", err) + } + writeFile("audio.mp3", "x") + writeFile("cover.WEBP", "x") + writeFile(metaFile, "{\"title\": \"晴天\", \"uploader\": \"周杰伦\", \"album\": \"叶惠美\", \"duration\": 269.5}\n") + // 屏幕输出里页面可控的标题可能伪造出 JSON 行,元数据只从文件读取。 + d, err := collect(dir, []byte("[download] Downloading playlist: x\n{\"title\": \"伪造\"}\n")) want := port.LinkDownload{Audio: file(filepath.Join(dir, "audio.mp3")), Cover: file(filepath.Join(dir, "cover.WEBP")), CoverExt: "webp"} want.Meta.Title, want.Meta.Artist, want.Meta.Album, want.Meta.Duration = "晴天", "周杰伦", "叶惠美", 269500*time.Millisecond if err != nil || d != want { t.Errorf("collect = %+v, %v\n期望 %+v", d, err, want) } - if d, _ := collect(dir, []byte(`{"title": "晴天", "artist": "周杰伦、方文山", "uploader": "JVR Music"}`)); d.Meta.Artist != "周杰伦、方文山" { + writeFile(metaFile, `{"title": "晴天", "artist": "周杰伦、方文山", "uploader": "JVR Music"}`) + if d, _ := collect(dir, nil); d.Meta.Artist != "周杰伦、方文山" { t.Errorf("有歌手时 Artist = %q,期望取歌手而不是上传者", d.Meta.Artist) } } @@ -414,7 +468,7 @@ func recordedArgs(t *testing.T, root string) []string { } func TestDownloadPassesCookiesFile(t *testing.T) { - fakeYtdlp(t, "#!/bin/sh\nprintf '%s\\n' \"$@\" > args.txt\nprintf mp3 > audio.mp3\necho '{}'\n") + fakeYtdlp(t, "#!/bin/sh\nprintf '%s\\n' \"$@\" > args.txt\nprintf mp3 > audio.mp3\necho '{}' > meta.json\n") root := t.TempDir() cookies := &fakeCookies{path: filepath.Join(t.TempDir(), "cookies.txt"), ok: true} _, release, err := New(root, net.DefaultResolver, cookies, func(time.Duration, string) {}, slog.New(slog.DiscardHandler)).Download(t.Context(), "https://www.youtube.com/watch?v=dQw4w9WgXcQ") @@ -435,7 +489,7 @@ func TestDownloadPassesCookiesFile(t *testing.T) { } func TestDownloadOmitsCookiesWhenNotOffered(t *testing.T) { - fakeYtdlp(t, "#!/bin/sh\nprintf '%s\\n' \"$@\" > args.txt\nprintf mp3 > audio.mp3\necho '{}'\n") + fakeYtdlp(t, "#!/bin/sh\nprintf '%s\\n' \"$@\" > args.txt\nprintf mp3 > audio.mp3\necho '{}' > meta.json\n") root := t.TempDir() cookies := &fakeCookies{} _, release, err := New(root, net.DefaultResolver, cookies, func(time.Duration, string) {}, slog.New(slog.DiscardHandler)).Download(t.Context(), "https://example.com/page") @@ -467,7 +521,7 @@ func TestDownloadReleasesCookiesOnFailure(t *testing.T) { } func TestDownloadFailsWhenCookiesUnavailable(t *testing.T) { - fakeYtdlp(t, "#!/bin/sh\nprintf '%s\\n' \"$@\" > args.txt\nprintf mp3 > audio.mp3\necho '{}'\n") + fakeYtdlp(t, "#!/bin/sh\nprintf '%s\\n' \"$@\" > args.txt\nprintf mp3 > audio.mp3\necho '{}' > meta.json\n") root := t.TempDir() cookies := &fakeCookies{err: errors.New("数据库不可用")} _, _, err := New(root, net.DefaultResolver, cookies, func(time.Duration, string) {}, slog.New(slog.DiscardHandler)).Download(t.Context(), "https://www.youtube.com/watch?v=dQw4w9WgXcQ") diff --git a/internal/app/port/bilibili.go b/internal/app/port/bilibili.go index 3d52e0d..8b4c20a 100644 --- a/internal/app/port/bilibili.go +++ b/internal/app/port/bilibili.go @@ -9,7 +9,8 @@ import ( type BilibiliSource interface { // CoverURL 和 PageURL 留空:平台的图片有防盗链,封面在准备时下载存储;页面地址由调用方按 ID 拼出。 TrackMeta(ctx context.Context, id string) (track.Meta, error) - // 读完文件之后调用 release 删除它们;返回错误时没有要删除的文件。视频不存在、需要付费或地区受限时返回 *RejectedError。 + // 读完文件之后调用 release 删除它们;返回错误时没有要删除的文件。视频不存在、需要付费、地区受限, + // 或者下载时发现音频超过 MaxAudioSize 时返回 *RejectedError。 Download(ctx context.Context, id string) (d BilibiliDownload, release func(), err error) } @@ -35,7 +36,7 @@ type BilibiliQRLoginSource interface { CredentialStatus(context.Context) (CredentialStatus, error) } -// 只读本地文件,不访问网络。读完 mp3 之后调用 release 删除它;返回错误时没有要删除的文件。 +// 只读本地文件,不访问网络。读完 mp3 之后调用 release 删除它;返回错误时没有要删除的文件。输入超过 MaxAudioSize 时返回 *RejectedError。 type Transcoder interface { ToMP3(ctx context.Context, audio LinkFile) (mp3 LinkFile, release func(), err error) } diff --git a/internal/app/port/link.go b/internal/app/port/link.go index ec05a00..5686c8b 100644 --- a/internal/app/port/link.go +++ b/internal/app/port/link.go @@ -2,6 +2,7 @@ package port import ( "context" + "fmt" "io" "github.com/dihemoe/alisten/internal/domain/track" @@ -22,8 +23,19 @@ type LinkFile interface { Open() (io.ReadSeekCloser, error) } +// 准备流程在上传之前拒绝超过 MaxAudioSize 的音频,把超过 MaxCoverSize 的封面当作没有封面。 +// 下载和转码也要在发现音频超过上限时尽早中止,免得超限的文件先占满临时目录。 +const ( + MaxAudioSize = 200 << 20 + MaxCoverSize = 10 << 20 +) + +// 音频超过 MaxAudioSize 时用 &RejectedError{Err: ErrAudioTooLarge} 拒绝。 +var ErrAudioTooLarge = fmt.Errorf("音频文件超过 %d MB", MaxAudioSize>>20) + type LinkDownloader interface { - // 读完文件之后调用 release 删除它们;返回错误时没有要删除的文件。链接不受支持或指向非公网地址时返回 *RejectedError。 + // 读完文件之后调用 release 删除它们;返回错误时没有要删除的文件。链接不受支持、指向非公网地址, + // 或者下载时发现音频超过 MaxAudioSize 时返回 *RejectedError。 Download(ctx context.Context, link string) (d LinkDownload, release func(), err error) } diff --git a/internal/app/prepare/media.go b/internal/app/prepare/media.go index eebe23f..b729d58 100644 --- a/internal/app/prepare/media.go +++ b/internal/app/prepare/media.go @@ -41,6 +41,9 @@ func (r *MediaRunner) Run(ctx context.Context, t track.Track) (err error) { return err } defer release() + if d, err = limitSizes(d); err != nil { + return err + } key := media.Key(t.ID) if err := r.clear(ctx, key); err != nil { @@ -112,6 +115,36 @@ func (r *MediaRunner) downloadBilibili(ctx context.Context, id string) (port.Lin }, nil } +// 下载和转码只能尽量限制文件的大小,上传之前要再检查一次。封面不影响播放,超过上限就当作没有封面,不让整首歌准备失败。 +func limitSizes(d port.LinkDownload) (port.LinkDownload, error) { + size, err := fileSize(d.Audio) + if err != nil { + return d, err + } + if size > port.MaxAudioSize { + return d, &port.RejectedError{Err: port.ErrAudioTooLarge} + } + if d.Cover == nil { + return d, nil + } + if size, err = fileSize(d.Cover); err != nil { + return d, err + } + if size > port.MaxCoverSize { + d.Cover, d.CoverExt = nil, "" + } + return d, nil +} + +func fileSize(file port.LinkFile) (int64, error) { + f, err := file.Open() + if err != nil { + return 0, err + } + defer func() { _ = f.Close() }() + return f.Seek(0, io.SeekEnd) +} + func pageURL(t track.Track) string { if t.Source == track.Bilibili { return track.BilibiliPageURL(t.ID) diff --git a/internal/app/prepare/media_test.go b/internal/app/prepare/media_test.go index 497e607..8333a89 100644 --- a/internal/app/prepare/media_test.go +++ b/internal/app/prepare/media_test.go @@ -123,6 +123,43 @@ func TestLinkRunnerSkipsCoverOfOtherFormats(t *testing.T) { } } +// 元数据里有时长:没有检查大小时,内容全是零的音频会按元数据的时长准备成功,而不是因为无法分析而失败。 +func TestLinkRunnerRejectsAudioOverLimit(t *testing.T) { + d := &downloader{meta: track.Meta{Title: "晴天", Duration: 3 * time.Second}, audio: make([]byte, port.MaxAudioSize+1), cover: []byte("jpeg"), coverExt: "jpg"} + store := porttest.NewMediaStore() + tracks, err := runLink(t, d, store, link) + var rejected *port.RejectedError + if !errors.As(err, &rejected) || err.Error() != port.ErrAudioTooLarge.Error() { + t.Errorf("音频超过上限时 = %v,期望不能准备,原因是 %q", err, port.ErrAudioTooLarge) + } + if keys := store.Keys(); len(keys) != 0 { + t.Errorf("上传了 %q,期望什么都不上传", keys) + } + if _, ok := tracks.row(link); ok { + t.Error("不能准备的歌不应写入曲目表") + } +} + +func TestLinkRunnerDropsCoverOverLimit(t *testing.T) { + cover := media.CoverObject(media.Key(link.ID), "jpg") + for _, tt := range []struct { + size int + kept bool + }{{port.MaxCoverSize, true}, {port.MaxCoverSize + 1, false}} { + d := &downloader{meta: track.Meta{Title: "晴天"}, audio: porttest.SilentWAV(time.Second), cover: make([]byte, tt.size), coverExt: "jpg"} + store := porttest.NewMediaStore() + tracks, err := runLink(t, d, store, link) + if err != nil { + t.Fatalf("封面 %d 字节时 = %v,期望准备成功", tt.size, err) + } + _, uploaded := store.Object(cover) + got, _ := tracks.row(link) + if uploaded != tt.kept || (got.Meta.CoverKey != "") != tt.kept { + t.Errorf("封面 %d 字节时上传了封面 = %v、曲目表中的封面 = %q,期望保留封面 = %v", tt.size, uploaded, got.Meta.CoverKey, tt.kept) + } + } +} + func TestLinkRunnerFallsBackWhenAudioCannotBeAnalyzed(t *testing.T) { d := &downloader{meta: track.Meta{Title: "晴天", Duration: 3 * time.Second}, audio: []byte("不是音频")} tracks, err := runLink(t, d, porttest.NewMediaStore(), link) diff --git a/internal/bootstrap/metrics.go b/internal/bootstrap/metrics.go index 8bfbf84..605d13a 100644 --- a/internal/bootstrap/metrics.go +++ b/internal/bootstrap/metrics.go @@ -101,7 +101,7 @@ func newWorkerMetrics(reg prometheus.Registerer) *workerMetrics { }), downloadFailures: prometheus.NewCounterVec(prometheus.CounterOpts{ Name: "alisten_ytdlp_download_failures_total", - Help: "yt-dlp 下载失败的次数,reason 为 unsupported(链接不受支持)、blocked(指向非公网地址)、timeout(超过任务的期限)或 error(其他错误)。", + Help: "yt-dlp 下载失败的次数,reason 为 unsupported(链接不受支持)、blocked(指向非公网地址)、too_large(音频超过大小上限)、timeout(超过任务的期限)或 error(其他错误)。", }, []string{"reason"}), waitingJobs: prometheus.NewGauge(prometheus.GaugeOpts{ Name: "alisten_ytdlp_jobs_waiting",