mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
fix(feed): keep audio-only notes (podcasts/music) out of the video grid
A podcast/music note published as a native kind-30078 (content url = an .ogg/
.mp3 audio file, imeta `m audio/*`) was accepted by convertEventToVideo (it has
a url) and rendered as a broken <video> — black frame + audio on Chrome/FF,
MEDIA_ERR_SRC_NOT_SUPPORTED on Safari/iOS — since bitvid has no <audio> player.
The existing audio guard only covered the NIP-71 ingest path, not native events.
convertEventToVideo now marks such notes invalid ("audio-only source (no video)")
so every ingestion path that already drops invalid filters them: main feed buffer,
author/channel batch fetch, and local ingest. Audio-only = no magnet AND no
`imeta m video/*` AND (an `imeta m audio/*` variant OR an unambiguous audio URL
.mp3/.m4a/.aac/.wav/.flac/.opus/.oga/.weba). `.ogg` alone is NOT filtered
(ambiguous → video/ogg); only when imeta says audio. A video imeta variant wins.
Direct open-by-nevent is intentionally unaffected — an explicit link still loads.
Near-term half of TODO #60 (the future dedicated Audio tab remains open).
Tests: tests/video-schema-and-conversion.test.mjs (+5, 8/8). Lint + build green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
1a4e53bdb1
commit
17b48cbfd7
3 files changed
+173
-7
No files matched your search
@@ -1461,6 +1461,42 @@ function inferMimeTypeFromUrl(url) {
|
||||
* @param {import("nostr-tools").Event} event - The raw event.
|
||||
* @returns {Object} The normalized video object, or { invalid: true, reason: string }.
|
||||
*/
|
||||
// Inspect an event's `imeta` tags for declared media types. Native bitvid notes
|
||||
// (kind 30078) can carry `imeta` variants with an `m <mime>` field just like
|
||||
// NIP-71 events; a podcast/music note advertises `m audio/*`. Mirrors the
|
||||
// video/audio split in nip71IngestAdapter.js so the native path stays consistent.
|
||||
function detectImetaMediaKinds(tags = []) {
|
||||
let hasVideo = false;
|
||||
let hasAudio = false;
|
||||
for (const tag of Array.isArray(tags) ? tags : []) {
|
||||
if (!Array.isArray(tag) || tag[0] !== "imeta") {
|
||||
continue;
|
||||
}
|
||||
for (let i = 1; i < tag.length; i += 1) {
|
||||
const entry = tag[i];
|
||||
if (typeof entry !== "string") {
|
||||
continue;
|
||||
}
|
||||
const trimmed = entry.trim();
|
||||
if (!/^m\s+/i.test(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
const mime = trimmed.replace(/^m\s+/i, "").trim().toLowerCase();
|
||||
if (mime.startsWith("video/")) {
|
||||
hasVideo = true;
|
||||
} else if (mime.startsWith("audio/")) {
|
||||
hasAudio = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { hasVideo, hasAudio };
|
||||
}
|
||||
|
||||
// Unambiguous audio-only file extensions. `.ogg` is deliberately excluded — it
|
||||
// can be video (Theora) and bitvid maps it to video/ogg — so `.ogg` is only
|
||||
// treated as audio when an `imeta m audio/*` tag says so.
|
||||
const AUDIO_ONLY_URL_PATTERN = /\.(mp3|m4a|aac|wav|flac|opus|oga|weba)(?:[?#]|$)/i;
|
||||
|
||||
export function convertEventToVideo(event = {}) {
|
||||
const safeTrim = (value) => (typeof value === "string" ? value.trim() : "");
|
||||
|
||||
@@ -1507,6 +1543,19 @@ export function convertEventToVideo(event = {}) {
|
||||
return { id: event.id, invalid: true, reason: "missing playable source" };
|
||||
}
|
||||
|
||||
// Keep audio-only notes (podcasts/music published as native video events) out
|
||||
// of the video feed: bitvid has no <audio> player, so they render as a broken
|
||||
// <video>. Treat as audio-only only when there's NO video signal — an `imeta
|
||||
// m audio/*` variant (with no video variant) or an unambiguous audio URL — and
|
||||
// no magnet (a torrent could still be video). See TODO #60 for the future
|
||||
// dedicated Audio tab that will surface these properly.
|
||||
const { hasVideo: imetaHasVideo, hasAudio: imetaHasAudio } =
|
||||
detectImetaMediaKinds(tags);
|
||||
const urlLooksAudioOnly = url ? AUDIO_ONLY_URL_PATTERN.test(url) : false;
|
||||
if (!magnet && !imetaHasVideo && (imetaHasAudio || urlLooksAudioOnly)) {
|
||||
return { id: event.id, invalid: true, reason: "audio-only source (no video)" };
|
||||
}
|
||||
|
||||
const thumbnail = safeTrim(parsedContent.thumbnail);
|
||||
const description = safeTrim(parsedContent.description);
|
||||
const rawMode = safeTrim(parsedContent.mode);
|
||||
|
||||
@@ -76,3 +76,115 @@ test("convertEventToVideo normalizes nsfw and kids booleans", () => {
|
||||
assert.equal(defaultParsed.isNsfw, false);
|
||||
assert.equal(defaultParsed.isForKids, false);
|
||||
});
|
||||
|
||||
// TODO #60: audio-only notes (podcasts/music published as native kind-30078)
|
||||
// must not leak into the video feed — bitvid has no <audio> player, so they
|
||||
// render as a broken <video>. convertEventToVideo marks them invalid so every
|
||||
// feed/grid ingestion path (which already drops invalid) filters them out.
|
||||
|
||||
test("audio-only event with imeta m audio/* is filtered as invalid", () => {
|
||||
const podcast = {
|
||||
id: "evt-audio-imeta",
|
||||
content: JSON.stringify({
|
||||
version: 3,
|
||||
title: "Nostr Compass Podcast #20",
|
||||
url: "https://relay.example/episode.ogg",
|
||||
videoRootId: "root-podcast",
|
||||
}),
|
||||
tags: [
|
||||
[
|
||||
"imeta",
|
||||
"url https://relay.example/episode.ogg",
|
||||
"m audio/ogg",
|
||||
"duration 5254",
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
const parsed = convertEventToVideo(podcast);
|
||||
assert.equal(parsed.invalid, true, "audio-only note should be invalid");
|
||||
assert.match(parsed.reason, /audio-only/);
|
||||
});
|
||||
|
||||
test("audio-only url extension (mp3) with no imeta is filtered", () => {
|
||||
const track = {
|
||||
id: "evt-audio-ext",
|
||||
content: JSON.stringify({
|
||||
version: 3,
|
||||
title: "A song",
|
||||
url: "https://cdn.example/track.mp3?v=2",
|
||||
videoRootId: "root-track",
|
||||
}),
|
||||
tags: [],
|
||||
};
|
||||
|
||||
assert.equal(convertEventToVideo(track).invalid, true);
|
||||
});
|
||||
|
||||
test(".ogg url WITHOUT an audio imeta is kept (ambiguous → treated as video)", () => {
|
||||
const ambiguousOgg = {
|
||||
id: "evt-ogg-ambiguous",
|
||||
content: JSON.stringify({
|
||||
version: 3,
|
||||
title: "Theora clip",
|
||||
url: "https://cdn.example/clip.ogg",
|
||||
videoRootId: "root-ogg",
|
||||
}),
|
||||
tags: [],
|
||||
};
|
||||
|
||||
const parsed = convertEventToVideo(ambiguousOgg);
|
||||
assert.equal(parsed.invalid, false, ".ogg alone must not be filtered");
|
||||
assert.equal(parsed.url, "https://cdn.example/clip.ogg");
|
||||
});
|
||||
|
||||
test("a video imeta variant wins even when an audio variant is also present", () => {
|
||||
const mixed = {
|
||||
id: "evt-mixed",
|
||||
content: JSON.stringify({
|
||||
version: 3,
|
||||
title: "Talk with audio alt",
|
||||
url: "https://cdn.example/talk.mp4",
|
||||
videoRootId: "root-mixed",
|
||||
}),
|
||||
tags: [
|
||||
["imeta", "url https://cdn.example/talk.mp4", "m video/mp4"],
|
||||
["imeta", "url https://cdn.example/talk.ogg", "m audio/ogg"],
|
||||
],
|
||||
};
|
||||
|
||||
assert.equal(convertEventToVideo(mixed).invalid, false);
|
||||
});
|
||||
|
||||
test("an audio url paired with a magnet is kept (torrent may be video)", () => {
|
||||
const withMagnet = {
|
||||
id: "evt-audio-magnet",
|
||||
content: JSON.stringify({
|
||||
version: 3,
|
||||
title: "Torrented",
|
||||
url: "https://cdn.example/preview.mp3",
|
||||
magnet: "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567",
|
||||
videoRootId: "root-magnet",
|
||||
}),
|
||||
tags: [["imeta", "url https://cdn.example/preview.mp3", "m audio/mpeg"]],
|
||||
};
|
||||
|
||||
assert.equal(convertEventToVideo(withMagnet).invalid, false);
|
||||
});
|
||||
|
||||
test("a normal video (mp4) event is unaffected by the audio guard", () => {
|
||||
const video = {
|
||||
id: "evt-video",
|
||||
content: JSON.stringify({
|
||||
version: 3,
|
||||
title: "Real video",
|
||||
url: "https://cdn.example/real.mp4",
|
||||
videoRootId: "root-video",
|
||||
}),
|
||||
tags: [["imeta", "url https://cdn.example/real.mp4", "m video/mp4"]],
|
||||
};
|
||||
|
||||
const parsed = convertEventToVideo(video);
|
||||
assert.equal(parsed.invalid, false);
|
||||
assert.equal(parsed.url, "https://cdn.example/real.mp4");
|
||||
});
|
||||
@@ -1999,13 +1999,18 @@ and hands it to a `<video>` element → black frame + audio on Chrome/FF, or
|
||||
The NIP-71 *ingest* guard (`nip71IngestAdapter.js` — "exclude audio/image/etc.")
|
||||
only covers ingested kinds 21/22/34235/34236, not the native 30078 path. Real
|
||||
example: event `95053d75…` "Nostr Compass Podcast #20" (pubkey `b7ed68b0…`).
|
||||
- [ ] **Near-term (launch hygiene, separate small task):** stop audio-only notes
|
||||
from rendering as broken video. Extend the existing audio guard to the native
|
||||
path (`convertEventToVideo` / author-fetch): if the only source is audio
|
||||
(`imeta m audio/*`, or an audio-only `.ogg/.mp3/.m4a/.opus/.wav` URL with no
|
||||
video track) mark it filtered so it doesn't appear in the video grid. Mirrors
|
||||
the ingest adapter's behavior. Test: audio-only event → filtered; video event
|
||||
→ unaffected. (This is the immediate fix; the tab below is the real feature.)
|
||||
- [x] **Near-term (launch hygiene) — DONE 2026-07-09.** `convertEventToVideo`
|
||||
(`js/nostr/nip71.js`) now marks audio-only notes `invalid` (reason
|
||||
"audio-only source (no video)") so every feed/grid ingestion path that already
|
||||
drops invalid filters them out: main feed buffer (`videoEventBuffer.js:145`),
|
||||
author/channel batch fetch (`client.js:4192`), local ingest (`client.js:4500`).
|
||||
Audio-only = no magnet AND no `imeta m video/*` AND (an `imeta m audio/*`
|
||||
variant OR an unambiguous audio URL `.mp3/.m4a/.aac/.wav/.flac/.opus/.oga/.weba`).
|
||||
`.ogg` is NOT filtered on extension alone (ambiguous → video/ogg) — only when
|
||||
an `imeta m audio/*` says so. A video imeta variant always wins. Tests:
|
||||
`tests/video-schema-and-conversion.test.mjs` (+5, 8/8). NOTE: the direct
|
||||
open-by-nevent path (`client.js:4129`) still resolves an audio event on
|
||||
purpose — an explicit link should still load; only feeds are filtered.
|
||||
- [ ] **The feature:** a dedicated **Audio** sidebar tab (podcasts / music / audio)
|
||||
with its own player. Detect `audio/*` sources and render an `<audio>` UI
|
||||
(poster from the `image`/thumbnail tag + transport controls, duration, seek)
|
||||
|
||||
Reference in new issue
Block a user