+ When enabled, stream URLs are routed through a gate endpoint.
+ If the player's User-Agent matches, a .strm file is
+ served instead of a direct link.
+
+
+
+ In "User-Agent Dependent" mode, non-matching
+ clients are transparently redirected to the direct stream
+ URL.
+
+
+
+ }
+ >
+
+ )}
{mode === 'pro' && (
{
+ 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;
diff --git a/packages/server/src/routes/stremio/stream.ts b/packages/server/src/routes/stremio/stream.ts
index c7e72c05c..2abac1fc4 100644
--- a/packages/server/src/routes/stremio/stream.ts
+++ b/packages/server/src/routes/stremio/stream.ts
@@ -7,6 +7,8 @@ import {
StremioTransformer,
Cache,
IdParser,
+ constants,
+ encryptString,
} from '@aiostreams/core';
import { stremioStreamRateLimiter } from '../../middlewares/ratelimit.js';
@@ -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 (
@@ -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';