mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
fix(feed): filter cached audio-only notes at render + hydration, not just ingest
The convert-time guard (17b48cbf) only blocks FRESH events. Audio notes cached
before it existed are rehydrated straight from IndexedDB/localStorage as
already-converted video objects (`sig: "unavailable (cached)"`), bypassing
convertEventToVideo — so they kept showing in the main feed and channel grids.
Extract the audio check into a reusable object-level predicate
`isAudioOnlyVideoObject(video)` (url + magnet + imeta tags) and apply it at every
boundary a cached object can slip through:
- convertEventToVideo now delegates to it (fresh ingest, unchanged behavior)
- PersistenceManager.applyCachedPayload drops audio-only on hydration (purges
the cache going forward)
- NostrClient.getActiveVideos filters it (main feed, immediate effect)
- nostrService.shouldIncludeVideo filters it above the viewer-is-author
short-circuit (channel/author grids, incl. the creator's own channel)
Audio-only = no magnet AND no imeta `m video/*` AND (imeta `m audio/*` OR an
unambiguous audio url); `.ogg` alone stays video. Bumped client.js size baseline
(+6 lines). Tests: video-schema-and-conversion (+3 predicate scenarios, 11/11);
nostr-service + ingest-adapter suites green. Lint + build clean. TODO #60.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
17b48cbfd7
commit
efdf497c59
7 files changed
+103
-14
No files matched your search
+8
-3
@@ -25,6 +25,7 @@ import {
|
||||
buildNip71MetadataTags,
|
||||
collectNip71PointerRequests,
|
||||
convertEventToVideo,
|
||||
isAudioOnlyVideoObject,
|
||||
extractNip71MetadataFromTags,
|
||||
getDTagValueFromTags,
|
||||
mergeNip71MetadataIntoVideo as mergeNip71MetadataIntoVideoHelper,
|
||||
@@ -4469,9 +4470,13 @@ export class NostrClient {
|
||||
}
|
||||
|
||||
getActiveVideos() {
|
||||
return Array.from(this.activeMap.values()).sort(
|
||||
(a, b) => b.created_at - a.created_at
|
||||
);
|
||||
// Filter audio-only notes (podcasts/music) here too, not just at ingest:
|
||||
// video objects rehydrated from the persisted cache bypass
|
||||
// convertEventToVideo, so a note cached before the audio guard existed would
|
||||
// otherwise still reach the feed. See TODO #60.
|
||||
return Array.from(this.activeMap.values())
|
||||
.filter((video) => !isAudioOnlyVideoObject(video))
|
||||
.sort((a, b) => b.created_at - a.created_at);
|
||||
}
|
||||
|
||||
// Optimistically add a just-published video event to the local caches so it
|
||||
|
||||
@@ -18,6 +18,7 @@ export {
|
||||
buildNip71VideoEvent,
|
||||
collectNip71PointerRequests,
|
||||
convertEventToVideo,
|
||||
isAudioOnlyVideoObject,
|
||||
extractNip71MetadataFromTags,
|
||||
getDTagValueFromTags,
|
||||
mergeNip71MetadataIntoVideo,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { EventsCacheStore } from "./EventsCacheStore.js";
|
||||
import { isAudioOnlyVideoObject } from "../nip71.js";
|
||||
import { devLogger, userLogger } from "../../utils/logger.js";
|
||||
import { isDevMode } from "../../config.js";
|
||||
import { NOTE_TYPES } from "../../nostrEventSchemas.js";
|
||||
@@ -154,6 +155,12 @@ export class PersistenceManager {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Drop audio-only notes (podcasts/music) cached before the audio guard
|
||||
// existed so they don't get rehydrated into the feed. See TODO #60.
|
||||
if (isAudioOnlyVideoObject(video)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.client.applyRootCreatedAt(video);
|
||||
const activeKey = this.client.getActiveKey(video);
|
||||
|
||||
|
||||
+27
-8
@@ -1497,6 +1497,31 @@ function detectImetaMediaKinds(tags = []) {
|
||||
// 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;
|
||||
|
||||
// Whether a (converted) video object is an audio-only note — a podcast/music
|
||||
// note published as a native kind-30078. bitvid has no <audio> player, so these
|
||||
// render as a broken <video> and must be kept out of the video feeds. Operates
|
||||
// on a video object (has `url`, `magnet`, `tags`) so it works on BOTH freshly
|
||||
// converted events AND video objects rehydrated from the persisted cache (which
|
||||
// bypass convertEventToVideo). Audio-only = no magnet (a torrent could be video)
|
||||
// AND no `imeta m video/*` variant AND (an `imeta m audio/*` variant OR an
|
||||
// unambiguous audio URL). See TODO #60.
|
||||
export function isAudioOnlyVideoObject(video) {
|
||||
if (!video || typeof video !== "object") {
|
||||
return false;
|
||||
}
|
||||
const url = typeof video.url === "string" ? video.url.trim() : "";
|
||||
const magnet = typeof video.magnet === "string" ? video.magnet.trim() : "";
|
||||
if (magnet) {
|
||||
return false;
|
||||
}
|
||||
const { hasVideo, hasAudio } = detectImetaMediaKinds(video.tags);
|
||||
if (hasVideo) {
|
||||
return false;
|
||||
}
|
||||
const urlLooksAudioOnly = url ? AUDIO_ONLY_URL_PATTERN.test(url) : false;
|
||||
return hasAudio || urlLooksAudioOnly;
|
||||
}
|
||||
|
||||
export function convertEventToVideo(event = {}) {
|
||||
const safeTrim = (value) => (typeof value === "string" ? value.trim() : "");
|
||||
|
||||
@@ -1545,14 +1570,8 @@ export function convertEventToVideo(event = {}) {
|
||||
|
||||
// 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)) {
|
||||
// <video>. See TODO #60 for the future dedicated Audio tab.
|
||||
if (isAudioOnlyVideoObject({ url, magnet, tags })) {
|
||||
return { id: event.id, invalid: true, reason: "audio-only source (no video)" };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { nostrClient } from "../nostrClientFacade.js";
|
||||
import { convertEventToVideo } from "../nostr/index.js";
|
||||
import { convertEventToVideo, isAudioOnlyVideoObject } from "../nostr/index.js";
|
||||
import { accessControl } from "../accessControl.js";
|
||||
import { ALLOW_NSFW_CONTENT } from "../config.js";
|
||||
import { devLogger, userLogger } from "../utils/logger.js";
|
||||
@@ -1502,6 +1502,13 @@ export class NostrService {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Audio-only notes (podcasts/music) have no <video> to play — keep them out
|
||||
// of every video grid, even the viewer's own channel. Catches objects
|
||||
// rehydrated from a pre-guard cache that skipped convertEventToVideo (#60).
|
||||
if (isAudioOnlyVideoObject(video)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const viewerIsAuthor = this.isViewerVideoAuthor(video);
|
||||
|
||||
if (viewerIsAuthor) {
|
||||
|
||||
@@ -30,7 +30,7 @@ const GRANDFATHERED = {
|
||||
"js/ui/components/VideoModal.js": 4976,
|
||||
"js/app.js": 5602,
|
||||
"js/channelProfile.js": 5806,
|
||||
"js/nostr/client.js": 4356,
|
||||
"js/nostr/client.js": 4362,
|
||||
"js/ui/components/VideoCard.js": 3129,
|
||||
"js/nostrEventSchemas.js": 3541,
|
||||
"js/ui/loginModalController.js": 2552,
|
||||
|
||||
@@ -5,7 +5,10 @@ import {
|
||||
NOTE_TYPES,
|
||||
getNostrEventSchema,
|
||||
} from "../js/nostrEventSchemas.js";
|
||||
import { convertEventToVideo } from "../js/nostr/nip71.js";
|
||||
import {
|
||||
convertEventToVideo,
|
||||
isAudioOnlyVideoObject,
|
||||
} from "../js/nostr/nip71.js";
|
||||
|
||||
test("video post schema documents nsfw and kids flags", () => {
|
||||
const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_POST);
|
||||
@@ -188,3 +191,50 @@ test("a normal video (mp4) event is unaffected by the audio guard", () => {
|
||||
assert.equal(parsed.invalid, false);
|
||||
assert.equal(parsed.url, "https://cdn.example/real.mp4");
|
||||
});
|
||||
|
||||
// isAudioOnlyVideoObject operates on a CONVERTED video object (url/magnet/tags),
|
||||
// so the same guard also filters objects rehydrated from the persisted cache
|
||||
// (which bypass convertEventToVideo) at the feed query boundary + hydration.
|
||||
|
||||
test("isAudioOnlyVideoObject flags a cached audio object (imeta audio)", () => {
|
||||
// Shaped like the real cached "Nostr Compass #15" note.
|
||||
const cachedAudio = {
|
||||
id: "64a3c44c",
|
||||
url: "https://relay.example/f7556db6.mp3",
|
||||
magnet: "",
|
||||
tags: [["imeta", "url https://relay.example/f7556db6.mp3", "m audio/mpeg"]],
|
||||
};
|
||||
assert.equal(isAudioOnlyVideoObject(cachedAudio), true);
|
||||
});
|
||||
|
||||
test("isAudioOnlyVideoObject flags a cached .ogg only when imeta says audio", () => {
|
||||
const cachedOggAudio = {
|
||||
url: "https://relay.example/ep.ogg",
|
||||
magnet: "",
|
||||
tags: [["imeta", "url https://relay.example/ep.ogg", "m audio/ogg"]],
|
||||
};
|
||||
const cachedOggNoImeta = {
|
||||
url: "https://relay.example/ep.ogg",
|
||||
magnet: "",
|
||||
tags: [],
|
||||
};
|
||||
assert.equal(isAudioOnlyVideoObject(cachedOggAudio), true, "audio imeta → audio");
|
||||
assert.equal(
|
||||
isAudioOnlyVideoObject(cachedOggNoImeta),
|
||||
false,
|
||||
".ogg alone is ambiguous → kept"
|
||||
);
|
||||
});
|
||||
|
||||
test("isAudioOnlyVideoObject keeps a normal cached video object", () => {
|
||||
assert.equal(
|
||||
isAudioOnlyVideoObject({
|
||||
url: "https://cdn.example/real.mp4",
|
||||
magnet: "",
|
||||
tags: [],
|
||||
}),
|
||||
false
|
||||
);
|
||||
assert.equal(isAudioOnlyVideoObject(null), false);
|
||||
assert.equal(isAudioOnlyVideoObject({}), false);
|
||||
});
|
||||
Reference in new issue
Block a user