Skip to content
Open
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
7 changes: 7 additions & 0 deletions packages/core/src/db/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,13 @@ export const UserDataSchema = z.object({
externalDownloads: z.boolean().optional(),
cacheAndPlay: CacheAndPlaySchema.optional(),

strmOutput: z
.object({
mode: z.enum(['disabled', 'always', 'userAgent']).optional(),
userAgents: z.array(z.string().min(1)).optional(),
})
.optional(),

autoRemoveDownloads: z.boolean().optional(),
});

Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/streams/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,10 @@ export class StreamContext {
this.userData.excludedStreamExpressions?.length ||
this.userData.requiredStreamExpressions?.length ||
this.userData.includedStreamExpressions?.length ||
(this.userData.precacheNextEpisode && this.type === 'series');
(this.userData.precacheNextEpisode && this.type === 'series') ||
// STRM output needs metadata for filename generation (title, year, tmdbId)
(this.userData.strmOutput?.mode &&
this.userData.strmOutput.mode !== 'disabled');

if (!needsMetadata || !this.parsedId) {
this._metadataFetched = true;
Expand Down
63 changes: 63 additions & 0 deletions packages/frontend/src/components/menu/miscellaneous.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,69 @@ function Content() {
/>
</SettingsCard>
)}
{mode === 'pro' && (
<SettingsCard
title="STRM Output"
description={
<div className="space-y-2">
<p>
When enabled, stream URLs are routed through a gate endpoint.
If the player&apos;s User-Agent matches, a .strm file is
served instead of a direct link.
</p>
<Alert intent="info-basic">
<p className="text-sm">
In &quot;User-Agent Dependent&quot; mode, non-matching
clients are transparently redirected to the direct stream
URL.
</p>
</Alert>
</div>
}
>
<Select
label="Mode"
options={[
{ label: 'Disabled', value: 'disabled' },
{ label: 'Always', value: 'always' },
{ label: 'User-Agent Dependent', value: 'userAgent' },
]}
value={userData.strmOutput?.mode || 'disabled'}
onValueChange={(value) => {
setUserData((prev) => ({
...prev,
strmOutput: {
...prev.strmOutput,
mode: value as 'disabled' | 'always' | 'userAgent',
},
}));
}}
/>
{userData.strmOutput?.mode === 'userAgent' && (
<TextInput
label="User Agents"
help="Comma-separated list of User-Agent substrings to match (case-insensitive). If the player's User-Agent contains any of these, a .strm file is served."
placeholder="e.g., Infuse, VLC, mpv"
value={(userData.strmOutput?.userAgents ?? ['Infuse']).join(
', '
)}
onValueChange={(value) => {
const agents = value
.split(',')
.map((s) => s.trim())
.filter((s) => s.length > 0);
setUserData((prev) => ({
...prev,
strmOutput: {
...prev.strmOutput,
userAgents: agents.length > 0 ? agents : undefined,
},
}));
}}
/>
)}
</SettingsCard>
)}
{mode === 'pro' && (
<SettingsCard title="Hide Errors">
<Switch
Expand Down
2 changes: 2 additions & 0 deletions packages/server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
proxyApi,
templatesApi,
syncApi,
strmApi,
} from './routes/api/index.js';
import {
configure,
Expand Down Expand Up @@ -110,6 +111,7 @@ apiRouter.use('/anime', animeApi);
apiRouter.use('/proxy', proxyApi);
apiRouter.use('/templates', templatesApi);
apiRouter.use('/sync', syncApi);
apiRouter.use('/strm-gate', strmApi);
app.use(`/api/v${constants.API_VERSION}`, apiRouter);

// Stremio Routes
Expand Down
1 change: 1 addition & 0 deletions packages/server/src/routes/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ export { default as animeApi } from './anime.js';
export { default as proxyApi } from './proxy.js';
export { default as templatesApi } from './templates.js';
export { default as syncApi } from './sync.js';
export { default as strmApi } from './strm.js';
80 changes: 80 additions & 0 deletions packages/server/src/routes/api/strm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { Router, Request, Response } from 'express';
import { createLogger, decryptString } from '@aiostreams/core';

const logger = createLogger('strm-gate');
const router: Router = Router();

interface StrmGatePayload {
url: string;
mode: 'always' | 'userAgent';
userAgents: string[];
}

router.get('/:data/:filename', (req: Request, res: Response) => {
try {
const { data, filename } = req.params;

// Decrypt the payload
const decrypted = decryptString(data);
if (!decrypted.success || !decrypted.data) {
logger.error('Failed to decrypt STRM gate data');
res.status(400).send('Invalid request');
return;
}

let payload: StrmGatePayload;
try {
payload = JSON.parse(decrypted.data);
} catch {
logger.error('Failed to parse STRM gate payload');
res.status(400).send('Invalid request');
return;
}

if (!payload.url || !payload.mode) {
logger.error('Missing required fields in STRM gate payload');
res.status(400).send('Invalid request');
return;
}

// Determine if we should serve a .strm file or redirect
let serveStrm = false;

if (payload.mode === 'always') {
serveStrm = true;
} else if (payload.mode === 'userAgent') {
const clientUserAgent = (req.headers['user-agent'] || '').toLowerCase();
const userAgents = payload.userAgents || ['Infuse'];
serveStrm = userAgents.some((ua) =>
clientUserAgent.includes(ua.toLowerCase())
);
}

if (serveStrm) {
// Serve the .strm file
const strmFilename = filename.endsWith('.strm')
? filename
: `${filename}.strm`;

logger.info(`Serving STRM file: ${strmFilename}`);

res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.setHeader(
'Content-Disposition',
`attachment; filename="${strmFilename}"`
);
res.status(200).send(payload.url);
} else {
// Redirect to the actual stream URL
logger.debug('STRM gate: User-Agent did not match, redirecting');
res.redirect(302, payload.url);
}
} catch (error) {
logger.error(
`STRM gate error: ${error instanceof Error ? error.message : 'Unknown error'}`
);
res.status(500).send('Internal server error');
}
});

export default router;
120 changes: 112 additions & 8 deletions packages/server/src/routes/stremio/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
StremioTransformer,
Cache,
IdParser,
constants,
encryptString,
} from '@aiostreams/core';
import { stremioStreamRateLimiter } from '../../middlewares/ratelimit.js';

Expand All @@ -16,6 +18,87 @@ const logger = createLogger('server');

router.use(stremioStreamRateLimiter);

/**
* Convert a string to Title Case (capitalize first letter of each word).
*/
function toTitleCase(str: string): string {
return str.replace(
/\w\S*/g,
(word) => word.charAt(0).toUpperCase() + word.slice(1)
);
}

/**
* Generate the STRM filename from metadata.
*/
function generateStrmFilename(
metadata: { title?: string; year?: number } | undefined,
parsedId: {
type: string;
value: string | number;
season?: string;
episode?: string;
} | null,
type: string
): string {
// Build the base name from metadata title or fallback, with Title Case
let baseName = toTitleCase(metadata?.title || 'Unknown');

// Add year for movies
if (metadata?.year) {
baseName += ` (${metadata.year})`;
}

// Add season/episode for series
if (type === 'series' && parsedId?.season && parsedId?.episode) {
const season = String(parsedId.season).padStart(2, '0');
const episode = String(parsedId.episode).padStart(2, '0');
baseName += ` S${season}E${episode}`;
}

return `${baseName}.strm`;
}

/**
* Wrap stream URLs through the STRM gate endpoint.
* The gate will decide at request time (based on User-Agent) whether to serve
* a .strm file or redirect to the actual stream URL.
* The URL looks identical to a normal API call - the .strm filename is only
* used in the Content-Disposition header when the gate serves the file.
*/
function wrapStreamsWithStrmGate(
result: AIOStreamResponse,
strmFilename: string,
strmMode: 'always' | 'userAgent',
userAgents: string[]
): AIOStreamResponse {
const wrappedStreams = result.streams.map((stream) => {
// Only wrap streams that have an HTTP url
if (!stream.url) {
return stream;
}

const payload = JSON.stringify({
url: stream.url,
mode: strmMode,
userAgents,
});

const encrypted = encryptString(payload);
if (!encrypted.success || !encrypted.data) {
logger.warn('Failed to encrypt STRM gate payload, keeping original URL');
return stream;
}

return {
...stream,
url: `${Env.BASE_URL}/api/v${constants.API_VERSION}/strm-gate/${encrypted.data}/${encodeURIComponent(strmFilename)}`,
};
});

return { ...result, streams: wrappedStreams };
}

router.get(
'/:type/:id.json',
async (
Expand Down Expand Up @@ -56,15 +139,36 @@ router.get(
throw new Error('Stream context not available');
}

res
.status(200)
.json(
await transformer.transformStreams(
response,
streamContext.toFormatterContext(response.data.streams),
{ provideStreamData, disableAutoplay }
)
let result = await transformer.transformStreams(
response,
streamContext.toFormatterContext(response.data.streams),
{ provideStreamData, disableAutoplay }
);

// STRM Gate wrapping: wrap stream URLs through the gate endpoint
const strmConfig = req.userData.strmOutput;
if (strmConfig?.mode && strmConfig.mode !== 'disabled') {
const metadata = await streamContext.getMetadata();
const strmFilename = generateStrmFilename(
metadata,
streamContext.parsedId,
type
);
const userAgents = strmConfig.userAgents ?? ['Infuse'];

result = wrapStreamsWithStrmGate(
result,
strmFilename,
strmConfig.mode as 'always' | 'userAgent',
userAgents
);

logger.info(
`Wrapped ${result.streams.filter((s) => s.url?.includes('/strm-gate/')).length} streams with STRM gate (mode: ${strmConfig.mode}, filename: ${strmFilename})`
);
}

res.status(200).json(result);
} catch (error) {
let errorMessage =
error instanceof Error ? error.message : 'Unknown error';
Expand Down