'use strict';
/**
* Veloflix ( Veloflix Website Nonton Film Gratis )
*/
const { setTimeout: sleep } = require('timers/promises');
const fs = require('fs/promises');
const path = require('path');
const DEFAULT_USER_AGENT =
'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36';
const TMDB_IMAGE_BASE = 'https://image.tmdb.org/t/p';
function escapeRegExp(value = '') {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function decodeHtmlEntities(value = '') {
return String(value)
.replace(/(x[0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(parseInt(hex.slice(1), 16)))
.replace(/(\d+);/g, (_, dec) => String.fromCodePoint(Number(dec)))
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/ /g, ' ')
.replace(/&/g, '&');
}
function normalizeTmdbImage(value, width = 'w500') {
if (!value) return null;
if (/^https?:\/\//i.test(value)) return value;
if (value.startsWith('/')) return `${TMDB_IMAGE_BASE}/${width}${value}`;
return `${TMDB_IMAGE_BASE}/${width}/${value}`;
}
function extractTitle(html = '') {
const match = html.match(/
]*>([\s\S]*?)<\/title>/i);
return match ? decodeHtmlEntities(match[1]).trim() : null;
}
function extractMetaContent(html = '', value, attr = 'name') {
if (!html || !value) return null;
const safeValue = escapeRegExp(value);
const patterns = [
new RegExp(`]+${attr}=["']${safeValue}["'][^>]+content=["']([^"']*)["']`, 'i'),
new RegExp(`]+content=["']([^"']*)["'][^>]+${attr}=["']${safeValue}["']`, 'i'),
];
for (const re of patterns) {
const match = html.match(re);
if (match) return decodeHtmlEntities(match[1]).trim();
}
return null;
}
function extractLinkHref(html = '', rel) {
if (!html || !rel) return null;
const safeRel = escapeRegExp(rel);
const patterns = [
new RegExp(`]+rel=["']${safeRel}["'][^>]+href=["']([^"']*)["']`, 'i'),
new RegExp(`]+href=["']([^"']*)["'][^>]+rel=["']${safeRel}["']`, 'i'),
];
for (const re of patterns) {
const match = html.match(re);
if (match) return decodeHtmlEntities(match[1]).trim();
}
return null;
}
function cleanTitleText(title = '') {
return String(title)
.replace(/^Nonton\s+(Streaming\s+)?(Film|TV Series|Serial TV|Series)\s*/i, '')
.replace(/\s*Sub Indo.*$/i, '')
.replace(/\s*\|\s*Veloflix\s*$/i, '')
.trim();
}
function parseTitleYear(rawTitle = '') {
let title = String(rawTitle || '').replace(/\s*\|\s*Veloflix\s*$/i, '').trim();
const withYear = title.match(/^(.*?)\s*\((\d{4})\)/);
if (withYear) {
return {
title: cleanTitleText(withYear[1]),
year: Number(withYear[2]),
};
}
const yearAnywhere = title.match(/\((\d{4})\)/);
if (yearAnywhere) {
return {
title: cleanTitleText(title.replace(yearAnywhere[0], '')),
year: Number(yearAnywhere[1]),
};
}
return {
title: cleanTitleText(title),
year: null,
};
}
function parseHtmlMetadata(html = '') {
return {
title: extractTitle(html),
description:
extractMetaContent(html, 'description') ||
extractMetaContent(html, 'og:description', 'property'),
ogTitle: extractMetaContent(html, 'og:title', 'property'),
ogDescription: extractMetaContent(html, 'og:description', 'property'),
ogImage: extractMetaContent(html, 'og:image', 'property'),
ogType: extractMetaContent(html, 'og:type', 'property'),
canonical: extractLinkHref(html, 'canonical'),
};
}
function extractJsonArrayByKey(text, key) {
if (!text || !key) return null;
const needle = `"${key}":`;
let searchFrom = 0;
while (true) {
const idx = text.indexOf(needle, searchFrom);
if (idx === -1) return null;
const start = text.indexOf('[', idx + needle.length);
if (start === -1) return null;
let inString = false;
let escaped = false;
let depth = 0;
for (let i = start; i < text.length; i++) {
const ch = text[i];
if (inString) {
if (escaped) {
escaped = false;
} else if (ch === '\\') {
escaped = true;
} else if (ch === '"') {
inString = false;
}
continue;
}
if (ch === '"') {
inString = true;
} else if (ch === '[' || ch === '{') {
depth++;
} else if (ch === ']' || ch === '}') {
depth--;
}
if (depth === 0 && ch === ']') {
const slice = text.slice(start, i + 1);
try {
return JSON.parse(slice);
} catch {
// lanjut cari occurrence berikutnya jika parse gagal
break;
}
}
}
searchFrom = idx + needle.length;
}
}
function extractNumberByKey(text, key) {
if (!text || !key) return null;
const re = new RegExp(`"${escapeRegExp(key)}"\\s*:\\s*(\\d+)`);
const match = text.match(re);
return match ? Number(match[1]) : null;
}
class VeloflixError extends Error {
constructor(message, context = {}) {
super(message);
this.name = 'VeloflixError';
Object.assign(this, context);
}
}
class Veloflix {
constructor(options = {}) {
if (typeof fetch !== 'function' && typeof options.fetchFn !== 'function') {
throw new VeloflixError(
'Global fetch tidak tersedia. Gunakan Node.js >= 18 atau sediakan options.fetchFn.'
);
}
this.baseUrl = (options.baseUrl || 'https://veloflix.my.id').replace(/\/+$/, '');
this.lang = options.lang || 'id';
this.userAgent = options.userAgent || DEFAULT_USER_AGENT;
this.referer = options.referer || `${this.baseUrl}/`;
if (options.cookie && typeof options.cookie === 'object') {
this.cookie = Object.entries(options.cookie)
.map(([k, v]) => `${k}=${v}`)
.join('; ');
} else {
this.cookie = options.cookie || '';
}
this.timeoutMs = options.timeoutMs ?? 30000;
this.retries = options.retries ?? 3;
this.retryDelayMs = options.retryDelayMs ?? 1200;
this.delayMs = options.delayMs ?? 450;
this.jitterMs = options.jitterMs ?? 550;
this.cacheTtlMs = options.cacheTtlMs ?? 0;
this.includeRaw = options.includeRaw ?? false;
this.fetchFn = options.fetchFn || ((...args) => fetch(...args));
this.cache = new Map();
this.queue = Promise.resolve();
}
setCookie(cookie) {
if (cookie && typeof cookie === 'object') {
this.cookie = Object.entries(cookie)
.map(([k, v]) => `${k}=${v}`)
.join('; ');
} else {
this.cookie = cookie || '';
}
return this;
}
clearCache() {
this.cache.clear();
return this;
}
buildUrl(pathname, params = {}) {
const url = new URL(pathname, this.baseUrl);
for (const [key, value] of Object.entries(params || {})) {
if (value === undefined || value === null || value === '') continue;
url.searchParams.set(key, String(value));
}
return url;
}
enqueue(task) {
const run = this.queue.then(() => task());
this.queue = run.then(
() => undefined,
() => undefined
);
return run;
}
async throttle() {
const delay = this.delayMs + Math.floor(Math.random() * this.jitterMs);
if (delay > 0) await sleep(delay);
}
cacheGet(key) {
if (this.cacheTtlMs <= 0) return undefined;
const entry = this.cache.get(key);
if (!entry) return undefined;
if (Date.now() > entry.expiresAt) {
this.cache.delete(key);
return undefined;
}
return entry.value;
}
cacheSet(key, value) {
if (this.cacheTtlMs <= 0) return;
this.cache.set(key, {
value,
expiresAt: Date.now() + this.cacheTtlMs,
});
}
get baseHeaders() {
const headers = {
'User-Agent': this.userAgent,
'Accept-Language': 'id-ID,id;q=0.9,en-US;q=0.8,en;q=0.7',
Referer: this.referer,
Origin: this.baseUrl,
};
if (this.cookie) {
headers.Cookie = this.cookie;
}
return headers;
}
get pageHeaders() {
return {
...this.baseHeaders,
Accept:
'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
'Upgrade-Insecure-Requests': '1',
};
}
get apiHeaders() {
return {
...this.baseHeaders,
Accept: 'application/json',
};
}
jsonHeaders() {
return {
...this.apiHeaders,
'Content-Type': 'application/json',
};
}
getRouterStateTree(pathname = '/') {
const rootTree = '["",{"children":["__PAGE__",{}]},null,null,true]';
const categoryTree = (type) =>
`["",{"children":["category",{"children":[["type","${type}","d"],{"children":["__PAGE__",{}]}]}]},null,null,true]`;
if (pathname.startsWith('/category/movie')) {
return encodeURIComponent(categoryTree('movie'));
}
if (pathname.startsWith('/category/tv')) {
return encodeURIComponent(categoryTree('tv'));
}
return encodeURIComponent(rootTree);
}
rscHeaders(pathname, nextUrl = '/') {
return {
...this.baseHeaders,
Accept: 'text/x-component',
RSC: '1',
'Next-Router-Prefetch': '1',
'Next-Url': nextUrl,
'Next-Router-State-Tree': this.getRouterStateTree(pathname),
};
}
async fetchWithTimeout(url, options = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
const headers = {
...this.baseHeaders,
...(options.headers || {}),
};
if (options.body && !headers['Content-Type']) {
headers['Content-Type'] = 'application/json';
}
try {
return await this.fetchFn(url.toString(), {
...options,
headers,
signal: controller.signal,
redirect: options.redirect || 'follow',
});
} finally {
clearTimeout(timer);
}
}
async request(url, options = {}) {
return this.enqueue(async () => {
let lastError;
for (let attempt = 0; attempt <= this.retries; attempt++) {
try {
if (attempt === 0) {
await this.throttle();
} else {
await sleep(this.retryDelayMs * attempt);
}
const res = await this.fetchWithTimeout(url, options);
if ((res.status === 429 || res.status >= 500) && attempt < this.retries) {
continue;
}
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new VeloflixError(
`HTTP ${res.status} ${res.statusText || ''}`.trim(),
{
status: res.status,
url: url.toString(),
body: body.slice(0, 1000),
}
);
}
return res;
} catch (err) {
lastError = err;
const retryableStatus =
err instanceof VeloflixError
? [429, 500, 502, 503, 504].includes(err.status)
: true;
const retryableNetwork =
err.name === 'AbortError' ||
/fetch failed|network|ETIMEDOUT|ECONNRESET|ECONNREFUSED/i.test(err.message || '');
if (attempt < this.retries && (retryableStatus || retryableNetwork)) {
continue;
}
throw err;
}
}
throw lastError;
});
}
async requestText(pathname, params = {}, options = {}) {
const url = this.buildUrl(pathname, params);
const method = (options.method || 'GET').toUpperCase();
const cacheKey = url.toString();
if (method === 'GET') {
const cached = this.cacheGet(cacheKey);
if (cached !== undefined) return cached;
}
const res = await this.request(url, options);
const text = await res.text();
if (method === 'GET') {
this.cacheSet(cacheKey, text);
}
return text;
}
async requestJson(pathname, params = {}, options = {}) {
const url = this.buildUrl(pathname, params);
const method = (options.method || 'GET').toUpperCase();
const cacheKey = url.toString();
if (method === 'GET') {
const cached = this.cacheGet(cacheKey);
if (cached !== undefined) return cached;
}
const res = await this.request(url, options);
const text = await res.text();
if (!text) {
return { ok: true };
}
let json;
try {
json = JSON.parse(text);
} catch {
throw new VeloflixError(`Response bukan JSON dari ${url}`, {
url: url.toString(),
body: text.slice(0, 1000),
});
}
if (json && json.error) {
const message =
typeof json.error === 'string'
? json.error
: json.error.message || 'API mengembalikan error';
throw new VeloflixError(message, {
url: url.toString(),
data: json,
});
}
if (method === 'GET') {
this.cacheSet(cacheKey, json);
}
return json;
}
async apiGet(pathname, params = {}) {
return this.requestJson(pathname, params, {
headers: this.apiHeaders,
});
}
async apiPost(pathname, body = {}, params = {}) {
return this.requestJson(pathname, params, {
method: 'POST',
headers: this.jsonHeaders(),
body: JSON.stringify(body),
});
}
async apiDelete(pathname, body = null, params = {}) {
return this.requestJson(pathname, params, {
method: 'DELETE',
headers: this.jsonHeaders(),
body: body ? JSON.stringify(body) : undefined,
});
}
requireAuth(featureName = 'Endpoint ini') {
if (!this.cookie) {
throw new VeloflixError(
`${featureName} membutuhkan cookie sesi/login. ` +
`Contoh: new Veloflix({ cookie: 'next-auth.session-token=...; __Secure-next-auth.session-token=...' })`
);
}
}
normalizeItem(raw) {
if (!raw) return raw;
if (typeof raw === 'string') {
return { title: raw };
}
if (typeof raw !== 'object') {
return raw;
}
const id = raw.id ?? raw.tmdbId ?? raw.tmdb_id ?? raw.movieId ?? raw.tvId;
const mediaType =
raw.mediaType ??
raw.media_type ??
raw.type ??
(raw.tvId || raw.first_air_date ? 'tv' : 'movie');
const title =
raw.title ??
raw.name ??
raw.label ??
raw.original_title ??
raw.original_name ??
null;
const year =
raw.year ??
(raw.releaseDate
? Number(String(raw.releaseDate).slice(0, 4))
: raw.release_date
? Number(String(raw.release_date).slice(0, 4))
: null);
const item = {
id: id ?? null,
mediaType,
title,
year,
rating: raw.rating ?? raw.vote_average ?? raw.voteAverage ?? raw.score ?? null,
voteCount: raw.voteCount ?? raw.vote_count ?? null,
posterUrl: normalizeTmdbImage(raw.posterUrl ?? raw.poster_path ?? raw.poster, 'w500'),
backdropUrl: normalizeTmdbImage(
raw.backdropUrl ?? raw.backdrop_path ?? raw.backdrop,
'w1280'
),
overview: raw.overview ?? raw.description ?? raw.synopsis ?? null,
genres: raw.genres ?? raw.genreIds ?? raw.genre_ids ?? [],
releaseDate: raw.releaseDate ?? raw.release_date ?? raw.first_air_date ?? null,
logoUrl: raw.logoUrl ?? raw.logo ?? null,
originalLanguage: raw.originalLanguage ?? raw.original_language ?? null,
originCountry: raw.originCountry ?? raw.origin_country ?? null,
imdbId: raw.imdbId ?? raw.imdb_id ?? null,
url:
id && mediaType
? `${this.baseUrl}/title/${mediaType}/${id}`
: null,
watchUrl:
id && mediaType
? `${this.baseUrl}/watch/${mediaType}/${id}?play=1`
: null,
};
if (this.includeRaw) {
item.raw = raw;
}
return item;
}
normalizeDetail(data, fallbackType, fallbackId) {
const base = this.normalizeItem(data || {});
return {
...base,
id: data?.id ?? fallbackId ?? base.id,
mediaType: data?.mediaType ?? fallbackType ?? base.mediaType,
cast: Array.isArray(data?.cast)
? data.cast.map((c) => ({
id: c.id ?? null,
name: c.name ?? null,
character: c.character ?? c.role ?? null,
profileUrl: normalizeTmdbImage(c.profileUrl ?? c.profile_path, 'w185'),
}))
: [],
recommendations: Array.isArray(data?.recommendations)
? data.recommendations.map((r) => this.normalizeItem(r))
: [],
seasons: Array.isArray(data?.seasons) ? data.seasons : [],
nextEpisodeToAir: data?.nextEpisodeToAir ?? null,
trailerYoutubeId: data?.trailerYoutubeId ?? data?.trailer ?? null,
providers: data?.providers ?? [],
imdbId: data?.imdbId ?? data?.imdb_id ?? base.imdbId,
};
}
extractListAndMeta(json) {
if (!json) {
return { list: [], meta: {} };
}
if (Array.isArray(json)) {
return { list: json, meta: {} };
}
const candidates = [json, json.data, json.result, json.results, json.response].filter(Boolean);
for (const candidate of candidates) {
if (Array.isArray(candidate)) {
return { list: candidate, meta: {} };
}
if (candidate && typeof candidate === 'object') {
const possibleKeys = ['items', 'results', 'data', 'list', 'titles', 'records'];
for (const key of possibleKeys) {
if (Array.isArray(candidate[key])) {
return { list: candidate[key], meta: candidate };
}
}
}
}
if (typeof json === 'object') {
return { list: [json], meta: json };
}
return { list: [], meta: {} };
}
parseCategoryPayload(text, fallbackPage = 1) {
if (!text) {
return {
items: [],
currentPage: fallbackPage,
totalPages: 1,
totalResults: 0,
};
}
let items = extractJsonArrayByKey(text, 'initialItems');
if (!items) {
try {
const json = JSON.parse(text);
const { list, meta } = this.extractListAndMeta(json);
if (Array.isArray(list) && list.length) {
return {
items: list.map((item) => this.normalizeItem(item)),
currentPage: meta.currentPage ?? fallbackPage,
totalPages: meta.totalPages ?? 1,
totalResults: meta.totalResults ?? list.length,
};
}
} catch {
}
}
if (!items || !Array.isArray(items)) {
return {
items: [],
currentPage: fallbackPage,
totalPages: 1,
totalResults: 0,
};
}
return {
items: items.map((item) => this.normalizeItem(item)),
currentPage: extractNumberByKey(text, 'currentPage') ?? fallbackPage,
totalPages: extractNumberByKey(text, 'totalPages') ?? 1,
totalResults: extractNumberByKey(text, 'totalResults') ?? items.length,
};
}
parseTitleLinksFromHtml(html = '', defaultType = 'movie') {
const items = [];
const seen = new Set();
const re = /href="\/title\/(movie|tv)\/(\d+)"/g;
let match;
while ((match = re.exec(html)) !== null) {
const mediaType = match[1] || defaultType;
const id = Number(match[2]);
const key = `${mediaType}-${id}`;
if (seen.has(key)) continue;
seen.add(key);
items.push(
this.normalizeItem({
id,
mediaType,
})
);
}
return items;
}
/* =========================
* SEARCH
* ========================= */
async search(query, params = {}) {
if (!query) return [];
const json = await this.apiGet('/api/search', {
q: query,
...params,
});
const { list } = this.extractListAndMeta(json);
return list.map((item) =>
typeof item === 'string' ? { title: item } : this.normalizeItem(item)
);
}
async getSearchTrending(limit = 10) {
const json = await this.apiGet('/api/search/trending', { limit });
const { list } = this.extractListAndMeta(json);
return list.map((item) => {
if (typeof item === 'string') return { label: item };
if (item && item.label) return item;
return this.normalizeItem(item);
});
}
/* =========================
* TRENDING
* ========================= */
async getTrendingCountry(options = {}) {
const {
country = 'ID',
type = 'movie', // movie | tv
page = 1,
limit,
lang = this.lang,
} = options;
const params = {
country,
type,
page,
lang,
};
if (limit) params.limit = limit;
const json = await this.apiGet('/api/trending/country', params);
const { list, meta } = this.extractListAndMeta(json);
return {
items: list.map((item) => this.normalizeItem(item)),
currentPage: meta.currentPage ?? page,
totalPages: meta.totalPages ?? null,
totalResults: meta.totalResults ?? list.length,
meta,
};
}
async getHomeFeed({ country = 'ID' } = {}) {
const movies = await this.getTrendingCountry({ country, type: 'movie' });
const series = await this.getTrendingCountry({ country, type: 'tv' });
return {
movies: movies.items,
series: series.items,
meta: {
movies,
series,
},
};
}
async getCategoryPage(options = {}) {
const {
type = 'movie', // movie | tv
page = 1,
genre,
sort,
year,
minRating,
rating,
country,
q,
search,
lang,
} = options;
const params = {
page,
genre,
sort,
year,
country,
q: q ?? search,
lang: lang ?? this.lang,
};
const effectiveMinRating = minRating ?? rating;
if (effectiveMinRating !== undefined && effectiveMinRating !== null) {
params.minRating = effectiveMinRating;
}
const pathname = `/category/${type}`;
const errors = [];
try {
const text = await this.requestText(pathname, params, {
headers: this.rscHeaders(pathname, pathname),
});
const parsed = this.parseCategoryPayload(text, page);
if (parsed.items.length) {
return {
...parsed,
source: 'rsc',
};
}
} catch (err) {
errors.push(err);
}
try {
const html = await this.requestText(pathname, params, {
headers: this.pageHeaders,
});
const parsed = this.parseCategoryPayload(html, page);
if (parsed.items.length) {
return {
...parsed,
source: 'html',
};
}
const fallbackItems = this.parseTitleLinksFromHtml(html, type);
if (fallbackItems.length) {
return {
items: fallbackItems,
currentPage: page,
totalPages: 1,
totalResults: fallbackItems.length,
source: 'html-links',
};
}
} catch (err) {
errors.push(err);
}
throw new VeloflixError(
`Gagal mengambil kategori ${type}. Kemungkinan struktur berubah atau diblokir. ` +
`Errors: ${errors.map((e) => e.message).join(' | ')}`,
{ errors }
);
}
async *iterateCategory(options = {}) {
const maxPages = options.maxPages ?? Infinity;
let page = options.page ?? 1;
while (page <= maxPages) {
const data = await this.getCategoryPage({
...options,
page,
});
yield data;
if (!data.items.length) break;
if (page >= (data.totalPages ?? page)) break;
page += 1;
}
}
async collectCategory(options = {}) {
const items = [];
for await (const pageData of this.iterateCategory(options)) {
items.push(...pageData.items);
if (options.limit && items.length >= options.limit) {
break;
}
}
return options.limit ? items.slice(0, options.limit) : items;
}
async getTitle(type, id) {
try {
const json = await this.apiGet(`/api/title/${type}/${id}`);
const payload = json?.data ?? json;
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
return this.normalizeDetail(payload, type, id);
}
if (Array.isArray(payload) && payload.length) {
return this.normalizeDetail(payload[0], type, id);
}
} catch {
}
return this.scrapeTitleMeta(type, id);
}
async scrapeTitleMeta(type, id) {
const html = await this.requestText(`/title/${type}/${id}`, {}, {
headers: this.pageHeaders,
});
const meta = parseHtmlMetadata(html);
const parsed = parseTitleYear(meta.ogTitle || meta.title || '');
return {
id: Number(id),
mediaType: type,
title: parsed.title || meta.ogTitle || meta.title || null,
year: parsed.year,
description: meta.description || meta.ogDescription || null,
posterUrl: normalizeTmdbImage(meta.ogImage, 'w1280'),
canonical: meta.canonical,
ogType: meta.ogType,
source: 'html',
};
}
async getWatchPage(type, id, options = {}) {
const params = {
play: options.play ?? 1,
};
const html = await this.requestText(`/watch/${type}/${id}`, params, {
headers: this.pageHeaders,
});
const meta = parseHtmlMetadata(html);
const parsed = parseTitleYear(meta.title || meta.ogTitle || '');
return {
id: Number(id),
mediaType: type,
title: parsed.title || meta.ogTitle || meta.title || null,
year: parsed.year,
description: meta.description || meta.ogDescription || null,
posterUrl: normalizeTmdbImage(meta.ogImage, 'w1280'),
canonical: meta.canonical,
note:
'URL stream tidak tersedia di HTML awal. Player kemungkinan dimuat lewat request client-side.',
};
}
/* =========================
* WATCHLIST (butuh login)
* ========================= */
async getWatchlist() {
this.requireAuth('getWatchlist()');
return this.apiGet('/api/watchlist');
}
async addWatchlist({ tmdbId, mediaType, title, posterUrl, type } = {}) {
this.requireAuth('addWatchlist()');
return this.apiPost('/api/watchlist', {
tmdbId,
mediaType: mediaType ?? type,
title,
posterUrl,
});
}
async removeWatchlist({ tmdbId, mediaType, type } = {}) {
this.requireAuth('removeWatchlist()');
return this.apiDelete('/api/watchlist', {
tmdbId,
mediaType: mediaType ?? type,
});
}
async getReaction({ tmdbId, mediaType } = {}) {
return this.apiGet('/api/user/reactions', {
tmdbId,
mediaType,
});
}
async getCommunityFavorites(params = {}) {
return this.apiGet('/api/user/reactions', {
mode: 'community',
...params,
});
}
async setReaction(payload = {}) {
const body = {
tmdbId: payload.tmdbId ?? payload.id,
mediaType: payload.mediaType ?? payload.type,
reaction: payload.reaction ?? null, // 'like' | 'dislike' | null
title: payload.title,
posterUrl: payload.posterUrl,
backdropUrl: payload.backdropUrl,
rating: payload.rating,
year: payload.year,
genres: payload.genres,
};
return this.apiPost('/api/user/reactions', body);
}
async like(payload = {}) {
return this.setReaction({ ...payload, reaction: 'like' });
}
async dislike(payload = {}) {
return this.setReaction({ ...payload, reaction: 'dislike' });
}
async removeReaction(payload = {}) {
return this.setReaction({ ...payload, reaction: null });
}
/* =========================
* CONTINUE WATCHING
* ========================= */
async getContinueWatching() {
this.requireAuth('getContinueWatching()');
return this.apiGet('/api/continue-watching');
}
async saveContinueWatching(payload = {}) {
this.requireAuth('saveContinueWatching()');
return this.apiPost('/api/continue-watching', {
tmdbId: payload.tmdbId ?? payload.id,
mediaType: payload.mediaType ?? payload.type,
positionSeconds: payload.positionSeconds ?? 0,
durationSeconds: payload.durationSeconds ?? null,
title: payload.title,
posterUrl: payload.posterUrl,
});
}
async deleteContinueWatching(payload = {}) {
this.requireAuth('deleteContinueWatching()');
return this.apiDelete('/api/continue-watching', {
tmdbId: payload.tmdbId ?? payload.id,
mediaType: payload.mediaType ?? payload.type,
});
}
/* =========================
* NOBAR ROOMS
* ========================= */
async getNobarRooms() {
return this.apiGet('/api/nobar/rooms');
}
async downloadFile(url, filePath) {
const res = await this.request(new URL(url), {
headers: {
Accept: '*/*',
},
});
const buffer = Buffer.from(await res.arrayBuffer());
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, buffer);
return filePath;
}
static async writeJson(filePath, data) {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf8');
return filePath;
}
/* =========================
* GENRE STATIS DARI DATA
* ========================= */
static get movieGenres() {
return [
{ id: 28, name: 'Action' },
{ id: 12, name: 'Adventure' },
{ id: 16, name: 'Animation' },
{ id: 35, name: 'Comedy' },
{ id: 80, name: 'Crime' },
{ id: 99, name: 'Documentary' },
{ id: 18, name: 'Drama' },
{ id: 10751, name: 'Family' },
{ id: 14, name: 'Fantasy' },
{ id: 36, name: 'History' },
{ id: 27, name: 'Horror' },
{ id: 10402, name: 'Music' },
{ id: 9648, name: 'Mystery' },
{ id: 10749, name: 'Romance' },
{ id: 878, name: 'Sci-Fi' },
{ id: 10770, name: 'TV Movie' },
{ id: 53, name: 'Thriller' },
{ id: 10752, name: 'War' },
{ id: 37, name: 'Western' },
];
}
static get tvGenres() {
return [
{ id: 10759, name: 'Action & Adventure' },
{ id: 16, name: 'Animation' },
{ id: 35, name: 'Comedy' },
{ id: 80, name: 'Crime' },
{ id: 99, name: 'Documentary' },
{ id: 18, name: 'Drama' },
{ id: 10751, name: 'Family' },
{ id: 10762, name: 'Kids' },
{ id: 9648, name: 'Mystery' },
{ id: 10763, name: 'News' },
{ id: 10764, name: 'Reality' },
{ id: 10765, name: 'Sci-Fi & Fantasy' },
{ id: 10766, name: 'Soap' },
{ id: 10767, name: 'Talk' },
{ id: 10768, name: 'War & Politics' },
{ id: 37, name: 'Western' },
];
}
static get animeGenres() {
return [
{ id: 28, name: 'Action' },
{ id: 12, name: 'Adventure' },
{ id: 35, name: 'Comedy' },
{ id: 18, name: 'Drama' },
{ id: 14, name: 'Fantasy' },
{ id: 27, name: 'Horror' },
{ id: 9648, name: 'Mystery' },
{ id: 10749, name: 'Romance' },
{ id: 878, name: 'Sci-Fi' },
{ id: 53, name: 'Thriller' },
{ id: 10751, name: 'Slice of Life' },
];
}
static findGenre(type, query) {
const list =
type === 'tv'
? Veloflix.tvGenres
: type === 'anime'
? Veloflix.animeGenres
: Veloflix.movieGenres;
const q = String(query).toLowerCase();
return list.find(
(g) =>
g.id === Number(query) ||
g.name.toLowerCase().includes(q)
);
}
}
module.exports = {
Veloflix,
VeloflixError,
};
if (require.main === module) {
(async () => {
try {
const scraper = new Veloflix({
lang: 'id',
delayMs: 700,
jitterMs: 600,
cacheTtlMs: 60_000,
includeRaw: false,
// cookie: 'next-auth.session-token=...; __Secure-next-auth.session-token=...'
});
// 1) Kategori movie genre Action
console.log('Mengambil kategori movie genre Action...');
const category = await scraper.getCategoryPage({
type: 'movie',
genre: 28,
page: 1,
});
console.log(`Total halaman: ${category.totalPages}`);
console.log(`Item di halaman ini: ${category.items.length}`);
console.log(category.items.slice(0, 2));
// 2) Detail title
console.log('Mengambil detail title...');
const detail = await scraper.getTitle('movie', 969681);
console.log(detail);
// 3) Search
console.log('Mengambil hasil search...');
const searchResults = await scraper.search('spider-man');
console.log(searchResults.slice(0, 3));
// 4) Trending country
console.log('Mengambil trending movie ID...');
const trending = await scraper.getTrendingCountry({
country: 'ID',
type: 'movie',
page: 1,
});
console.log(trending.items.slice(0, 3));
} catch (err) {
console.error('ERROR:', err);
process.exit(1);
}
})();
}