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:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-07-09 09:36:56 -04:00
1 parent 17b48cbfd7
commit efdf497c59
7 files changed
+103 -14

No files matched your search

+8 -3
View File
@@ -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
+1
View File
@@ -18,6 +18,7 @@ export {
buildNip71VideoEvent,
collectNip71PointerRequests,
convertEventToVideo,
isAudioOnlyVideoObject,
extractNip71MetadataFromTags,
getDTagValueFromTags,
mergeNip71MetadataIntoVideo,
+7
View File
@@ -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
View File
@@ -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)" };
}
+8 -1
View File
@@ -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) {
+1 -1
View File
@@ -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,
+51 -1
View File
@@ -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);
});