push
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const CONFIG_PATH = path.join(__dirname, '..', 'config.json');
|
||||
|
||||
const DEFAULTS = {
|
||||
tmdbKey: '',
|
||||
enabledProviders: ['videasy', 'vidfast'],
|
||||
timeoutMs: 30000,
|
||||
apiHost: '0.0.0.0',
|
||||
apiPort: 8789,
|
||||
filters: {
|
||||
minQuality: 'any',
|
||||
audioCodec: 'any',
|
||||
subtitles: 'any',
|
||||
subtitleLang: '',
|
||||
},
|
||||
};
|
||||
|
||||
function loadConfig() {
|
||||
let cfg = { ...DEFAULTS, filters: { ...DEFAULTS.filters } };
|
||||
if (fs.existsSync(CONFIG_PATH)) {
|
||||
const saved = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
||||
cfg = { ...cfg, ...saved, filters: { ...cfg.filters, ...(saved.filters || {}) } };
|
||||
} else {
|
||||
saveConfig(cfg);
|
||||
}
|
||||
if (!cfg.tmdbKey && process.env.TMDB_API_KEY) cfg.tmdbKey = process.env.TMDB_API_KEY;
|
||||
if (!cfg.tmdbKey) {
|
||||
console.error('No TMDB API key found. Set "tmdbKey" in config.json or the TMDB_API_KEY env var.');
|
||||
process.exit(1);
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
|
||||
function saveConfig(cfg) {
|
||||
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + '\n');
|
||||
}
|
||||
|
||||
module.exports = { loadConfig, saveConfig, CONFIG_PATH };
|
||||
@@ -0,0 +1,37 @@
|
||||
const QUALITY_MIN = { any: 0, '480': 480, '720': 720, '1080': 1080, '2160': 2160 };
|
||||
const AUDIO_OPTIONS = ['any', 'aac', 'ac3', 'dts', 'opus', 'mp3', 'flac', 'multi'];
|
||||
const SUBTITLE_OPTIONS = ['any', 'required', 'lang'];
|
||||
|
||||
function langCode(lang) {
|
||||
return String(lang || '').toLowerCase().replace(/[^a-z]/g, '').slice(0, 2);
|
||||
}
|
||||
|
||||
function hasSubtitleLang(streams_subtitles, wanted) {
|
||||
const want = langCode(wanted);
|
||||
if (!want) return true;
|
||||
return streams_subtitles.some((s) => {
|
||||
const code = langCode(s.language) || langCode(s.name);
|
||||
return code === want;
|
||||
});
|
||||
}
|
||||
|
||||
function applyFilters(streams, filters) {
|
||||
const minPixels = QUALITY_MIN[filters.minQuality] ?? 0;
|
||||
return streams.filter((s) => {
|
||||
if (minPixels > 0 && (s.pixels == null || s.pixels < minPixels)) return false;
|
||||
if (filters.audioCodec && filters.audioCodec !== 'any' && s.audio !== filters.audioCodec) return false;
|
||||
if (filters.subtitles && filters.subtitles !== 'any') {
|
||||
if (s.subtitles.length === 0) return false;
|
||||
if (filters.subtitles === 'lang' && filters.subtitleLang && !hasSubtitleLang(s.subtitles, filters.subtitleLang)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function rankStreams(streams) {
|
||||
return streams
|
||||
.slice()
|
||||
.sort((a, b) => (b.pixels || 0) - (a.pixels || 0) || a.provider.localeCompare(b.provider));
|
||||
}
|
||||
|
||||
module.exports = { applyFilters, rankStreams, QUALITY_MIN, AUDIO_OPTIONS, SUBTITLE_OPTIONS };
|
||||
@@ -0,0 +1,37 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PROVIDERS_DIR = path.join(__dirname, '..', 'providers');
|
||||
|
||||
function listAll() {
|
||||
return fs
|
||||
.readdirSync(PROVIDERS_DIR)
|
||||
.filter((f) => f.endsWith('.js'))
|
||||
.map((f) => f.slice(0, -3))
|
||||
.sort();
|
||||
}
|
||||
|
||||
function loadProviders(names) {
|
||||
const providers = [];
|
||||
const loadErrors = {};
|
||||
for (const name of names) {
|
||||
const file = path.join(PROVIDERS_DIR, `${name}.js`);
|
||||
if (!fs.existsSync(file)) {
|
||||
loadErrors[name] = 'not found';
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const mod = require(file);
|
||||
if (typeof mod.getStreams !== 'function') {
|
||||
loadErrors[name] = 'does not export getStreams()';
|
||||
continue;
|
||||
}
|
||||
providers.push({ name, getStreams: mod.getStreams });
|
||||
} catch (err) {
|
||||
loadErrors[name] = err.message.split('\n')[0];
|
||||
}
|
||||
}
|
||||
return { providers, loadErrors };
|
||||
}
|
||||
|
||||
module.exports = { listAll, loadProviders, PROVIDERS_DIR };
|
||||
@@ -0,0 +1,91 @@
|
||||
function qualityToPixels(q) {
|
||||
if (!q) return null;
|
||||
const s = String(q).toLowerCase();
|
||||
if (/2160|4k|uhd/.test(s)) return 2160;
|
||||
const m = s.match(/(\d{3,4})\s*p/);
|
||||
if (m) {
|
||||
const n = parseInt(m[1], 10);
|
||||
return n >= 1440 ? 2160 : n;
|
||||
}
|
||||
if (/1080|full\s?hd|fhd/.test(s)) return 1080;
|
||||
if (/720/.test(s)) return 720;
|
||||
if (/480/.test(s)) return 480;
|
||||
return null;
|
||||
}
|
||||
|
||||
function detectAudio(text) {
|
||||
const t = ` ${String(text || '').toLowerCase()} `;
|
||||
if (/\bdts\b|dts-?hd/.test(t)) return 'dts';
|
||||
if (/e-?ac3|dd\+|ac-?3|dolby digital/.test(t)) return 'ac3';
|
||||
if (/\baac\b/.test(t)) return 'aac';
|
||||
if (/\bopus\b/.test(t)) return 'opus';
|
||||
if (/\bflac\b|lossless/.test(t)) return 'flac';
|
||||
if (/\bmp3\b/.test(t)) return 'mp3';
|
||||
if (/dual-?audio|multi-?audio|original audio|multi audio/.test(t)) return 'multi';
|
||||
return null;
|
||||
}
|
||||
|
||||
function extOf(text) {
|
||||
const m = String(text || '').toLowerCase().split(/[?#]/)[0].match(/\.([a-z0-9]{2,5})$/);
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
|
||||
function streamType(url, name) {
|
||||
const u = String(url || '');
|
||||
const n = String(name || '').toLowerCase();
|
||||
if (/^magnet:/i.test(u)) return 'magnet';
|
||||
if (/m3u8/i.test(u)) return 'm3u8';
|
||||
const ext = extOf(u) || extOf(n);
|
||||
if (ext === 'mkv' || /\.mkv\b/.test(n)) return 'mkv';
|
||||
if (ext === 'mp4' || ext === 'm4v' || ext === 'mov' || /\.mp4\b/.test(n)) return 'mp4';
|
||||
if (ext === 'avi' || /\.avi\b/.test(n)) return 'avi';
|
||||
if (ext === 'webm') return 'webm';
|
||||
if (ext === 'ts' || ext === 'm4a') return 'other';
|
||||
return ext || 'other';
|
||||
}
|
||||
|
||||
function normalizeSubtitles(subs) {
|
||||
if (!Array.isArray(subs)) return [];
|
||||
return subs
|
||||
.filter((s) => s && typeof s.url === 'string' && s.url.length > 0)
|
||||
.map((s) => ({
|
||||
url: s.url,
|
||||
language: s.language || s.id || s.lang || '',
|
||||
name: s.name || '',
|
||||
}));
|
||||
}
|
||||
|
||||
function normalize(raw, providerName) {
|
||||
const text = [raw.name, raw.title, raw.description].filter(Boolean).join(' ');
|
||||
const pixels = qualityToPixels(raw.quality) ?? qualityToPixels(`${raw.name || ''} ${raw.title || ''}`);
|
||||
return {
|
||||
provider: providerName,
|
||||
serverName: raw._serverName || '',
|
||||
name: raw.name || raw.title || 'stream',
|
||||
title: raw.title || '',
|
||||
description: raw.description || '',
|
||||
url: raw.url,
|
||||
quality: raw.quality || (pixels ? `${pixels}p` : ''),
|
||||
pixels,
|
||||
audio: detectAudio(text),
|
||||
type: streamType(raw.url, raw.name || raw.title),
|
||||
subtitles: normalizeSubtitles(raw.subtitles),
|
||||
size: typeof raw.size === 'number' ? raw.size : 0,
|
||||
headers: raw.headers && typeof raw.headers === 'object' ? raw.headers : {},
|
||||
is4k: !!raw._is4k || pixels === 2160,
|
||||
};
|
||||
}
|
||||
|
||||
function formatBytes(n) {
|
||||
if (!n || n <= 0) return '';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let i = 0;
|
||||
let v = n;
|
||||
while (v >= 1024 && i < units.length - 1) {
|
||||
v /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${v.toFixed(v >= 100 || i === 0 ? 0 : 1)}${units[i]}`;
|
||||
}
|
||||
|
||||
module.exports = { normalize, qualityToPixels, detectAudio, formatBytes };
|
||||
@@ -0,0 +1,55 @@
|
||||
function withTimeout(promise, ms, label) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
|
||||
promise.then(
|
||||
(v) => {
|
||||
clearTimeout(timer);
|
||||
resolve(v);
|
||||
},
|
||||
(e) => {
|
||||
clearTimeout(timer);
|
||||
reject(e);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function toStreamArray(raw, providerName) {
|
||||
let arr = Array.isArray(raw) ? raw : raw && typeof raw === 'object' && raw.url ? [raw] : [];
|
||||
return arr
|
||||
.filter((s) => s && typeof s === 'object' && typeof s.url === 'string' && s.url.length > 0)
|
||||
.map((s) => ({ ...s, _provider: s._provider || providerName }));
|
||||
}
|
||||
|
||||
async function scrapeAll(providers, media, timeoutMs) {
|
||||
const settled = await Promise.allSettled(
|
||||
providers.map(async (p) => {
|
||||
const raw = await withTimeout(
|
||||
Promise.resolve().then(() =>
|
||||
p.getStreams(String(media.id), media.type, media.seasonId ?? null, media.episodeId ?? null)
|
||||
),
|
||||
timeoutMs,
|
||||
p.name
|
||||
);
|
||||
return toStreamArray(raw, p.name);
|
||||
})
|
||||
);
|
||||
|
||||
const streams = [];
|
||||
const errors = {};
|
||||
settled.forEach((r, i) => {
|
||||
if (r.status === 'fulfilled') streams.push(...r.value);
|
||||
else errors[providers[i].name] = (r.reason && r.reason.message) || String(r.reason);
|
||||
});
|
||||
|
||||
const seen = new Set();
|
||||
const unique = [];
|
||||
for (const s of streams) {
|
||||
if (seen.has(s.url)) continue;
|
||||
seen.add(s.url);
|
||||
unique.push(s);
|
||||
}
|
||||
return { streams: unique, errors };
|
||||
}
|
||||
|
||||
module.exports = { scrapeAll, withTimeout };
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
const BASE = 'https://api.themoviedb.org/3';
|
||||
|
||||
async function get(pathname, key) {
|
||||
const sep = pathname.includes('?') ? '&' : '?';
|
||||
const res = await fetch(`${BASE}${pathname}${sep}api_key=${encodeURIComponent(key)}`);
|
||||
if (!res.ok) throw new Error(`TMDB HTTP ${res.status} for ${pathname}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function search(query, key) {
|
||||
return get(
|
||||
`/search/multi?query=${encodeURIComponent(query)}&include_adult=false&language=en-US&page=1`,
|
||||
key
|
||||
).then((r) =>
|
||||
(r.results || []).filter((m) => m.media_type === 'movie' || m.media_type === 'tv').slice(0, 15)
|
||||
);
|
||||
}
|
||||
|
||||
function trending(mediaType, timeWindow, key) {
|
||||
const type = ['movie', 'tv', 'all'].includes(mediaType) ? mediaType : 'all';
|
||||
const window_ = timeWindow === 'day' ? 'day' : 'week';
|
||||
return get(`/trending/${type}/${window_}?language=en-US`, key).then((r) =>
|
||||
(r.results || []).slice(0, 20)
|
||||
);
|
||||
}
|
||||
|
||||
function getSeasons(tvId, key) {
|
||||
return get(`/tv/${tvId}?language=en-US`, key).then((r) =>
|
||||
(r.seasons || []).filter((s) => s.season_number > 0 && (s.episode_count || 0) > 0)
|
||||
);
|
||||
}
|
||||
|
||||
function getEpisodes(tvId, seasonNumber, key) {
|
||||
return get(`/tv/${tvId}/season/${seasonNumber}?language=en-US`, key).then((r) =>
|
||||
(r.episodes || []).slice().sort((a, b) => a.episode_number - b.episode_number)
|
||||
);
|
||||
}
|
||||
|
||||
function details(type, id, key) {
|
||||
return get(`/${type}/${id}?language=en-US&append_to_response=images`, key);
|
||||
}
|
||||
|
||||
module.exports = { search, trending, getSeasons, getEpisodes, details };
|
||||
Reference in New Issue
Block a user