From c88401d7c7c394781de0e29db6f7311213218001 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 5 Jun 2026 16:29:41 +0200 Subject: [PATCH] fix: restore niconico audio playback --- apps/web/src/lib/nico-hls-manifest.ts | 115 ++++++++++++++++++++++++++ apps/web/src/lib/stream-src.ts | 9 +- 2 files changed, 118 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/lib/nico-hls-manifest.ts diff --git a/apps/web/src/lib/nico-hls-manifest.ts b/apps/web/src/lib/nico-hls-manifest.ts new file mode 100644 index 0000000..445f2f6 --- /dev/null +++ b/apps/web/src/lib/nico-hls-manifest.ts @@ -0,0 +1,115 @@ +import type { AudioStreamItem, VideoStreamItem } from "../types/api"; +import { proxyUrl } from "./proxy"; + +const AUDIO_GROUP_ID = "audio"; +const FALLBACK_VIDEO_BANDWIDTHS = [ + [1080, 5_000_000], + [720, 2_500_000], + [480, 1_200_000], + [360, 800_000], +] as const; + +function encodeManifest(value: string): string { + const bytes = new TextEncoder().encode(value); + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); +} + +function quote(value: string): string { + return value + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"') + .replace(/[\r\n]/g, " "); +} + +function positive(value: number | null | undefined): number | null { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null; +} + +function heightFromResolution(value: string): number | null { + return positive(Number(value.match(/(\d+)\s*[pP]/)?.[1])); +} + +function videoHeight(stream: VideoStreamItem): number | null { + return positive(stream.height) ?? heightFromResolution(stream.resolution); +} + +function videoResolution(stream: VideoStreamItem): string | null { + const height = videoHeight(stream); + const width = height === null ? null : (positive(stream.width) ?? Math.round((height * 16) / 9)); + return height === null || width === null ? null : `${width}x${height}`; +} + +function bitrateBandwidth(value: number | null): number | null { + const bitrate = positive(value); + return bitrate === null ? null : bitrate < 10_000 ? bitrate * 1000 : bitrate; +} + +function fallbackVideoBandwidth(stream: VideoStreamItem): number { + const height = videoHeight(stream) ?? 360; + return FALLBACK_VIDEO_BANDWIDTHS.find(([minimum]) => height >= minimum)?.[1] ?? 500_000; +} + +function videoBandwidth(stream: VideoStreamItem): number { + return bitrateBandwidth(stream.bitrate) ?? fallbackVideoBandwidth(stream); +} + +function qualityBandwidth(value: string | null): number | null { + return positive(Number(value?.match(/(\d+)/)?.[1])); +} + +function audioBandwidth(stream: AudioStreamItem): number { + const quality = qualityBandwidth(stream.quality); + return bitrateBandwidth(stream.bitrate) ?? (quality !== null ? quality * 1000 : 128_000); +} + +function audioName(stream: AudioStreamItem, index: number): string { + return ( + [stream.audioTrackName, stream.quality ? `${stream.quality} kbps` : null].find( + (value): value is string => typeof value === "string" && value.length > 0, + ) ?? `Audio ${index + 1}` + ); +} + +function audioMedia(stream: AudioStreamItem, index: number): string { + const name = quote(audioName(stream, index)); + const isDefault = index === 0 ? "YES" : "NO"; + return [ + `#EXT-X-MEDIA:TYPE=AUDIO`, + `GROUP-ID="${AUDIO_GROUP_ID}"`, + `NAME="${name}"`, + `DEFAULT=${isDefault}`, + `AUTOSELECT=YES`, + `URI="${quote(proxyUrl(stream.url))}"`, + ].join(","); +} + +function streamInfo(stream: VideoStreamItem, audio: AudioStreamItem | undefined): string { + const bandwidth = videoBandwidth(stream) + (audio ? audioBandwidth(audio) : 0); + const resolution = videoResolution(stream); + const attributes = [ + `BANDWIDTH=${bandwidth}`, + resolution ? `RESOLUTION=${resolution}` : null, + audio ? `AUDIO="${AUDIO_GROUP_ID}"` : null, + ].filter((attribute) => attribute !== null); + return `#EXT-X-STREAM-INF:${attributes.join(",")}`; +} + +export function buildNicoHlsManifest( + videoStreams: VideoStreamItem[], + audioStreams: AudioStreamItem[], +): string | null { + const videos = videoStreams.filter((stream) => stream.url.length > 0); + const audios = audioStreams.filter((stream) => stream.url.length > 0); + if (videos.length === 0) return null; + + const primaryAudio = audios[0]; + const lines = ["#EXTM3U", "#EXT-X-VERSION:6", ...audios.map(audioMedia)]; + for (const video of videos) { + lines.push(streamInfo(video, primaryAudio), proxyUrl(video.url)); + } + + const manifest = `${lines.join("\n")}\n`; + return `data:application/vnd.apple.mpegurl;base64,${encodeManifest(manifest)}`; +} diff --git a/apps/web/src/lib/stream-src.ts b/apps/web/src/lib/stream-src.ts index ea88841..42c35dc 100644 --- a/apps/web/src/lib/stream-src.ts +++ b/apps/web/src/lib/stream-src.ts @@ -2,6 +2,7 @@ import type { VideoStream } from "../types/stream"; import { buildBilibiliDashManifest } from "./bilibili-manifest"; import { buildDashManifest } from "./dash-manifest"; import { API_BASE as BASE } from "./env"; +import { buildNicoHlsManifest } from "./nico-hls-manifest"; import { isCompatibilityPlaybackMode } from "./playback-mode"; import { detectProvider } from "./provider"; import { proxyDashManifest } from "./proxy"; @@ -51,10 +52,6 @@ function fallbackSrc( }; } -function pickNicoHlsUrl(stream: VideoStream): string | null { - return stream.videoOnlyStreams?.[0]?.url ?? stream.audioStreams?.[0]?.url ?? null; -} - export function resolveManifestSrc( stream: VideoStream, isLive: boolean, @@ -78,8 +75,8 @@ export function resolveManifestSrc( } if (provider === "nicovideo") { - const hlsUrl = pickNicoHlsUrl(stream); - if (hlsUrl) return { src: proxyDashManifest(hlsUrl), type: "application/x-mpegurl" }; + const built = buildNicoHlsManifest(stream.videoOnlyStreams ?? [], stream.audioStreams ?? []); + if (built) return { src: built, type: "application/x-mpegurl" }; } if (provider === "bilibili") {