From 1d68206a569631dd656aac9dc4b77d4c84baa0a2 Mon Sep 17 00:00:00 2001 From: petomalik6-duda Date: Mon, 2 Nov 2026 20:30:47 +0100 Subject: [PATCH 1/3] fix(web): guard malformed source title fields --- web/components/details/sourcePlaybackPresentation.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/web/components/details/sourcePlaybackPresentation.ts b/web/components/details/sourcePlaybackPresentation.ts index e71b1da88d..381f83df18 100644 --- a/web/components/details/sourcePlaybackPresentation.ts +++ b/web/components/details/sourcePlaybackPresentation.ts @@ -135,15 +135,21 @@ export function sourceBadges(stream: StreamSource, info: { debrid: boolean; unca * what tells two "Torrentio 1080p" rows apart), else the addon's own label. */ export function sourceTitle(stream: Pick) { - const file = stream.behaviorHints?.filename?.trim(); + const rawFilename = stream.behaviorHints?.filename; + const file = typeof rawFilename === "string" ? rawFilename.trim() : ""; if (file) return file; - const firstLine = stream.description?.split("\n").map((line) => line.trim()).find(Boolean); + + const description = typeof stream.description === "string" ? stream.description : ""; + const firstLine = description.split("\n").map((line) => line.trim()).find(Boolean); // Stremio addons put the release name on the first description line and // decorations (šŸ‘¤ seeders, šŸ’¾ size, āš™ļø provider) on the following ones. if (firstLine && /[._ -]/.test(firstLine) && firstLine.length > 8 && !/^[šŸ‘¤šŸ’¾āš™ļøšŸ”—šŸ“¦]/u.test(firstLine)) { return firstLine.replace(/\s*šŸ’¾.*$/u, "").trim() || firstLine; } - return stream.source || stream.addonName; + + if (typeof stream.source === "string" && stream.source) return stream.source; + if (typeof stream.addonName === "string" && stream.addonName) return stream.addonName; + return "Source"; } export type SourceFilter = "all" | "here" | "4k" | "1080p" | "cached"; From 3ea3f102d9747f9d98801b5c593ce7ad764a5f41 Mon Sep 17 00:00:00 2001 From: petomalik6-duda Date: Mon, 2 Nov 2026 20:31:16 +0100 Subject: [PATCH 2/3] test(web): cover malformed source title fields --- web/tests/source-title-runtime-types.test.cjs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 web/tests/source-title-runtime-types.test.cjs diff --git a/web/tests/source-title-runtime-types.test.cjs b/web/tests/source-title-runtime-types.test.cjs new file mode 100644 index 0000000000..387812c9e6 --- /dev/null +++ b/web/tests/source-title-runtime-types.test.cjs @@ -0,0 +1,29 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { load } = require('./load.cjs'); + +const { sourceTitle } = load('components/details/sourcePlaybackPresentation.ts'); +const stream = (overrides = {}) => ({ + source: 'Fixture', + addonName: 'Library', + ...overrides +}); + +test('sourceTitle tolerates malformed runtime filename values', () => { + for (const filename of [123, { name: 'movie.mkv' }, ['movie.mkv']]) { + assert.equal(sourceTitle(stream({ behaviorHints: { filename } })), 'Fixture'); + } +}); + +test('sourceTitle tolerates malformed runtime description values', () => { + for (const description of [123, { text: 'movie' }, ['movie']]) { + assert.equal(sourceTitle(stream({ description })), 'Fixture'); + } +}); + +test('sourceTitle keeps valid provider labels and safe fallbacks', () => { + assert.equal(sourceTitle(stream({ behaviorHints: { filename: ' Movie.2026.1080p.mkv ' } })), 'Movie.2026.1080p.mkv'); + assert.equal(sourceTitle(stream({ description: 'Movie.2026.1080p.WEB-DL.mkv\nšŸ’¾ 3.2 GB' })), 'Movie.2026.1080p.WEB-DL.mkv'); + assert.equal(sourceTitle({ source: 42, addonName: 'Fallback addon', behaviorHints: { filename: null }, description: null }), 'Fallback addon'); + assert.equal(sourceTitle({ source: null, addonName: null, behaviorHints: { filename: {} }, description: {} }), 'Source'); +}); From 1f0efc8b80102b4e4a075e5f6a0cb16f12ac4fc4 Mon Sep 17 00:00:00 2001 From: Arvin Date: Fri, 6 Nov 2026 12:54:51 +0100 Subject: [PATCH 3/3] Normalize malformed stream labels before rendering and playback --- web/lib/addons.ts | 20 +++++++--- web/lib/resolver.ts | 22 +++++++--- web/tests/addon-stream-info.test.cjs | 60 +++++++++++++++++++++++++++- 3 files changed, 90 insertions(+), 12 deletions(-) diff --git a/web/lib/addons.ts b/web/lib/addons.ts index 9b9e5fc6d7..3970f0bc74 100644 --- a/web/lib/addons.ts +++ b/web/lib/addons.ts @@ -505,26 +505,36 @@ function addonSupportsId(addon: InstalledAddon, id: string) { ); } +function streamText(value: unknown) { + return typeof value === "string" ? value : undefined; +} + function normalizeStream(stream: RawStream, addon: InstalledAddon): StreamSource { - const text = [stream.title, stream.name, stream.description, stream.size].filter(Boolean).join(" "); + const name = streamText(stream.name); + const title = streamText(stream.title); + const description = streamText(stream.description); + const size = streamText(stream.size); + const addonName = streamText(addon.name) ?? "Source"; + const text = [title, name, description, size].filter(Boolean).join(" "); const url = streamUrl(stream); const normalized: StreamSource = { - source: stream.name ?? stream.title ?? addon.name, - addonName: addon.name, + source: name ?? title ?? addonName, + addonName, addonId: addon.id, quality: detectQuality(text), - size: stream.size ?? sizeLabel(stream.sizeBytes ?? stream.behaviorHints?.videoSize ?? null), + size: size ?? sizeLabel(stream.sizeBytes ?? stream.behaviorHints?.videoSize ?? null), sizeBytes: stream.sizeBytes ?? stream.behaviorHints?.videoSize ?? null, url, infoHash: stream.infoHash ?? null, fileIdx: stream.fileIdx ?? null, behaviorHints: { ...(stream.behaviorHints ?? {}), + filename: streamText(stream.behaviorHints?.filename) ?? null, notWebReady: !url && Boolean(stream.infoHash || stream.ytId || stream.behaviorHints?.videoHash) }, subtitles: stream.subtitles ?? [], sources: stream.sources ?? [], - description: stream.description ?? stream.title ?? null + description: description ?? title ?? null }; normalized.behaviorHints = { ...(normalized.behaviorHints ?? {}), diff --git a/web/lib/resolver.ts b/web/lib/resolver.ts index 71ddf736de..d92a267a3a 100644 --- a/web/lib/resolver.ts +++ b/web/lib/resolver.ts @@ -8,6 +8,18 @@ type ResolverEvent = | { type: "final"; cached?: boolean; streams?: StreamSource[] } | { type: "error"; error?: string }; +function normalizeStreamLabels(stream: StreamSource): StreamSource { + const hints = stream.behaviorHints; + return { + ...stream, + source: typeof stream.source === "string" ? stream.source : "", + addonName: typeof stream.addonName === "string" ? stream.addonName : "Source", + description: typeof stream.description === "string" ? stream.description : null, + size: typeof stream.size === "string" ? stream.size : undefined, + behaviorHints: hints && { ...hints, filename: typeof hints.filename === "string" ? hints.filename : null } + }; +} + export async function getResolverStreamsProgressive( addons: InstalledAddon[], item: MediaItem, @@ -30,7 +42,7 @@ export async function getResolverStreamsProgressive( if (!response.body || contentType.includes("application/json")) { const payload = (await response.json()) as ResolverEvent; if (payload.type === "error") throw new Error(payload.error ?? "Resolver failed"); - const streams = payload.type === "final" ? payload.streams ?? [] : []; + const streams = payload.type === "final" ? payload.streams?.map(normalizeStreamLabels) ?? [] : []; if (streams.length) onUpdate?.(streams, streams); return streams; } @@ -51,18 +63,18 @@ export async function getResolverStreamsProgressive( if (!event) continue; if (event.type === "error") throw new Error(event.error ?? "Resolver failed"); if (event.type === "batch") { - latest = event.streams ?? latest; - onUpdate?.(latest, event.batch ?? []); + latest = event.streams?.map(normalizeStreamLabels) ?? latest; + onUpdate?.(latest, event.batch?.map(normalizeStreamLabels) ?? []); } if (event.type === "final") { - latest = event.streams ?? latest; + latest = event.streams?.map(normalizeStreamLabels) ?? latest; } } } if (buffer.trim()) { const event = parseEvent(buffer); - if (event?.type === "final") latest = event.streams ?? latest; + if (event?.type === "final") latest = event.streams?.map(normalizeStreamLabels) ?? latest; } return latest; diff --git a/web/tests/addon-stream-info.test.cjs b/web/tests/addon-stream-info.test.cjs index 85fc1f3cd6..0be45ff048 100644 --- a/web/tests/addon-stream-info.test.cjs +++ b/web/tests/addon-stream-info.test.cjs @@ -45,11 +45,19 @@ test('real media and torrent results survive informational-looking titles', () = ]) assert.equal(info.isInformationalAddonStream(entry), false, JSON.stringify(entry)); }); -function addonClient({ resolver = false, raw = [], remote = [] } = {}) { +function addonClient({ resolver = false, raw = [], remote = [], cached = false } = {}) { + const final = { type: 'final', streams: remote }; + const batch = { type: 'batch', streams: remote, batch: remote }; + const relay = load('lib/resolver.ts', { './config': { config: { resolverUrl: 'https://relay.example.test' } } }, { + fetch: async () => cached ? Response.json(final) : new Response( + [batch, final].map(event => JSON.stringify(event)).join('\n'), + { headers: { 'content-type': 'application/x-ndjson' } } + ) + }); return load('lib/addons.ts', { './http': { proxiedUrl: url => url, jsonRequest: async url => url.includes('/subtitles/') ? { subtitles: [] } : { streams: raw } }, './config': { hasResolverConfig: () => resolver }, - './resolver': { getResolverStreamsProgressive: async (_addons, _item, _season, _episode, update) => { update?.(remote, remote); return remote; } }, + './resolver': relay, './storage': { loadStored: (_key, fallback) => fallback, saveStored() {} }, './streamCompatibility': { isBrowserPlayableStream: stream => !!stream.url, isIosPlayableStream: stream => !!stream.url }, './addonStreamInfo': info @@ -88,3 +96,51 @@ test('resolver notices are filtered from every progressive batch and final merge assert.ok(batches.length > 0); assert.ok(batches.every(stream => stream.source === 'Movie')); }); + +test('browser addon results keep playable URLs when release metadata is not text', async () => { + const url = 'https://cdn.example.test/movie.mp4'; + const headers = { request: { Referer: 'https://addon.example.test/' } }; + const client = addonClient({ raw: [{ + url, name: 42, title: 'Movie.2026.1080p', description: { toString: null }, + size: ['5 GB'], behaviorHints: { filename: 123, proxyHeaders: headers } + }] }); + const [stream] = await client.getStreams([addon], item); + assert.equal(stream.url, url); + assert.equal(stream.source, 'Movie.2026.1080p'); + assert.equal(stream.description, 'Movie.2026.1080p'); + assert.equal(stream.behaviorHints.filename, null); + assert.equal(stream.behaviorHints.proxyHeaders, headers); + assert.equal(stream.quality, '1080p'); +}); + +test('resolver results normalize malformed labels before batches and final sources reach the player', async () => { + const headers = { request: { Referer: 'https://addon.example.test/' } }; + const valid = { url: 'https://cdn.example.test/valid.mp4', source: 'Valid', addonName: 'Library', description: 'Movie.2026', behaviorHints: { filename: 'Movie.2026.mp4' } }; + const malformed = { + url: 'https://cdn.example.test/movie.mp4', addonId: 'fixture', source: ['movie'], addonName: { label: 'Invalid' }, + description: { toString: null }, behaviorHints: { filename: 123, proxyHeaders: headers } + }; + for (const cached of [true, false]) { + const client = addonClient({ resolver: true, remote: [valid, malformed], cached }); + const updates = []; + const final = await client.getStreamsProgressive([], item, undefined, undefined, (all, batch) => updates.push(...all, ...batch)); + assert.ok(updates.some(stream => stream.url === malformed.url)); + assert.equal(final.length, 2); + for (const stream of [...updates, ...final]) { + if (stream.url === valid.url) { + assert.equal(stream.source, valid.source); + assert.equal(stream.addonName, valid.addonName); + assert.equal(stream.description, valid.description); + assert.equal(stream.behaviorHints.filename, valid.behaviorHints.filename); + } else { + assert.equal(stream.url, malformed.url); + assert.equal(stream.addonId, malformed.addonId); + assert.equal(stream.addonName, 'Source'); + assert.equal(stream.source, ''); + assert.equal(stream.description, null); + assert.equal(stream.behaviorHints.filename, null); + assert.equal(stream.behaviorHints.proxyHeaders.request.Referer, headers.request.Referer); + } + } + } +});