Skip to content
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"build": "tsc",
"start": "npm run build && node dist/server.js",
"format": "prettier --write src",
"clean": "npx rimraf dist"
"clean": "npx rimraf dist"
},
"dependencies": {
"@omss/framework": "^1.1.19",
Expand Down
223 changes: 223 additions & 0 deletions src/providers/popr/popr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import { BaseProvider } from '@omss/framework';
import type {
ProviderCapabilities,
ProviderMediaObject,
ProviderResult,
Source,
SourceType,
Subtitle
} from '@omss/framework';
import axios from 'axios';
import { VidnestResponse } from './popr.types.js';
export class PoprProvider extends BaseProvider {
readonly id = 'popr';
readonly name = 'Popr';
readonly enabled = true;
readonly BASE_URL = 'https://popr.ink';
readonly HEADERS = {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150 Safari/537.36',
Referer: `${this.BASE_URL}/`
};

readonly capabilities: ProviderCapabilities = {
supportedContentTypes: ['movies', 'tv']
};

/**
* Fetch movie sources
*/
async getMovieSources(media: ProviderMediaObject): Promise<ProviderResult> {
try {
this.console.log('fetching movie response', media);
let movieSource = await this.fetchSource(media, 'movie');

return {
sources: movieSource.sources,
subtitles: movieSource.subtitles,
diagnostics: []
};
Comment on lines +35 to +39

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In getMovieSources, if fetchSource returns no sources/subtitles (it returns empty arrays when all servers fail), this currently returns diagnostics: [], which makes failures silent. Consider returning emptyResult('No playable sources found', media) (or adding a warning diagnostic) when movieSource.sources.length === 0 so consumers can distinguish 'no sources' from 'provider succeeded'.

Copilot uses AI. Check for mistakes.
} catch (error) {
this.console.error(
error instanceof Error
? error.message
: 'error at getting movie source'
);
return this.emptyResult(
error instanceof Error
? error.message
: 'error at getting source',
media
);
}
}

/**
* Fetch TV episode sources
*/
async getTVSources(media: ProviderMediaObject): Promise<ProviderResult> {
try {
this.console.log('fetching tv response', media);
let tvSource = await this.fetchSource(media, 'tv');

return {
sources: tvSource.sources,
subtitles: tvSource.subtitles,
diagnostics: []
};
Comment on lines +63 to +67

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In getTVSources, same as movies: when fetchSource returns empty arrays after trying all servers, this returns diagnostics: [] and silently fails. Add an empty-result diagnostic when no sources are found so callers can surface a meaningful provider error/warning.

Copilot uses AI. Check for mistakes.
} catch (error) {
this.console.error(
error instanceof Error
? error.message
: 'error at getting movie source'

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The non-Error fallback message in the getTVSources catch block says "error at getting movie source". This looks like a copy/paste error and will be misleading in logs and diagnostics; update it to reference TV sources/episodes instead.

Suggested change
: 'error at getting movie source'
: 'error at getting tv source'

Copilot uses AI. Check for mistakes.
);
return this.emptyResult(
error instanceof Error
? error.message
: 'error at getting source',
media
);
}
}

// https://popr.ink/api/vidnest?id=262848&type=tv&server=catflix&season=1&episode=1
private async fetchSource(
media: ProviderMediaObject,
type: 'tv' | 'movie' = 'movie'
): Promise<{ sources: Source[]; subtitles: Subtitle[] }> {
const servers = [
'default',
'catflix',
'hexa',
'Gama',
'Liligoon',
'Sigma',
'Prime',
'Alfa',
'Lamda',
'ynx_vidsrc'
];

const ep = media.e || 1;
const season = media.s || 1;

const buildUrl = (server: string) => {
if (type === 'tv') {
return `${this.BASE_URL}/api/vidnest?id=${media.tmdbId}&type=tv&server=${server}&season=${season}&episode=${ep}`;
}
return (
`${this.BASE_URL}/api/vidnest?id=${media.tmdbId}&type=movie` +
(server !== 'default' ? `&server=${server}` : '')
);
};

const requests = servers.map(
(server) =>
axios
.get<VidnestResponse>(buildUrl(server), {
headers: this.HEADERS,
timeout: 8000
})
.then(({ data }) => {
const stream = data?.results?.[0]?.streams?.[0];
if (!stream?.url) return null;

const ext = (new URL(stream.url).pathname.match(
/\.[^./]+$/
) || [''])[0];

const quality = stream.quality;
const INVALID_QUALITIES = ['Hindi', 'English', 'MAIN'];
const QUALITIES = ['Hindi', 'English'];
const languages = QUALITIES.includes(quality);

return {
source: {
url: this.createProxyUrl(
stream.url,
stream.headers
),
type: (ext === '.m3u8'
? 'hls'
: 'mp4') as SourceType,
quality: INVALID_QUALITIES.includes(quality)
? 'auto'
: quality || 'auto',
audioTracks: [
{
language: languages
? quality.toLowerCase().slice(0, 3)
: 'eng',
label: languages ? quality : 'English'
}
],
provider: { name: this.name, id: this.id }
},
subtitles: data.results?.[0]?.subtitles || []
};
})
.catch(() => null) // swallow per-request errors
);

const results = await Promise.allSettled(requests);

const sources: Source[] = [];
const subtitlesMap = new Map<string, Subtitle>();

for (const res of results) {
if (res.status !== 'fulfilled' || !res.value) continue;

sources.push(res.value.source);

for (const sub of res.value.subtitles) {
if (!sub?.url) continue;

// dedupe subtitles by URL
if (!subtitlesMap.has(sub.url)) {
subtitlesMap.set(sub.url, {
url: sub.url,

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Subtitles are returned with the raw sub.url instead of being proxied. Other providers typically proxy subtitle URLs to avoid CORS issues and to attach any required headers/referer. Consider using createProxyUrl here (and include headers if Popr requires them).

Suggested change
url: sub.url,
url: this.createProxyUrl(sub.url, {
...this.HEADERS,
Referer: `${this.BASE_URL}/`
}),

Copilot uses AI. Check for mistakes.
format: 'vtt',
label: sub.lang || 'Unknown'
});
}
}
}

return {
sources,
subtitles: Array.from(subtitlesMap.values())
};
}

private emptyResult(
message: string,
media: ProviderMediaObject
): ProviderResult {
return {
sources: [],
subtitles: [],
diagnostics: [
{
code: 'PROVIDER_ERROR',
message: `${this.name}: ${message}`,
field: '',
severity: 'error'
}
]
};
}
/**
* Health check
*/
async healthCheck(): Promise<boolean> {
try {
const response = await axios.head(this.BASE_URL, {
timeout: 5000,
headers: this.HEADERS
});
return response.status === 200;
} catch {
return false;
}
}
}
21 changes: 21 additions & 0 deletions src/providers/popr/popr.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export type VidnestResponse = {
success: boolean;
results: {
server: string;
serverName: string;
streams: {
url: string;
quality: string;
isM3U8: boolean;
headers?: {
Referer?: string;
Origin?: string;
};
}[];
subtitles?: {
url: string;
format: string;
lang: string;
}[];
}[];
};
Loading