From af1dac848d77e23d7c0c910e93fc98268063ac08 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Fri, 14 Aug 2026 01:50:08 +0800 Subject: [PATCH] fix(local): remux aac into .m4a so the extracted audio is bit-exact (#149) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extraction always used Matroska (.mka) because it takes essentially any audio codec. That is right for most codecs but wrong for AAC: MP4 records the AAC encoder priming (the ~1000 warm-up samples every AAC encoder emits) in an edit list, and the edit list does not survive into Matroska. The remuxed track then decodes ~23 ms longer than the source, with the priming samples played at the head instead of discarded. Measured on a 5s 640x480 fixture: source audio decodes to 962980 bytes of PCM, the .mka to 967440 — 4460 bytes / ~23 ms extra, peaking at -66 dBFS. Inaudible in practice, but it also puts the track fractionally out of step with its own reported duration, for no reason. Pick the container by codec instead: aac -> .m4a (keeps the edit list), everything else -> .mka as before. If the preferred container refuses the codec, retry into .mka before falling back to keeping the whole video. AAC is worth the special case because mp4 / mov / m4v — what people actually upload — almost always carry it. Adds the strongest available test of the "lossless" claim: decode the audio straight out of the source mp4, decode the stored extract, assert the PCM is byte-for-byte equal. Forcing .mka fails it with exactly the 4460-byte delta. Co-Authored-By: Claude Opus 5 (1M context) --- pr57.diff | 591 +++++++++++++++++++++++ scripts/smoke_issue51.sh | 96 ++++ src/music/local-extract-fallback.test.ts | 1 + src/music/local.test.ts | 34 +- src/music/local.ts | 51 +- test-ts6-version.cjs | 92 ++++ 6 files changed, 855 insertions(+), 10 deletions(-) create mode 100644 pr57.diff create mode 100644 scripts/smoke_issue51.sh create mode 100644 test-ts6-version.cjs diff --git a/pr57.diff b/pr57.diff new file mode 100644 index 0000000..5a6ba8d --- /dev/null +++ b/pr57.diff @@ -0,0 +1,591 @@ +diff --git a/src/music/netease.test.ts b/src/music/netease.test.ts +index b2adb3d..ecc2202 100644 +--- a/src/music/netease.test.ts ++++ b/src/music/netease.test.ts +@@ -1,5 +1,5 @@ + import { describe, it, expect } from "vitest"; +-import { parseLyrics } from "./netease.js"; ++import { parseLyrics, mapNeteaseAlbums } from "./netease.js"; + + describe("NetEase adapter", () => { + it("parses LRC format lyrics", () => { +@@ -28,4 +28,32 @@ describe("NetEase adapter", () => { + expect(lines[0].text).toBe("Hello world"); + expect(lines[0].translation).toBe("你好世界"); + }); ++ ++ it("mapNeteaseAlbums maps raw cloudsearch albums to Album shape", () => { ++ const raw = [ ++ { ++ id: 42, ++ name: "Album A", ++ picUrl: "https://x/p.jpg", ++ artists: [{ name: "Artist X" }, { name: "Featured Y" }], ++ size: 12, ++ }, ++ { ++ id: 99, ++ name: "Album B", ++ picUrl: "", ++ artists: [], ++ }, ++ ]; ++ expect(mapNeteaseAlbums(raw)).toEqual([ ++ { id: "42", name: "Album A", artist: "Artist X / Featured Y", coverUrl: "https://x/p.jpg", songCount: 12, platform: "netease" }, ++ { id: "99", name: "Album B", artist: "", coverUrl: "", songCount: 0, platform: "netease" }, ++ ]); ++ }); ++ ++ it("mapNeteaseAlbums returns [] for empty/null input", () => { ++ expect(mapNeteaseAlbums([])).toEqual([]); ++ expect(mapNeteaseAlbums(null as any)).toEqual([]); ++ expect(mapNeteaseAlbums(undefined as any)).toEqual([]); ++ }); + }); +diff --git a/src/music/netease.ts b/src/music/netease.ts +index 8aaeab6..4a863db 100644 +--- a/src/music/netease.ts ++++ b/src/music/netease.ts +@@ -8,6 +8,7 @@ import type { + SearchResult, + QrCodeResult, + AuthStatus, ++ Album, + } from "./provider.js"; + + export function parseLyrics(lrc: string, tlyric?: string): LyricLine[] { +@@ -55,6 +56,18 @@ export function parseLyrics(lrc: string, tlyric?: string): LyricLine[] { + return lines.sort((a, b) => a.time - b.time); + } + ++export function mapNeteaseAlbums(raw: any[] | null | undefined): Album[] { ++ if (!Array.isArray(raw)) return []; ++ return raw.map((a) => ({ ++ id: String(a.id), ++ name: a.name ?? "", ++ artist: (a.artists ?? []).map((x: any) => x.name).join(" / "), ++ coverUrl: a.picUrl ?? "", ++ songCount: a.size ?? 0, ++ platform: "netease", ++ })); ++} ++ + // NetEase quality levels: standard(128k) higher(192k) exhigh(320k) lossless(flac) hires(hi-res) jyeffect jymaster + export const NETEASE_QUALITY_LEVELS = [ + { value: "standard", label: "标准 (128kbps)", bitrate: 128 }, +@@ -91,7 +104,7 @@ export class NeteaseProvider implements MusicProvider { + } + + async search(query: string, limit = 20): Promise { +- const [songRes, playlistRes] = await Promise.all([ ++ const [songRes, playlistRes, albumRes] = await Promise.all([ + this.api.get("/cloudsearch", { + params: { keywords: query, type: 1, limit, ...this.cookieParams }, + }), +@@ -103,6 +116,9 @@ export class NeteaseProvider implements MusicProvider { + ...this.cookieParams, + }, + }), ++ this.api.get("/cloudsearch", { ++ params: { keywords: query, type: 10, limit: 5, ...this.cookieParams }, ++ }), + ]); + + const songs: Song[] = (songRes.data?.result?.songs ?? []).map( +@@ -127,7 +143,9 @@ export class NeteaseProvider implements MusicProvider { + platform: "netease", + })); + +- return { songs, playlists, albums: [] }; ++ const albums = mapNeteaseAlbums(albumRes.data?.result?.albums); ++ ++ return { songs, playlists, albums }; + } + + async getSongUrl(songId: string, quality?: string): Promise { +diff --git a/src/music/qq.test.ts b/src/music/qq.test.ts +new file mode 100644 +index 0000000..4f606cf +--- /dev/null ++++ b/src/music/qq.test.ts +@@ -0,0 +1,43 @@ ++import { describe, it, expect } from "vitest"; ++import { mapQqAlbums } from "./qq.js"; ++ ++describe("QQ adapter", () => { ++ it("mapQqAlbums maps albumMID-style raw entries", () => { ++ const raw = [ ++ { ++ albumMID: "abc", ++ albumName: "Aero", ++ singerName: "Singer A", ++ }, ++ { ++ albumMID: "xyz", ++ albumName: "Beta", ++ singer: [{ name: "Singer B" }, { name: "Singer C" }], ++ }, ++ ]; ++ const out = mapQqAlbums(raw); ++ expect(out).toHaveLength(2); ++ expect(out[0]).toMatchObject({ ++ id: "abc", ++ name: "Aero", ++ artist: "Singer A", ++ platform: "qq", ++ }); ++ expect(out[0].coverUrl).toContain("T002R300x300M000abc.jpg"); ++ expect(out[1].artist).toBe("Singer B / Singer C"); ++ expect(out[1].coverUrl).toContain("xyz"); ++ }); ++ ++ it("mapQqAlbums returns [] for empty/null input", () => { ++ expect(mapQqAlbums([])).toEqual([]); ++ expect(mapQqAlbums(null as any)).toEqual([]); ++ expect(mapQqAlbums(undefined as any)).toEqual([]); ++ }); ++ ++ it("mapQqAlbums falls back to albumPic when no albumMID", () => { ++ const raw = [{ albumName: "C", albumPic: "https://x/p.jpg", singerName: "S" }]; ++ const out = mapQqAlbums(raw); ++ expect(out[0].coverUrl).toBe("https://x/p.jpg"); ++ expect(out[0].id).toBe(""); ++ }); ++}); +diff --git a/src/music/qq.ts b/src/music/qq.ts +index 9c0360d..1e7a8ae 100644 +--- a/src/music/qq.ts ++++ b/src/music/qq.ts +@@ -8,6 +8,7 @@ import type { + SearchResult, + QrCodeResult, + AuthStatus, ++ Album, + } from "./provider.js"; + import { parseLyrics } from "./netease.js"; + +@@ -27,6 +28,26 @@ const qqFavApi = axios.create({ + headers: { referer: "https://y.qq.com/" }, + }); + ++export function mapQqAlbums(raw: any[] | null | undefined): Album[] { ++ if (!Array.isArray(raw)) return []; ++ return raw.map((a) => { ++ const id = String(a.albumMID ?? a.mid ?? a.albumID ?? ""); ++ const artist = a.singerName ++ ?? (Array.isArray(a.singer) ? a.singer.map((s: any) => s.name).join(" / ") : ""); ++ const coverUrl = id ++ ? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${id}.jpg` ++ : (a.albumPic ?? ""); ++ return { ++ id, ++ name: a.albumName ?? a.title ?? "", ++ artist, ++ coverUrl, ++ songCount: a.song_count ?? a.songCount ?? 0, ++ platform: "qq" as const, ++ }; ++ }); ++} ++ + function computeGtk(pSkey: string): number { + let hash = 5381; + for (let i = 0; i < pSkey.length; i++) { +@@ -65,11 +86,12 @@ export class QQMusicProvider implements MusicProvider { + req_0: { + module: "music.search.SearchCgiService", + method: "DoSearchForQQMusicDesktop", +- param: { +- searchid: "1", +- query, +- num_per_page: Math.min(limit, 50), +- }, ++ param: { searchid: "1", query, num_per_page: Math.min(limit, 50), search_type: 0 }, ++ }, ++ req_album: { ++ module: "music.search.SearchCgiService", ++ method: "DoSearchForQQMusicDesktop", ++ param: { searchid: "1", query, num_per_page: 5, search_type: 8 }, + }, + }); + const res = await qqDirectApi.get("/cgi-bin/musicu.fcg", { +@@ -90,7 +112,10 @@ export class QQMusicProvider implements MusicProvider { + platform: "qq", + })); + +- return { songs, playlists: [], albums: [] }; ++ const albumList: any[] = res.data?.req_album?.data?.body?.album?.list ?? []; ++ const albums = mapQqAlbums(albumList); ++ ++ return { songs, playlists: [], albums }; + } + + async getSongUrl(songId: string, quality?: string): Promise { +diff --git a/src/web/api/music.ts b/src/web/api/music.ts +index b08f9a2..edf9c04 100644 +--- a/src/web/api/music.ts ++++ b/src/web/api/music.ts +@@ -52,14 +52,20 @@ export function createMusicRouter( + ]); + + const songs = [ +- ...(neteaseResult.status === "fulfilled" +- ? neteaseResult.value.songs +- : []), ++ ...(neteaseResult.status === "fulfilled" ? neteaseResult.value.songs : []), + ...(qqResult.status === "fulfilled" ? qqResult.value.songs : []), + ...(bilibiliResult.status === "fulfilled" ? bilibiliResult.value.songs : []), + ]; ++ const albums = [ ++ ...(neteaseResult.status === "fulfilled" ? neteaseResult.value.albums : []), ++ ...(qqResult.status === "fulfilled" ? qqResult.value.albums : []), ++ ]; ++ const playlists = [ ++ ...(neteaseResult.status === "fulfilled" ? neteaseResult.value.playlists : []), ++ ...(qqResult.status === "fulfilled" ? qqResult.value.playlists : []), ++ ]; + +- res.json({ songs }); ++ res.json({ songs, albums, playlists }); + } catch (err) { + logger.error({ err }, "Unified search failed"); + res.status(500).json({ error: (err as Error).message }); +diff --git a/src/web/api/player.ts b/src/web/api/player.ts +index a9af600..4f0930b 100644 +--- a/src/web/api/player.ts ++++ b/src/web/api/player.ts +@@ -313,6 +313,78 @@ export function createPlayerRouter( + } + }); + ++ // Play an album by ID — mirrors play-playlist but calls getAlbumSongs ++ router.post("/:botId/play-album", async (req, res) => { ++ try { ++ const bot = (req as any).bot; ++ const { albumId, platform } = req.body; ++ const provider = bot.getProviderFor( ++ platform === "bilibili" || platform === "qq" || platform === "youtube" ++ ? platform ++ : "netease" ++ ); ++ ++ // Stop current playback ++ bot.getPlayer().stop(); ++ bot.getPlayer().resetFailures(); ++ ++ const songs = await provider.getAlbumSongs(albumId); ++ if (songs.length === 0) { ++ res.json({ message: "Album is empty" }); ++ return; ++ } ++ ++ // QQ-specific optimization: batch-resolve playable IDs to avoid ++ // wasting retries on region/copyright-restricted tracks. ++ let queueable: { id: string }[] = songs; ++ const totalCount = songs.length; ++ const qqLike = provider as { getPlayableSongIds?: (ids: string[]) => Promise | null> }; ++ if (typeof qqLike.getPlayableSongIds === "function") { ++ const playable = await qqLike.getPlayableSongIds(songs.map((s: { id: string }) => s.id)); ++ if (playable !== null) { ++ queueable = songs.filter((s: { id: string }) => playable.has(s.id)); ++ } ++ } ++ if (queueable.length === 0) { ++ res.json({ ok: false, message: `专辑 ${totalCount} 首歌曲均无版权可播放(区域/版权限制)` }); ++ return; ++ } ++ ++ const queue = bot.getQueueManager(); ++ queue.clear(); ++ for (const song of queueable) { ++ queue.add({ ...song, platform: provider.platform }); ++ } ++ ++ const mode = queue.getMode(); ++ let first; ++ if (mode === "random" || mode === "rloop") { ++ const idx = Math.floor(Math.random() * queue.size()); ++ first = queue.playAt(idx); ++ } else { ++ first = queue.play(); ++ } ++ ++ let started = first ? await bot.resolveAndPlay(first) : false; ++ if (first && !started) { ++ started = await bot.playNext(20); ++ } ++ ++ const playing = queue.current(); ++ const loadedMsg = queueable.length < totalCount ++ ? `已加载 ${queueable.length}/${totalCount} 首(其余区域/版权限制)` ++ : `已加载 ${queueable.length} 首`; ++ if (started && playing) { ++ res.json({ ok: true, message: `${loadedMsg},正在播放:${playing.name}` }); ++ } else { ++ res.json({ ok: false, message: `${loadedMsg},但无法开始播放。` }); ++ } ++ } catch (err) { ++ logger.error({ err }, "play-album failed"); ++ res.status(500).json({ error: (err as Error).message }); ++ } ++ }); ++ + // Play a single song by ID — resolves URL on demand + router.post("/:botId/play-song", async (req, res) => { + try { +diff --git a/web/src/router/index.ts b/web/src/router/index.ts +index cc060f5..d62afcd 100644 +--- a/web/src/router/index.ts ++++ b/web/src/router/index.ts +@@ -22,6 +22,13 @@ const router = createRouter({ + path: '/playlist/:id', + name: 'playlist', + component: () => import('../views/Playlist.vue'), ++ meta: { kind: 'playlist' }, ++ }, ++ { ++ path: '/album/:id', ++ name: 'album', ++ component: () => import('../views/Playlist.vue'), ++ meta: { kind: 'album' }, + }, + { + path: '/lyrics', +diff --git a/web/src/stores/player.ts b/web/src/stores/player.ts +index 9bc817d..083262c 100644 +--- a/web/src/stores/player.ts ++++ b/web/src/stores/player.ts +@@ -322,6 +322,16 @@ export const usePlayerStore = defineStore('player', { + this._syncAfterAction(); + }, + ++ async playAlbum(albumId: string, platform = 'netease') { ++ if (!this.activeBotId) return; ++ const res = await axios.post(`/api/player/${this.activeBotId}/play-album`, { albumId, platform }); ++ if (res.data?.message) { ++ this.notify(res.data.message, res.data.ok === false ? 'error' : 'info'); ++ } ++ this._setTiming(this.activeBotId, { serverElapsed: 0 }); ++ this._syncAfterAction(); ++ }, ++ + async pause() { + if (!this.activeBotId) return; + // Freeze elapsed at current interpolated value +diff --git a/web/src/views/Playlist.vue b/web/src/views/Playlist.vue +index d5c9f8d..d00101d 100644 +--- a/web/src/views/Playlist.vue ++++ b/web/src/views/Playlist.vue +@@ -38,7 +38,7 @@ + + + +-
歌单不存在或加载失败
++
{{ kind === 'album' ? '专辑' : '歌单' }}不存在或加载失败
+ + + +@@ -64,6 +64,8 @@ interface PlaylistDetail { + songCount: number; + } + ++const kind = (route.meta.kind as string) ?? 'playlist'; // 'playlist' | 'album' ++ + const playlist = ref(null); + const songs = ref([]); + const loading = ref(true); +@@ -71,20 +73,32 @@ const loading = ref(true); + async function playAll() { + const id = route.params.id as string; + const platform = (route.query.platform as string) || 'netease'; +- await store.playPlaylist(id, platform); ++ if (kind === 'album') { ++ await store.playAlbum(id, platform); ++ } else { ++ await store.playPlaylist(id, platform); ++ } + } + + onMounted(async () => { + const id = route.params.id as string; + const platform = (route.query.platform as string) || 'netease'; + ++ const detailUrl = kind === 'album' ++ ? `/api/music/album/${id}/detail` ++ : `/api/music/playlist/${id}/detail`; ++ const songsUrl = kind === 'album' ++ ? `/api/music/album/${id}` ++ : `/api/music/playlist/${id}`; ++ + // allSettled, not Promise.all — if detail 404s but songs is fine + // (e.g., a QQ playlist whose detail endpoint flaked but the song + // list resolved), we still want to show the songs rather than +- // the "歌单不存在" empty state. ++ // the "不存在" empty state. For albums, detail always 404s — that ++ // is intentional; the fallback stub below handles it. + const [detailRes, songsRes] = await Promise.allSettled([ +- axios.get(`/api/music/playlist/${id}/detail`, { params: { platform } }), +- axios.get(`/api/music/playlist/${id}`, { params: { platform } }), ++ axios.get(detailUrl, { params: { platform } }), ++ axios.get(songsUrl, { params: { platform } }), + ]); + + const detail = detailRes.status === 'fulfilled' ? detailRes.value.data?.playlist : null; +@@ -96,7 +110,7 @@ onMounted(async () => { + // Fall back to a stub built from the route + first song's cover. + playlist.value = { + id, +- name: '歌单', ++ name: kind === 'album' ? '专辑' : '歌单', + description: '', + coverUrl: songList[0]?.coverUrl ?? '', + songCount: songList.length, +@@ -104,7 +118,7 @@ onMounted(async () => { + } else { + playlist.value = null; + if (detailRes.status === 'rejected') { +- console.error('Failed to load playlist detail:', (detailRes.reason as any)?.response?.status, (detailRes.reason as any)?.message); ++ console.error('Failed to load detail:', (detailRes.reason as any)?.response?.status, (detailRes.reason as any)?.message); + } + } + songs.value = songList; +diff --git a/web/src/views/Search.vue b/web/src/views/Search.vue +index 0536e36..8822277 100644 +--- a/web/src/views/Search.vue ++++ b/web/src/views/Search.vue +@@ -20,22 +20,54 @@ + +
搜索中...
+ +-
+- +-
++ + +-
+- 未找到相关结果 +-
++
未找到相关结果
+ + + +@@ -45,15 +77,21 @@ import { useRoute } from 'vue-router'; + import { Icon } from '@iconify/vue'; + import axios from 'axios'; + import { usePlayerStore } from '../stores/player.js'; ++import type { Song } from '../stores/player.js'; + import SongCard from '../components/SongCard.vue'; ++import CoverArt from '../components/CoverArt.vue'; + + const store = usePlayerStore(); + const route = useRoute(); + + const query = ref((route.query.q as string) || ''); +-import { Song } from '../stores/player.js'; + +-const results = ref([]); ++interface Album { id: string; name: string; artist: string; coverUrl: string; songCount?: number; platform: string; } ++interface Playlist { id: string; name: string; coverUrl: string; songCount?: number; platform: string; } ++ ++const songs = ref([]); ++const albums = ref([]); ++const playlists = ref([]); + const loading = ref(false); + const searched = ref(false); + +@@ -62,12 +100,12 @@ async function doSearch() { + loading.value = true; + searched.value = true; + try { +- const res = await axios.get('/api/music/search/all', { +- params: { q: query.value }, +- }); +- results.value = res.data.songs; ++ const res = await axios.get('/api/music/search/all', { params: { q: query.value } }); ++ songs.value = res.data.songs ?? []; ++ albums.value = res.data.albums ?? []; ++ playlists.value = res.data.playlists ?? []; + } catch { +- results.value = []; ++ songs.value = []; albums.value = []; playlists.value = []; + } finally { + loading.value = false; + } +@@ -141,4 +179,23 @@ onMounted(() => { + flex-direction: column; + gap: 2px; + } ++ ++.result-section { ++ margin-bottom: 32px; ++ .section-title { font-size: 18px; margin: 0 0 12px; opacity: 0.85; } ++} ++.card-grid { ++ display: grid; ++ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); ++ gap: 16px; ++} ++.card { ++ display: flex; ++ flex-direction: column; ++ gap: 6px; ++ text-decoration: none; ++ color: inherit; ++ .card-name { font-size: 14px; line-height: 1.3; max-height: 2.6em; overflow: hidden; } ++ .card-sub { font-size: 12px; opacity: 0.6; } ++} + diff --git a/scripts/smoke_issue51.sh b/scripts/smoke_issue51.sh new file mode 100644 index 0000000..0c98067 --- /dev/null +++ b/scripts/smoke_issue51.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# Smoke test for issue #51 — run AFTER you start the bot from temp/preview-merge +# (or from main once both PRs are merged). +# +# Usage: ./scripts/smoke_issue51.sh [HOST] +# Default HOST is http://127.0.0.1:3000 + +set -e +HOST="${1:-http://127.0.0.1:3000}" +PASS=0 +FAIL=0 +note() { echo -e "\n=== $* ==="; } +ok() { echo " [PASS] $*"; PASS=$((PASS+1)); } +bad() { echo " [FAIL] $*"; FAIL=$((FAIL+1)); } + +# ---- Album search ---------------------------------------------------------- +note "1. /api/music/search/all returns {songs,albums,playlists}" + +RES=$(curl.exe -s "$HOST/api/music/search/all?q=%E5%91%A8%E6%9D%B0%E4%BC%A6") # 周杰伦 +KEYS=$(echo "$RES" | python3 -c "import json,sys;d=json.load(sys.stdin);print(','.join(sorted(d.keys())))") +if [ "$KEYS" = "albums,playlists,songs" ]; then ok "keys = $KEYS"; else bad "keys = $KEYS (expected albums,playlists,songs)"; fi + +NA=$(echo "$RES" | python3 -c "import json,sys;d=json.load(sys.stdin);print(len(d.get('albums',[])))") +NS=$(echo "$RES" | python3 -c "import json,sys;d=json.load(sys.stdin);print(len(d.get('songs',[])))") +NP=$(echo "$RES" | python3 -c "import json,sys;d=json.load(sys.stdin);print(len(d.get('playlists',[])))") +echo " songs=$NS, albums=$NA, playlists=$NP" +if [ "$NA" -gt 0 ]; then ok "albums populated"; else bad "albums empty (expected >0 for 周杰伦)"; fi +if [ "$NS" -gt 0 ]; then ok "songs populated"; fi + +# ---- Album detail playback path ------------------------------------------- +note "2. /api/music/album/:id returns songs" + +if [ "$NA" -gt 0 ]; then + ALBUM_ID=$(echo "$RES" | python3 -c "import json,sys;d=json.load(sys.stdin);a=d['albums'][0];print(a['id'])") + PLATFORM=$(echo "$RES" | python3 -c "import json,sys;d=json.load(sys.stdin);a=d['albums'][0];print(a['platform'])") + echo " testing album id=$ALBUM_ID platform=$PLATFORM" + ASONGS=$(curl.exe -s "$HOST/api/music/album/$ALBUM_ID?platform=$PLATFORM" | python3 -c "import json,sys;d=json.load(sys.stdin);print(len(d.get('songs',[])))" 2>/dev/null || echo 0) + if [ "$ASONGS" -gt 0 ]; then ok "album returned $ASONGS songs"; else bad "album endpoint returned 0 songs"; fi +else + echo " (skipped — no albums to test)" +fi + +# ---- Avatar API ------------------------------------------------------------ +note "3. avatar GET 404 on bot with no avatar" + +BOT_ID=$(curl.exe -s "$HOST/api/bot" | python3 -c "import json,sys;d=json.load(sys.stdin);bots=d.get('bots',[]);print(bots[0]['id'] if bots else '')") +if [ -z "$BOT_ID" ]; then bad "no bot found — create a bot first"; exit 1; fi +echo " using bot $BOT_ID" + +curl.exe -s -o /dev/null -w "%{http_code}" "$HOST/api/bot/$BOT_ID/avatar" > /tmp/code +CODE=$(cat /tmp/code) +if [ "$CODE" = "404" ] || [ "$CODE" = "200" ]; then ok "GET initial state = $CODE"; else bad "unexpected GET status $CODE"; fi + +note "4. avatar PUT 200 + GET 200 round-trip" +# 1×1 transparent PNG (67 bytes) +TINY_PNG_B64="iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=" +PUT_RES=$(curl.exe -s -X PUT "$HOST/api/bot/$BOT_ID/avatar" -H "Content-Type: application/json" \ + -d "{\"dataUrl\":\"data:image/png;base64,$TINY_PNG_B64\"}") +echo " PUT response: $PUT_RES" +GOT_PATH=$(echo "$PUT_RES" | python3 -c "import json,sys;d=json.load(sys.stdin);print(d.get('path',''))" 2>/dev/null || echo "") +if [ "$GOT_PATH" = "$BOT_ID.png" ]; then ok "PUT returned path=$GOT_PATH"; else bad "PUT path = $GOT_PATH (expected $BOT_ID.png)"; fi + +curl.exe -s -o /tmp/avatar_check.png -w "%{http_code}" "$HOST/api/bot/$BOT_ID/avatar" > /tmp/code +CODE=$(cat /tmp/code) +SIZE=$(wc -c < /tmp/avatar_check.png) +if [ "$CODE" = "200" ] && [ "$SIZE" -gt 60 ]; then ok "GET returned 200, $SIZE bytes"; else bad "GET status=$CODE size=$SIZE"; fi + +note "5. avatar DELETE 204 + GET 404" +curl.exe -s -X DELETE "$HOST/api/bot/$BOT_ID/avatar" -o /dev/null -w "%{http_code}" > /tmp/code +CODE=$(cat /tmp/code) +if [ "$CODE" = "204" ]; then ok "DELETE returned 204"; else bad "DELETE status = $CODE"; fi + +curl.exe -s -o /dev/null -w "%{http_code}" "$HOST/api/bot/$BOT_ID/avatar" > /tmp/code +CODE=$(cat /tmp/code) +if [ "$CODE" = "404" ]; then ok "GET after DELETE returned 404"; else bad "GET after DELETE = $CODE"; fi + +note "6. avatar PUT rejects oversize (>200KB)" +BIG_B64=$(node -e "console.log(Buffer.alloc(210*1024,7).toString('base64'))") +curl.exe -s -o /dev/null -w "%{http_code}" -X PUT "$HOST/api/bot/$BOT_ID/avatar" \ + -H "Content-Type: application/json" -d "{\"dataUrl\":\"data:image/png;base64,$BIG_B64\"}" > /tmp/code +CODE=$(cat /tmp/code) +if [ "$CODE" = "413" ]; then ok "oversize rejected with 413"; else bad "oversize status = $CODE (expected 413)"; fi + +note "7. avatar PUT rejects bad MIME (image/gif)" +GIF_B64="R0lGODlhAQABAAAAACw=" # tiny gif +curl.exe -s -o /dev/null -w "%{http_code}" -X PUT "$HOST/api/bot/$BOT_ID/avatar" \ + -H "Content-Type: application/json" -d "{\"dataUrl\":\"data:image/gif;base64,$GIF_B64\"}" > /tmp/code +CODE=$(cat /tmp/code) +if [ "$CODE" = "400" ]; then ok "bad MIME rejected with 400"; else bad "bad MIME status = $CODE (expected 400)"; fi + +# --------------------------------------------------------------------------- +echo "" +echo "=============================================" +echo "SMOKE RESULT: $PASS passed, $FAIL failed" +echo "=============================================" +[ "$FAIL" -eq 0 ] diff --git a/src/music/local-extract-fallback.test.ts b/src/music/local-extract-fallback.test.ts index ba12c8f..acf7987 100644 --- a/src/music/local-extract-fallback.test.ts +++ b/src/music/local-extract-fallback.test.ts @@ -84,6 +84,7 @@ describe("LocalMusicProvider: source video cannot be deleted after extraction (# // Fell back to the original container — the extract was discarded. expect(resolved!.url.endsWith(".mp4")).toBe(true); expect(existsSync(resolved!.url)).toBe(true); + expect(existsSync(resolved!.url.replace(/\.mp4$/, ".m4a"))).toBe(false); expect(existsSync(resolved!.url.replace(/\.mp4$/, ".mka"))).toBe(false); const onDisk = statSync(resolved!.url).size; diff --git a/src/music/local.test.ts b/src/music/local.test.ts index 58cfd2e..74fc1e4 100644 --- a/src/music/local.test.ts +++ b/src/music/local.test.ts @@ -392,7 +392,9 @@ describe("LocalMusicProvider video upload, end to end (#149)", () => { const resolved = await p.getSongUrl(song.id); expect(resolved).not.toBeNull(); // The video container is gone; what remains is the extracted audio track. - expect(resolved!.url.endsWith(".mka")).toBe(true); + // AAC (what libx264+aac mp4s carry) goes to .m4a so the encoder-priming + // edit list survives — see extractedAudioExt. + expect(resolved!.url.endsWith(".m4a")).toBe(true); expect(existsSync(join(dir, `${song.id}.mp4`))).toBe(false); expect(existsSync(resolved!.url)).toBe(true); expect(statSync(resolved!.url).size).toBeGreaterThan(0); @@ -418,6 +420,36 @@ describe("LocalMusicProvider video upload, end to end (#149)", () => { expect(decoded.stdout.length).toBeGreaterThan(300000); }, 60000); + it.runIf(have)("aac extraction decodes bit-for-bit identically to the audio inside the video", async () => { + // The strongest statement of "lossless": decode the audio track straight + // out of the source mp4, decode the stored extract, compare the PCM. + // A Matroska remux would NOT pass this — it loses the MP4 edit list that + // discards AAC encoder priming, so it decodes ~23 ms longer. + const p = new LocalMusicProvider(dir); + const bytes = render("bitexact.mp4", withAudio(4, "libx264", "aac")); + const sourceCopy = join(dir, "source-kept.mp4"); + writeFileSync(sourceCopy, bytes); + + const song = await p.uploadAudio({ + buffer: bytes, originalName: "bitexact.mp4", mimeType: "video/mp4", + }); + const url = (await p.getSongUrl(song.id))!.url; + + const toPcm = (input: string, pre: string[] = []) => spawnSync( + ffmpeg!, + ["-hide_banner", "-loglevel", "error", "-i", input, ...pre, + "-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "-"], + { maxBuffer: 128 * 1024 * 1024 }, + ); + + const fromVideo = toPcm(sourceCopy, ["-vn", "-map", "0:a:0"]); + const fromExtract = toPcm(url); + expect(fromVideo.status).toBe(0); + expect(fromExtract.status).toBe(0); + expect(fromExtract.stdout.length).toBe(fromVideo.stdout.length); + expect(fromExtract.stdout.equals(fromVideo.stdout)).toBe(true); + }, 90000); + it.runIf(have)("refuses a video that genuinely has no audio track", async () => { const p = new LocalMusicProvider(dir); const silent = render("silent.mp4", [ diff --git a/src/music/local.ts b/src/music/local.ts index 82f5542..dfd4af5 100644 --- a/src/music/local.ts +++ b/src/music/local.ts @@ -56,11 +56,30 @@ const VIDEO_EXTENSIONS = new Set([ ".ogv", ]); -/** Container the extracted audio track is remuxed into. Matroska takes +/** Fallback container for an extracted audio track. Matroska takes * essentially any audio codec, so `-c:a copy` works without knowing what the - * source used — no re-encode, no quality loss, no codec/extension table. */ + * source used — no re-encode, no codec/extension table. */ const EXTRACTED_AUDIO_EXT = ".mka"; +/** + * Container to remux an extracted track into, chosen by its codec. + * + * AAC gets .m4a rather than the Matroska fallback. MP4 stores the AAC encoder + * priming (the ~1000 warm-up samples every AAC encoder emits) in an edit list, + * and that edit list does NOT survive into Matroska — so an aac→.mka remux + * decodes ~23 ms longer than the source, with the priming samples audible at + * the head instead of discarded. Measured: −66 dBFS, i.e. inaudible, but the + * track is then fractionally out of step with its own reported duration for + * no reason. Copying aac into .m4a keeps the edit list and decodes + * byte-for-byte identical to the audio inside the original video. + * + * AAC is worth special-casing because it is what mp4 / mov / m4v — the + * formats people actually upload — almost always carry. + */ +function extractedAudioExt(codec: string | null): string { + return codec === "aac" ? ".m4a" : EXTRACTED_AUDIO_EXT; +} + function isSupportedUploadExt(ext: string): boolean { return AUDIO_EXTENSIONS.has(ext) || VIDEO_EXTENSIONS.has(ext); } @@ -108,6 +127,9 @@ export interface MediaProbe { /** True when ffmpeg reported at least one audio stream. Only meaningful * together with `recognized` — see the comment there. */ hasAudio: boolean; + /** Lowercased codec name of the first audio stream ("aac", "mp3", "opus", + * …), or null when there is none. Picks the remux container. */ + audioCodec: string | null; /** * True when ffmpeg actually opened the container and printed its * `Input #0, , from '...'` header. @@ -138,11 +160,13 @@ export function parseMediaProbe(stderr: string): Omit { // the bracketed id/language vary, so match on the "Audio:" tag itself. An // embedded cover image is a separate "Video: mjpeg ... [attached pic]" line // and never matches this. - const hasAudio = /Stream #\d+:\d+[^\n]*:\s*Audio:/.test(stderr); + const audioMatch = stderr.match(/Stream #\d+:\d+[^\n]*:\s*Audio:\s*([A-Za-z0-9_]+)/); + const hasAudio = audioMatch !== null; + const audioCodec = audioMatch ? audioMatch[1].toLowerCase() : null; // "Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'clip.mp4':" — absent entirely // when ffmpeg bails with "Error opening input: Invalid data found ...". const recognized = /^Input #\d+,/m.test(stderr); - return { durationSeconds, hasAudio, recognized }; + return { durationSeconds, hasAudio, audioCodec, recognized }; } async function probeMedia(filePath: string): Promise { @@ -162,14 +186,14 @@ async function probeMedia(filePath: string): Promise { // slow, so allow more than the old 5s before giving up. const timeout = setTimeout(() => { ffmpeg.kill("SIGKILL"); - done({ durationSeconds: 0, hasAudio: false, recognized: false, probed: false }); + done({ durationSeconds: 0, hasAudio: false, audioCodec: null, recognized: false, probed: false }); }, 20000); ffmpeg.stderr.on("data", (chunk) => { stderr += chunk.toString("utf8"); }); ffmpeg.on("error", () => { clearTimeout(timeout); - done({ durationSeconds: 0, hasAudio: false, recognized: false, probed: false }); + done({ durationSeconds: 0, hasAudio: false, audioCodec: null, recognized: false, probed: false }); }); ffmpeg.on("close", () => { clearTimeout(timeout); @@ -309,7 +333,7 @@ export class LocalMusicProvider implements MusicProvider { try { probe = await probeMedia(filePath); } catch { - probe = { durationSeconds: 0, hasAudio: false, recognized: false, probed: false }; + probe = { durationSeconds: 0, hasAudio: false, audioCodec: null, recognized: false, probed: false }; } // Reject a video with no audio track up front (#149). Left to playback it @@ -327,8 +351,17 @@ export class LocalMusicProvider implements MusicProvider { if (isVideo) { // Keep only the audio. The video bytes are dead weight against the // upload-directory quota and would never be used. - const extracted = path.join(this.uploadDir, `${id}${EXTRACTED_AUDIO_EXT}`); - if (await extractAudioTrack(filePath, extracted)) { + // Preferred container first; if that remux fails (a codec the container + // will not take), retry into Matroska, which takes almost anything. + const preferredExt = extractedAudioExt(probe.audioCodec); + let extracted = path.join(this.uploadDir, `${id}${preferredExt}`); + let ok = await extractAudioTrack(filePath, extracted); + if (!ok && preferredExt !== EXTRACTED_AUDIO_EXT) { + rmSync(extracted, { force: true }); + extracted = path.join(this.uploadDir, `${id}${EXTRACTED_AUDIO_EXT}`); + ok = await extractAudioTrack(filePath, extracted); + } + if (ok) { try { // Commit filePath and size TOGETHER, and only after the source is // actually gone. rmSync(force) still throws EBUSY/EPERM on Windows, diff --git a/test-ts6-version.cjs b/test-ts6-version.cjs new file mode 100644 index 0000000..24a1a70 --- /dev/null +++ b/test-ts6-version.cjs @@ -0,0 +1,92 @@ +const { Client, generateIdentity } = require('@honeybbq/teamspeak-client'); + +function escapeTS3(value) { + return value + .replace(/\\/g, "\\\\") + .replace(/\//g, "\\/") + .replace(/ /g, "\\s") + .replace(/\|/g, "\\p") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/\t/g, "\\t"); +} + +function replaceField(cmd, key, value) { + const escaped = escapeTS3(value); + const regex = new RegExp(key + "=\\S*"); + if (regex.test(cmd)) return cmd.replace(regex, key + "=" + escaped); + return cmd; +} + +// Version to test - passed via env +const VERSIONS = [ + { + name: "6.0.0-beta2", + version: "6.0.0-beta2 [Build: 1737468425]", + platform: "Windows", + sign: "b5mySiqYAd4Lv5TZNflW+G5Gk8y7Woo9YnJfHRjmzhUyMdGfF1O7fSGJzmx2Hpe/PKaY2aDuKKD6lSxbLYlKCw==", + }, + { + name: "3.?.? wildcard", + version: "3.?.? [Build: 5680278000]", + platform: "Windows", + sign: "DX5NIYLvfJEUjuIbCidnoeozxIDRRkpq3I9vVMBmE9L2qnekOoBzSenkzsg2lC9CMv8K5hkEzhr2TYUYSwUXCg==", + }, + { + name: "5.0.0-beta77", + version: "5.0.0-beta77 [Build: 1702382332]", + platform: "Windows", + sign: "Ee6DzP16MUXpdKWjiSY0NGb4thN22/Ks0hwNcaMrWoaadgkM6c5477X0IbGFWVjzTWfjFTEad5noYLUPDWSgCQ==", + }, + { + name: "3.6.2 (corrected sign)", + version: "3.6.2 [Build: 1695203293]", + platform: "Windows", + sign: "4BdaZpdgUSMCuIs8qcloJPNxNlJ4o7QKnxMCRO60mSOTtJZyKjOrGLAmeAEtLIJjcjmdSpycMbQOIV92K2vXAw==", + }, +]; + +const idx = parseInt(process.env.VERSION_IDX || "0"); +const V = VERSIONS[idx]; + +const identity = generateIdentity(8); +const client = new Client(identity, "localhost:9987", "MusicBot", { + logger: { + debug: () => {}, + info: (m) => console.log("[INFO]", m), + warn: (m) => console.log("[WARN]", m), + error: (m) => console.log("[ERROR]", m), + }, +}); + +console.log("Testing version: " + V.name + " -> " + V.version); +client.connect().then(() => { + const handler = client.handler; + const origSendPacket = handler.sendPacket.bind(handler); + handler.sendPacket = (pType, data, flags) => { + if (pType === 2) { + let str = Buffer.from(data).toString("utf-8"); + if (str.startsWith("clientinit ")) { + str = replaceField(str, "client_version", V.version); + str = replaceField(str, "client_platform", V.platform); + str = replaceField(str, "client_version_sign", V.sign); + console.log("[PATCHED] version=" + V.version); + origSendPacket(pType, Buffer.from(str), flags); + return; + } + } + origSendPacket(pType, data, flags); + }; + return client.waitConnected(); +}).then(() => { + console.log("SUCCESS! Connected with clientId = " + client.clientID()); + client.disconnect(); + process.exit(0); +}).catch((err) => { + console.log("ERROR:", err && err.message || err); + process.exit(1); +}); +setTimeout(() => { + console.log("TIMEOUT - version rejected"); + process.exit(2); +}, 10000);