Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions web/components/details/sourcePlaybackPresentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<StreamSource, "source" | "addonName" | "description" | "behaviorHints">) {
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";
Expand Down
20 changes: 15 additions & 5 deletions web/lib/addons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? {}),
Expand Down
22 changes: 17 additions & 5 deletions web/lib/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
}
Expand All @@ -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;
Expand Down
60 changes: 58 additions & 2 deletions web/tests/addon-stream-info.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
}
}
});
29 changes: 29 additions & 0 deletions web/tests/source-title-runtime-types.test.cjs
Original file line number Diff line number Diff line change
@@ -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');
});
Loading