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
17 changes: 16 additions & 1 deletion packages/core/src/db/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,17 @@ const StreamProxyConfig = z.object({

export type StreamProxyConfig = z.infer<typeof StreamProxyConfig>;

const CleanRedirectOutputConfig = z.object({
enabled: z.boolean().optional(),
redirectCode: z
.union([z.literal(302), z.literal(307), z.literal(308)])
.optional(),
});

export type CleanRedirectOutputConfig = z.infer<
typeof CleanRedirectOutputConfig
>;

const ResultLimitOptions = z.object({
global: z.number().min(1).optional(),
service: z.number().min(1).optional(),
Expand Down Expand Up @@ -381,7 +392,7 @@ export const ParentConfigSchema = z.object({
proxy: BinaryMergeStrategy.default('inherit'),
metadata: BinaryMergeStrategy.default('inherit'),
misc: BinaryMergeStrategy.default('inherit'),
branding: BinaryMergeStrategy.default('inherit'),
branding: BinaryMergeStrategy.default('inherit'),
fieldOverrides: z
.record(z.string(), z.enum(['inherit', 'override', 'extend']))
.optional(),
Expand Down Expand Up @@ -646,6 +657,10 @@ export const UserDataSchema = z.object({
usePosterServiceForMeta: z.boolean().optional(),
formatter: Formatter,
proxy: StreamProxyConfig.optional(),
cleanRedirectOutput: CleanRedirectOutputConfig.optional().default({
enabled: false,
redirectCode: 307,
}),
resultLimits: ResultLimitOptions.optional(),
size: SizeFilterOptions.optional(),
bitrate: BitrateFilterOptions.optional(),
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1515,6 +1515,7 @@ const METADATA_FIELDS: (keyof UserData)[] = [
const MISC_FIELDS: (keyof UserData)[] = [
'autoPlay', 'areYouStillThere', 'statistics', 'dynamicAddonFetching',
'nzbFailover', 'serviceWrap', 'cacheAndPlay', 'preloadStreams', 'precacheSelector',
'cleanRedirectOutput',
'hideErrors', 'hideErrorsForResources', 'addonCategoryColors', 'catalogModifications', 'mergedCatalogs',
'addonPassword', 'externalDownloads', 'autoRemoveDownloads', 'checkOwned', 'showChanges',
'randomiseResults', 'enhanceResults', 'enhancePosters',
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/utils/fieldMeta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ export const FIELD_META: Omit<Record<keyof UserData, FieldMeta>, IgnoredKeys> =
usePosterServiceForMeta: { label: 'Use Poster Service for Meta', group: 'metadata', type: 'scalar', menu: 'services', subTab: 'posters' },

autoPlay: { label: 'Auto Play', group: 'misc', type: 'scalar', menu: 'miscellaneous', subTab: 'playback' },
cleanRedirectOutput: { label: 'Clean Filename Redirect', group: 'misc', type: 'scalar', menu: 'miscellaneous', subTab: 'playback', keywords: ['infuse', 'subtitles', 'redirect'] },
areYouStillThere: { label: 'Are You Still There?', group: 'misc', type: 'scalar', menu: 'miscellaneous', subTab: 'playback' },
statistics: { label: 'Statistics', group: 'misc', type: 'scalar', menu: 'miscellaneous', subTab: 'display' },
hideErrors: { label: 'Hide Errors', group: 'misc', type: 'scalar', menu: 'miscellaneous', subTab: 'display' },
Expand Down
16 changes: 16 additions & 0 deletions packages/docs/content/docs/guides/clean-filename-redirect.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
title: Clean Filename Redirect
description: Use a clean initial playback filename for external player subtitle matching.
---

# Clean Filename Redirect

Clean Filename Redirect rewrites HTTP stream URLs through a lightweight AIOStreams endpoint that includes a cleaned filename in the URL path, then redirects to the original stream URL.

This is useful for external players such as Infuse, where subtitle matching may rely on the initial playback filename.

This feature does not proxy video traffic. AIOStreams only returns an HTTP redirect; the media is still fetched from the original provider, debrid service, or CDN URL.

Recommended redirect code: 307.

Default: disabled.
3 changes: 2 additions & 1 deletion packages/docs/content/docs/guides/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
"pages": [
"groups",
"usenet",
"clean-filename-redirect",
"scored-sorting",
"seanime",
"development"
]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,49 @@ export function PlaybackBehavior() {
)}
</SettingsCard>

<SettingsCard
title="Clean Filename Redirect"
id="cleanRedirectOutput"
description="Rewrite HTTP stream URLs through a lightweight redirect endpoint with a clean filename in the initial playback path."
>
<Switch
label="Enable"
side="right"
value={userData.cleanRedirectOutput?.enabled ?? false}
onValueChange={(value) => {
setUserData((prev) => ({
...prev,
cleanRedirectOutput: {
...prev.cleanRedirectOutput,
enabled: value,
redirectCode: prev.cleanRedirectOutput?.redirectCode ?? 307,
},
}));
}}
/>
<Select
label="Redirect Status Code"
help="HTTP redirect code used by the clean filename redirect endpoint. 307 is recommended."
disabled={!userData.cleanRedirectOutput?.enabled}
options={[
{ label: '302 Found', value: '302' },
{ label: '307 Temporary Redirect - recommended', value: '307' },
{ label: '308 Permanent Redirect', value: '308' },
]}
value={String(userData.cleanRedirectOutput?.redirectCode ?? 307)}
onValueChange={(value) => {
setUserData((prev) => ({
...prev,
cleanRedirectOutput: {
...prev.cleanRedirectOutput,
enabled: prev.cleanRedirectOutput?.enabled ?? false,
redirectCode: Number(value) as 302 | 307 | 308,
},
}));
}}
/>
</SettingsCard>

<SettingsCard
title="Are you still there?"
id="areYouStillThere"
Expand Down
4 changes: 4 additions & 0 deletions packages/frontend/src/context/userData.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,10 @@ export const DefaultUserData: UserData = {
formatter: {
id: 'gdrive',
},
cleanRedirectOutput: {
enabled: false,
redirectCode: 307,
},
preferredQualities: Object.values(QUALITIES),
preferredResolutions: Object.values(RESOLUTIONS),
excludedQualities: ['CAM', 'SCR', 'TS', 'TC'],
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 @@ -13,6 +13,7 @@ import {
proxyApi,
templatesApi,
syncApi,
streamGateApi,
} from './routes/api/index.js';
import {
configure,
Expand Down Expand Up @@ -111,6 +112,7 @@ apiRouter.use('/anime', animeApi);
apiRouter.use('/proxy', proxyApi);
apiRouter.use('/templates', templatesApi);
apiRouter.use('/sync', syncApi);
apiRouter.use('/stream-gate', streamGateApi);
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 @@ -11,3 +11,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 streamGateApi } from './streamGate.js';
65 changes: 65 additions & 0 deletions packages/server/src/routes/api/streamGate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { Router, Request, Response } from 'express';
import { createLogger, decryptString } from '@aiostreams/core';
import {
type CleanRedirectPayload,
isValidHttpUrl,
sanitizeFilename,
} from '../../utils/cleanRedirect.js';

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

type StreamGateParams = {
data: string;
filename: string;
};

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

const decrypted = decryptString(data);

if (!decrypted.success || !decrypted.data) {
logger.warn('Invalid stream-gate payload');
res.status(400).send('Invalid request');
return;
}

let payload: CleanRedirectPayload;

try {
payload = JSON.parse(decrypted.data) as CleanRedirectPayload;
} catch {
res.status(400).send('Invalid payload');
return;
}

if (
!payload ||
typeof payload !== 'object' ||
!payload.url ||
!isValidHttpUrl(payload.url)
) {
res.status(400).send('Invalid stream URL');
return;
}

const safeFilename = sanitizeFilename(filename);
const redirectCode = payload.redirectCode ?? 307;

if (![302, 307, 308].includes(redirectCode)) {
res.status(400).send('Invalid redirect code');
return;
}

res.setHeader('Content-Disposition', `inline; filename="${safeFilename}"`);
res.setHeader('Cache-Control', 'no-store');
res.setHeader('X-Clean-Filename', safeFilename);

res.redirect(redirectCode, payload.url);
}
);

export default router;
24 changes: 15 additions & 9 deletions packages/server/src/routes/stremio/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
IdParser,
} from '@aiostreams/core';
import { stremioStreamRateLimiter } from '../../middlewares/ratelimit.js';
import { wrapStreamsWithCleanRedirectGate } from '../../utils/cleanRedirect.js';

const router: Router = Router();

Expand Down Expand Up @@ -61,15 +62,20 @@ 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 transformed = await transformer.transformStreams(
response,
streamContext.toFormatterContext(response.data.streams),
{ provideStreamData, disableAutoplay }
);

transformed = wrapStreamsWithCleanRedirectGate(
transformed,
response.data.streams,
req.userData,
req
);

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