Veloflix ( Veloflix Website Nonton Film Gratis )
JavaScriptPublicby zx-apiAug 12, 2026, 03:39 PMExpires: Never232 views
1'use strict';
2
3/**
4 * Veloflix ( Veloflix Website Nonton Film Gratis )
5 */
6
7const { setTimeout: sleep } = require('timers/promises');
8const fs = require('fs/promises');
9const path = require('path');
10
11const DEFAULT_USER_AGENT =
12 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36';
13
14const TMDB_IMAGE_BASE = 'https://image.tmdb.org/t/p';
15
16function escapeRegExp(value = '') {
17 return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
18}
19
20function decodeHtmlEntities(value = '') {
21 return String(value)
22 .replace(/&#(x[0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(parseInt(hex.slice(1), 16)))
23 .replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(Number(dec)))
24 .replace(/"/g, '"')
25 .replace(/'/g, "'")
26 .replace(/</g, '<')
27 .replace(/>/g, '>')
28 .replace(/ /g, ' ')
29 .replace(/&/g, '&');
30}
31
32function normalizeTmdbImage(value, width = 'w500') {
33 if (!value) return null;
34 if (/^https?:\/\//i.test(value)) return value;
35 if (value.startsWith('/')) return `${TMDB_IMAGE_BASE}/${width}${value}`;
36 return `${TMDB_IMAGE_BASE}/${width}/${value}`;
37}
38
39function extractTitle(html = '') {
40 const match = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
41 return match ? decodeHtmlEntities(match[1]).trim() : null;
42}
43
44function extractMetaContent(html = '', value, attr = 'name') {
45 if (!html || !value) return null;
46
47 const safeValue = escapeRegExp(value);
48 const patterns = [
49 new RegExp(`<meta[^>]+${attr}=["']${safeValue}["'][^>]+content=["']([^"']*)["']`, 'i'),
50 new RegExp(`<meta[^>]+content=["']([^"']*)["'][^>]+${attr}=["']${safeValue}["']`, 'i'),
51 ];
52
53 for (const re of patterns) {
54 const match = html.match(re);
55 if (match) return decodeHtmlEntities(match[1]).trim();
56 }
57
58 return null;
59}
60
61function extractLinkHref(html = '', rel) {
62 if (!html || !rel) return null;
63
64 const safeRel = escapeRegExp(rel);
65 const patterns = [
66 new RegExp(`<link[^>]+rel=["']${safeRel}["'][^>]+href=["']([^"']*)["']`, 'i'),
67 new RegExp(`<link[^>]+href=["']([^"']*)["'][^>]+rel=["']${safeRel}["']`, 'i'),
68 ];
69
70 for (const re of patterns) {
71 const match = html.match(re);
72 if (match) return decodeHtmlEntities(match[1]).trim();
73 }
74
75 return null;
76}
77
78function cleanTitleText(title = '') {
79 return String(title)
80 .replace(/^Nonton\s+(Streaming\s+)?(Film|TV Series|Serial TV|Series)\s*/i, '')
81 .replace(/\s*Sub Indo.*$/i, '')
82 .replace(/\s*\|\s*Veloflix\s*$/i, '')
83 .trim();
84}
85
86function parseTitleYear(rawTitle = '') {
87 let title = String(rawTitle || '').replace(/\s*\|\s*Veloflix\s*$/i, '').trim();
88
89 const withYear = title.match(/^(.*?)\s*\((\d{4})\)/);
90 if (withYear) {
91 return {
92 title: cleanTitleText(withYear[1]),
93 year: Number(withYear[2]),
94 };
95 }
96
97 const yearAnywhere = title.match(/\((\d{4})\)/);
98 if (yearAnywhere) {
99 return {
100 title: cleanTitleText(title.replace(yearAnywhere[0], '')),
101 year: Number(yearAnywhere[1]),
102 };
103 }
104
105 return {
106 title: cleanTitleText(title),
107 year: null,
108 };
109}
110
111function parseHtmlMetadata(html = '') {
112 return {
113 title: extractTitle(html),
114 description:
115 extractMetaContent(html, 'description') ||
116 extractMetaContent(html, 'og:description', 'property'),
117 ogTitle: extractMetaContent(html, 'og:title', 'property'),
118 ogDescription: extractMetaContent(html, 'og:description', 'property'),
119 ogImage: extractMetaContent(html, 'og:image', 'property'),
120 ogType: extractMetaContent(html, 'og:type', 'property'),
121 canonical: extractLinkHref(html, 'canonical'),
122 };
123}
124
125function extractJsonArrayByKey(text, key) {
126 if (!text || !key) return null;
127
128 const needle = `"${key}":`;
129 let searchFrom = 0;
130
131 while (true) {
132 const idx = text.indexOf(needle, searchFrom);
133 if (idx === -1) return null;
134
135 const start = text.indexOf('[', idx + needle.length);
136 if (start === -1) return null;
137
138 let inString = false;
139 let escaped = false;
140 let depth = 0;
141
142 for (let i = start; i < text.length; i++) {
143 const ch = text[i];
144
145 if (inString) {
146 if (escaped) {
147 escaped = false;
148 } else if (ch === '\\') {
149 escaped = true;
150 } else if (ch === '"') {
151 inString = false;
152 }
153 continue;
154 }
155
156 if (ch === '"') {
157 inString = true;
158 } else if (ch === '[' || ch === '{') {
159 depth++;
160 } else if (ch === ']' || ch === '}') {
161 depth--;
162 }
163
164 if (depth === 0 && ch === ']') {
165 const slice = text.slice(start, i + 1);
166 try {
167 return JSON.parse(slice);
168 } catch {
169 // lanjut cari occurrence berikutnya jika parse gagal
170 break;
171 }
172 }
173 }
174
175 searchFrom = idx + needle.length;
176 }
177}
178
179function extractNumberByKey(text, key) {
180 if (!text || !key) return null;
181 const re = new RegExp(`"${escapeRegExp(key)}"\\s*:\\s*(\\d+)`);
182 const match = text.match(re);
183 return match ? Number(match[1]) : null;
184}
185
186class VeloflixError extends Error {
187 constructor(message, context = {}) {
188 super(message);
189 this.name = 'VeloflixError';
190 Object.assign(this, context);
191 }
192}
193
194class Veloflix {
195 constructor(options = {}) {
196 if (typeof fetch !== 'function' && typeof options.fetchFn !== 'function') {
197 throw new VeloflixError(
198 'Global fetch tidak tersedia. Gunakan Node.js >= 18 atau sediakan options.fetchFn.'
199 );
200 }
201
202 this.baseUrl = (options.baseUrl || 'https://veloflix.my.id').replace(/\/+$/, '');
203 this.lang = options.lang || 'id';
204 this.userAgent = options.userAgent || DEFAULT_USER_AGENT;
205 this.referer = options.referer || `${this.baseUrl}/`;
206
207 if (options.cookie && typeof options.cookie === 'object') {
208 this.cookie = Object.entries(options.cookie)
209 .map(([k, v]) => `${k}=${v}`)
210 .join('; ');
211 } else {
212 this.cookie = options.cookie || '';
213 }
214
215 this.timeoutMs = options.timeoutMs ?? 30000;
216 this.retries = options.retries ?? 3;
217 this.retryDelayMs = options.retryDelayMs ?? 1200;
218 this.delayMs = options.delayMs ?? 450;
219 this.jitterMs = options.jitterMs ?? 550;
220 this.cacheTtlMs = options.cacheTtlMs ?? 0;
221 this.includeRaw = options.includeRaw ?? false;
222
223 this.fetchFn = options.fetchFn || ((...args) => fetch(...args));
224 this.cache = new Map();
225 this.queue = Promise.resolve();
226 }
227
228 setCookie(cookie) {
229 if (cookie && typeof cookie === 'object') {
230 this.cookie = Object.entries(cookie)
231 .map(([k, v]) => `${k}=${v}`)
232 .join('; ');
233 } else {
234 this.cookie = cookie || '';
235 }
236 return this;
237 }
238
239 clearCache() {
240 this.cache.clear();
241 return this;
242 }
243
244 buildUrl(pathname, params = {}) {
245 const url = new URL(pathname, this.baseUrl);
246
247 for (const [key, value] of Object.entries(params || {})) {
248 if (value === undefined || value === null || value === '') continue;
249 url.searchParams.set(key, String(value));
250 }
251
252 return url;
253 }
254
255 enqueue(task) {
256 const run = this.queue.then(() => task());
257 this.queue = run.then(
258 () => undefined,
259 () => undefined
260 );
261 return run;
262 }
263
264 async throttle() {
265 const delay = this.delayMs + Math.floor(Math.random() * this.jitterMs);
266 if (delay > 0) await sleep(delay);
267 }
268
269 cacheGet(key) {
270 if (this.cacheTtlMs <= 0) return undefined;
271 const entry = this.cache.get(key);
272 if (!entry) return undefined;
273
274 if (Date.now() > entry.expiresAt) {
275 this.cache.delete(key);
276 return undefined;
277 }
278
279 return entry.value;
280 }
281
282 cacheSet(key, value) {
283 if (this.cacheTtlMs <= 0) return;
284 this.cache.set(key, {
285 value,
286 expiresAt: Date.now() + this.cacheTtlMs,
287 });
288 }
289
290
291 get baseHeaders() {
292 const headers = {
293 'User-Agent': this.userAgent,
294 'Accept-Language': 'id-ID,id;q=0.9,en-US;q=0.8,en;q=0.7',
295 Referer: this.referer,
296 Origin: this.baseUrl,
297 };
298
299 if (this.cookie) {
300 headers.Cookie = this.cookie;
301 }
302
303 return headers;
304 }
305
306 get pageHeaders() {
307 return {
308 ...this.baseHeaders,
309 Accept:
310 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
311 'Upgrade-Insecure-Requests': '1',
312 };
313 }
314
315 get apiHeaders() {
316 return {
317 ...this.baseHeaders,
318 Accept: 'application/json',
319 };
320 }
321
322 jsonHeaders() {
323 return {
324 ...this.apiHeaders,
325 'Content-Type': 'application/json',
326 };
327 }
328
329 getRouterStateTree(pathname = '/') {
330 const rootTree = '["",{"children":["__PAGE__",{}]},null,null,true]';
331
332 const categoryTree = (type) =>
333 `["",{"children":["category",{"children":[["type","${type}","d"],{"children":["__PAGE__",{}]}]}]},null,null,true]`;
334
335 if (pathname.startsWith('/category/movie')) {
336 return encodeURIComponent(categoryTree('movie'));
337 }
338
339 if (pathname.startsWith('/category/tv')) {
340 return encodeURIComponent(categoryTree('tv'));
341 }
342
343 return encodeURIComponent(rootTree);
344 }
345
346 rscHeaders(pathname, nextUrl = '/') {
347 return {
348 ...this.baseHeaders,
349 Accept: 'text/x-component',
350 RSC: '1',
351 'Next-Router-Prefetch': '1',
352 'Next-Url': nextUrl,
353 'Next-Router-State-Tree': this.getRouterStateTree(pathname),
354 };
355 }
356
357 async fetchWithTimeout(url, options = {}) {
358 const controller = new AbortController();
359 const timer = setTimeout(() => controller.abort(), this.timeoutMs);
360
361 const headers = {
362 ...this.baseHeaders,
363 ...(options.headers || {}),
364 };
365
366 if (options.body && !headers['Content-Type']) {
367 headers['Content-Type'] = 'application/json';
368 }
369
370 try {
371 return await this.fetchFn(url.toString(), {
372 ...options,
373 headers,
374 signal: controller.signal,
375 redirect: options.redirect || 'follow',
376 });
377 } finally {
378 clearTimeout(timer);
379 }
380 }
381
382 async request(url, options = {}) {
383 return this.enqueue(async () => {
384 let lastError;
385
386 for (let attempt = 0; attempt <= this.retries; attempt++) {
387 try {
388 if (attempt === 0) {
389 await this.throttle();
390 } else {
391 await sleep(this.retryDelayMs * attempt);
392 }
393
394 const res = await this.fetchWithTimeout(url, options);
395
396 if ((res.status === 429 || res.status >= 500) && attempt < this.retries) {
397 continue;
398 }
399
400 if (!res.ok) {
401 const body = await res.text().catch(() => '');
402 throw new VeloflixError(
403 `HTTP ${res.status} ${res.statusText || ''}`.trim(),
404 {
405 status: res.status,
406 url: url.toString(),
407 body: body.slice(0, 1000),
408 }
409 );
410 }
411
412 return res;
413 } catch (err) {
414 lastError = err;
415
416 const retryableStatus =
417 err instanceof VeloflixError
418 ? [429, 500, 502, 503, 504].includes(err.status)
419 : true;
420
421 const retryableNetwork =
422 err.name === 'AbortError' ||
423 /fetch failed|network|ETIMEDOUT|ECONNRESET|ECONNREFUSED/i.test(err.message || '');
424
425 if (attempt < this.retries && (retryableStatus || retryableNetwork)) {
426 continue;
427 }
428
429 throw err;
430 }
431 }
432
433 throw lastError;
434 });
435 }
436
437 async requestText(pathname, params = {}, options = {}) {
438 const url = this.buildUrl(pathname, params);
439 const method = (options.method || 'GET').toUpperCase();
440 const cacheKey = url.toString();
441
442 if (method === 'GET') {
443 const cached = this.cacheGet(cacheKey);
444 if (cached !== undefined) return cached;
445 }
446
447 const res = await this.request(url, options);
448 const text = await res.text();
449
450 if (method === 'GET') {
451 this.cacheSet(cacheKey, text);
452 }
453
454 return text;
455 }
456
457 async requestJson(pathname, params = {}, options = {}) {
458 const url = this.buildUrl(pathname, params);
459 const method = (options.method || 'GET').toUpperCase();
460 const cacheKey = url.toString();
461
462 if (method === 'GET') {
463 const cached = this.cacheGet(cacheKey);
464 if (cached !== undefined) return cached;
465 }
466
467 const res = await this.request(url, options);
468 const text = await res.text();
469
470 if (!text) {
471 return { ok: true };
472 }
473
474 let json;
475 try {
476 json = JSON.parse(text);
477 } catch {
478 throw new VeloflixError(`Response bukan JSON dari ${url}`, {
479 url: url.toString(),
480 body: text.slice(0, 1000),
481 });
482 }
483
484 if (json && json.error) {
485 const message =
486 typeof json.error === 'string'
487 ? json.error
488 : json.error.message || 'API mengembalikan error';
489
490 throw new VeloflixError(message, {
491 url: url.toString(),
492 data: json,
493 });
494 }
495
496 if (method === 'GET') {
497 this.cacheSet(cacheKey, json);
498 }
499
500 return json;
501 }
502
503 async apiGet(pathname, params = {}) {
504 return this.requestJson(pathname, params, {
505 headers: this.apiHeaders,
506 });
507 }
508
509 async apiPost(pathname, body = {}, params = {}) {
510 return this.requestJson(pathname, params, {
511 method: 'POST',
512 headers: this.jsonHeaders(),
513 body: JSON.stringify(body),
514 });
515 }
516
517 async apiDelete(pathname, body = null, params = {}) {
518 return this.requestJson(pathname, params, {
519 method: 'DELETE',
520 headers: this.jsonHeaders(),
521 body: body ? JSON.stringify(body) : undefined,
522 });
523 }
524
525 requireAuth(featureName = 'Endpoint ini') {
526 if (!this.cookie) {
527 throw new VeloflixError(
528 `${featureName} membutuhkan cookie sesi/login. ` +
529 `Contoh: new Veloflix({ cookie: 'next-auth.session-token=...; __Secure-next-auth.session-token=...' })`
530 );
531 }
532 }
533
534 normalizeItem(raw) {
535 if (!raw) return raw;
536
537 if (typeof raw === 'string') {
538 return { title: raw };
539 }
540
541 if (typeof raw !== 'object') {
542 return raw;
543 }
544
545 const id = raw.id ?? raw.tmdbId ?? raw.tmdb_id ?? raw.movieId ?? raw.tvId;
546 const mediaType =
547 raw.mediaType ??
548 raw.media_type ??
549 raw.type ??
550 (raw.tvId || raw.first_air_date ? 'tv' : 'movie');
551
552 const title =
553 raw.title ??
554 raw.name ??
555 raw.label ??
556 raw.original_title ??
557 raw.original_name ??
558 null;
559
560 const year =
561 raw.year ??
562 (raw.releaseDate
563 ? Number(String(raw.releaseDate).slice(0, 4))
564 : raw.release_date
565 ? Number(String(raw.release_date).slice(0, 4))
566 : null);
567
568 const item = {
569 id: id ?? null,
570 mediaType,
571 title,
572 year,
573 rating: raw.rating ?? raw.vote_average ?? raw.voteAverage ?? raw.score ?? null,
574 voteCount: raw.voteCount ?? raw.vote_count ?? null,
575 posterUrl: normalizeTmdbImage(raw.posterUrl ?? raw.poster_path ?? raw.poster, 'w500'),
576 backdropUrl: normalizeTmdbImage(
577 raw.backdropUrl ?? raw.backdrop_path ?? raw.backdrop,
578 'w1280'
579 ),
580 overview: raw.overview ?? raw.description ?? raw.synopsis ?? null,
581 genres: raw.genres ?? raw.genreIds ?? raw.genre_ids ?? [],
582 releaseDate: raw.releaseDate ?? raw.release_date ?? raw.first_air_date ?? null,
583 logoUrl: raw.logoUrl ?? raw.logo ?? null,
584 originalLanguage: raw.originalLanguage ?? raw.original_language ?? null,
585 originCountry: raw.originCountry ?? raw.origin_country ?? null,
586 imdbId: raw.imdbId ?? raw.imdb_id ?? null,
587 url:
588 id && mediaType
589 ? `${this.baseUrl}/title/${mediaType}/${id}`
590 : null,
591 watchUrl:
592 id && mediaType
593 ? `${this.baseUrl}/watch/${mediaType}/${id}?play=1`
594 : null,
595 };
596
597 if (this.includeRaw) {
598 item.raw = raw;
599 }
600
601 return item;
602 }
603
604 normalizeDetail(data, fallbackType, fallbackId) {
605 const base = this.normalizeItem(data || {});
606
607 return {
608 ...base,
609 id: data?.id ?? fallbackId ?? base.id,
610 mediaType: data?.mediaType ?? fallbackType ?? base.mediaType,
611 cast: Array.isArray(data?.cast)
612 ? data.cast.map((c) => ({
613 id: c.id ?? null,
614 name: c.name ?? null,
615 character: c.character ?? c.role ?? null,
616 profileUrl: normalizeTmdbImage(c.profileUrl ?? c.profile_path, 'w185'),
617 }))
618 : [],
619 recommendations: Array.isArray(data?.recommendations)
620 ? data.recommendations.map((r) => this.normalizeItem(r))
621 : [],
622 seasons: Array.isArray(data?.seasons) ? data.seasons : [],
623 nextEpisodeToAir: data?.nextEpisodeToAir ?? null,
624 trailerYoutubeId: data?.trailerYoutubeId ?? data?.trailer ?? null,
625 providers: data?.providers ?? [],
626 imdbId: data?.imdbId ?? data?.imdb_id ?? base.imdbId,
627 };
628 }
629
630 extractListAndMeta(json) {
631 if (!json) {
632 return { list: [], meta: {} };
633 }
634
635 if (Array.isArray(json)) {
636 return { list: json, meta: {} };
637 }
638
639 const candidates = [json, json.data, json.result, json.results, json.response].filter(Boolean);
640
641 for (const candidate of candidates) {
642 if (Array.isArray(candidate)) {
643 return { list: candidate, meta: {} };
644 }
645
646 if (candidate && typeof candidate === 'object') {
647 const possibleKeys = ['items', 'results', 'data', 'list', 'titles', 'records'];
648 for (const key of possibleKeys) {
649 if (Array.isArray(candidate[key])) {
650 return { list: candidate[key], meta: candidate };
651 }
652 }
653 }
654 }
655
656 if (typeof json === 'object') {
657 return { list: [json], meta: json };
658 }
659
660 return { list: [], meta: {} };
661 }
662
663 parseCategoryPayload(text, fallbackPage = 1) {
664 if (!text) {
665 return {
666 items: [],
667 currentPage: fallbackPage,
668 totalPages: 1,
669 totalResults: 0,
670 };
671 }
672
673 let items = extractJsonArrayByKey(text, 'initialItems');
674
675 if (!items) {
676 try {
677 const json = JSON.parse(text);
678 const { list, meta } = this.extractListAndMeta(json);
679
680 if (Array.isArray(list) && list.length) {
681 return {
682 items: list.map((item) => this.normalizeItem(item)),
683 currentPage: meta.currentPage ?? fallbackPage,
684 totalPages: meta.totalPages ?? 1,
685 totalResults: meta.totalResults ?? list.length,
686 };
687 }
688 } catch {
689 }
690 }
691
692 if (!items || !Array.isArray(items)) {
693 return {
694 items: [],
695 currentPage: fallbackPage,
696 totalPages: 1,
697 totalResults: 0,
698 };
699 }
700
701 return {
702 items: items.map((item) => this.normalizeItem(item)),
703 currentPage: extractNumberByKey(text, 'currentPage') ?? fallbackPage,
704 totalPages: extractNumberByKey(text, 'totalPages') ?? 1,
705 totalResults: extractNumberByKey(text, 'totalResults') ?? items.length,
706 };
707 }
708
709 parseTitleLinksFromHtml(html = '', defaultType = 'movie') {
710 const items = [];
711 const seen = new Set();
712 const re = /href="\/title\/(movie|tv)\/(\d+)"/g;
713
714 let match;
715 while ((match = re.exec(html)) !== null) {
716 const mediaType = match[1] || defaultType;
717 const id = Number(match[2]);
718 const key = `${mediaType}-${id}`;
719
720 if (seen.has(key)) continue;
721 seen.add(key);
722
723 items.push(
724 this.normalizeItem({
725 id,
726 mediaType,
727 })
728 );
729 }
730
731 return items;
732 }
733
734 /* =========================
735 * SEARCH
736 * ========================= */
737
738 async search(query, params = {}) {
739 if (!query) return [];
740
741 const json = await this.apiGet('/api/search', {
742 q: query,
743 ...params,
744 });
745
746 const { list } = this.extractListAndMeta(json);
747
748 return list.map((item) =>
749 typeof item === 'string' ? { title: item } : this.normalizeItem(item)
750 );
751 }
752
753 async getSearchTrending(limit = 10) {
754 const json = await this.apiGet('/api/search/trending', { limit });
755 const { list } = this.extractListAndMeta(json);
756
757 return list.map((item) => {
758 if (typeof item === 'string') return { label: item };
759 if (item && item.label) return item;
760 return this.normalizeItem(item);
761 });
762 }
763
764 /* =========================
765 * TRENDING
766 * ========================= */
767
768 async getTrendingCountry(options = {}) {
769 const {
770 country = 'ID',
771 type = 'movie', // movie | tv
772 page = 1,
773 limit,
774 lang = this.lang,
775 } = options;
776
777 const params = {
778 country,
779 type,
780 page,
781 lang,
782 };
783
784 if (limit) params.limit = limit;
785
786 const json = await this.apiGet('/api/trending/country', params);
787 const { list, meta } = this.extractListAndMeta(json);
788
789 return {
790 items: list.map((item) => this.normalizeItem(item)),
791 currentPage: meta.currentPage ?? page,
792 totalPages: meta.totalPages ?? null,
793 totalResults: meta.totalResults ?? list.length,
794 meta,
795 };
796 }
797
798 async getHomeFeed({ country = 'ID' } = {}) {
799 const movies = await this.getTrendingCountry({ country, type: 'movie' });
800 const series = await this.getTrendingCountry({ country, type: 'tv' });
801
802 return {
803 movies: movies.items,
804 series: series.items,
805 meta: {
806 movies,
807 series,
808 },
809 };
810 }
811
812 async getCategoryPage(options = {}) {
813 const {
814 type = 'movie', // movie | tv
815 page = 1,
816 genre,
817 sort,
818 year,
819 minRating,
820 rating,
821 country,
822 q,
823 search,
824 lang,
825 } = options;
826
827 const params = {
828 page,
829 genre,
830 sort,
831 year,
832 country,
833 q: q ?? search,
834 lang: lang ?? this.lang,
835 };
836
837 const effectiveMinRating = minRating ?? rating;
838 if (effectiveMinRating !== undefined && effectiveMinRating !== null) {
839 params.minRating = effectiveMinRating;
840 }
841
842 const pathname = `/category/${type}`;
843 const errors = [];
844
845 try {
846 const text = await this.requestText(pathname, params, {
847 headers: this.rscHeaders(pathname, pathname),
848 });
849
850 const parsed = this.parseCategoryPayload(text, page);
851 if (parsed.items.length) {
852 return {
853 ...parsed,
854 source: 'rsc',
855 };
856 }
857 } catch (err) {
858 errors.push(err);
859 }
860
861 try {
862 const html = await this.requestText(pathname, params, {
863 headers: this.pageHeaders,
864 });
865
866 const parsed = this.parseCategoryPayload(html, page);
867 if (parsed.items.length) {
868 return {
869 ...parsed,
870 source: 'html',
871 };
872 }
873
874 const fallbackItems = this.parseTitleLinksFromHtml(html, type);
875 if (fallbackItems.length) {
876 return {
877 items: fallbackItems,
878 currentPage: page,
879 totalPages: 1,
880 totalResults: fallbackItems.length,
881 source: 'html-links',
882 };
883 }
884 } catch (err) {
885 errors.push(err);
886 }
887
888 throw new VeloflixError(
889 `Gagal mengambil kategori ${type}. Kemungkinan struktur berubah atau diblokir. ` +
890 `Errors: ${errors.map((e) => e.message).join(' | ')}`,
891 { errors }
892 );
893 }
894
895 async *iterateCategory(options = {}) {
896 const maxPages = options.maxPages ?? Infinity;
897 let page = options.page ?? 1;
898
899 while (page <= maxPages) {
900 const data = await this.getCategoryPage({
901 ...options,
902 page,
903 });
904
905 yield data;
906
907 if (!data.items.length) break;
908 if (page >= (data.totalPages ?? page)) break;
909
910 page += 1;
911 }
912 }
913
914 async collectCategory(options = {}) {
915 const items = [];
916
917 for await (const pageData of this.iterateCategory(options)) {
918 items.push(...pageData.items);
919
920 if (options.limit && items.length >= options.limit) {
921 break;
922 }
923 }
924
925 return options.limit ? items.slice(0, options.limit) : items;
926 }
927
928 async getTitle(type, id) {
929 try {
930 const json = await this.apiGet(`/api/title/${type}/${id}`);
931 const payload = json?.data ?? json;
932
933 if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
934 return this.normalizeDetail(payload, type, id);
935 }
936
937 if (Array.isArray(payload) && payload.length) {
938 return this.normalizeDetail(payload[0], type, id);
939 }
940 } catch {
941 }
942
943 return this.scrapeTitleMeta(type, id);
944 }
945
946 async scrapeTitleMeta(type, id) {
947 const html = await this.requestText(`/title/${type}/${id}`, {}, {
948 headers: this.pageHeaders,
949 });
950
951 const meta = parseHtmlMetadata(html);
952 const parsed = parseTitleYear(meta.ogTitle || meta.title || '');
953
954 return {
955 id: Number(id),
956 mediaType: type,
957 title: parsed.title || meta.ogTitle || meta.title || null,
958 year: parsed.year,
959 description: meta.description || meta.ogDescription || null,
960 posterUrl: normalizeTmdbImage(meta.ogImage, 'w1280'),
961 canonical: meta.canonical,
962 ogType: meta.ogType,
963 source: 'html',
964 };
965 }
966
967 async getWatchPage(type, id, options = {}) {
968 const params = {
969 play: options.play ?? 1,
970 };
971
972 const html = await this.requestText(`/watch/${type}/${id}`, params, {
973 headers: this.pageHeaders,
974 });
975
976 const meta = parseHtmlMetadata(html);
977 const parsed = parseTitleYear(meta.title || meta.ogTitle || '');
978
979 return {
980 id: Number(id),
981 mediaType: type,
982 title: parsed.title || meta.ogTitle || meta.title || null,
983 year: parsed.year,
984 description: meta.description || meta.ogDescription || null,
985 posterUrl: normalizeTmdbImage(meta.ogImage, 'w1280'),
986 canonical: meta.canonical,
987 note:
988 'URL stream tidak tersedia di HTML awal. Player kemungkinan dimuat lewat request client-side.',
989 };
990 }
991
992 /* =========================
993 * WATCHLIST (butuh login)
994 * ========================= */
995
996 async getWatchlist() {
997 this.requireAuth('getWatchlist()');
998 return this.apiGet('/api/watchlist');
999 }
1000
1001 async addWatchlist({ tmdbId, mediaType, title, posterUrl, type } = {}) {
1002 this.requireAuth('addWatchlist()');
1003
1004 return this.apiPost('/api/watchlist', {
1005 tmdbId,
1006 mediaType: mediaType ?? type,
1007 title,
1008 posterUrl,
1009 });
1010 }
1011
1012 async removeWatchlist({ tmdbId, mediaType, type } = {}) {
1013 this.requireAuth('removeWatchlist()');
1014
1015 return this.apiDelete('/api/watchlist', {
1016 tmdbId,
1017 mediaType: mediaType ?? type,
1018 });
1019 }
1020
1021 async getReaction({ tmdbId, mediaType } = {}) {
1022 return this.apiGet('/api/user/reactions', {
1023 tmdbId,
1024 mediaType,
1025 });
1026 }
1027
1028 async getCommunityFavorites(params = {}) {
1029 return this.apiGet('/api/user/reactions', {
1030 mode: 'community',
1031 ...params,
1032 });
1033 }
1034
1035 async setReaction(payload = {}) {
1036 const body = {
1037 tmdbId: payload.tmdbId ?? payload.id,
1038 mediaType: payload.mediaType ?? payload.type,
1039 reaction: payload.reaction ?? null, // 'like' | 'dislike' | null
1040 title: payload.title,
1041 posterUrl: payload.posterUrl,
1042 backdropUrl: payload.backdropUrl,
1043 rating: payload.rating,
1044 year: payload.year,
1045 genres: payload.genres,
1046 };
1047
1048 return this.apiPost('/api/user/reactions', body);
1049 }
1050
1051 async like(payload = {}) {
1052 return this.setReaction({ ...payload, reaction: 'like' });
1053 }
1054
1055 async dislike(payload = {}) {
1056 return this.setReaction({ ...payload, reaction: 'dislike' });
1057 }
1058
1059 async removeReaction(payload = {}) {
1060 return this.setReaction({ ...payload, reaction: null });
1061 }
1062
1063 /* =========================
1064 * CONTINUE WATCHING
1065 * ========================= */
1066
1067 async getContinueWatching() {
1068 this.requireAuth('getContinueWatching()');
1069 return this.apiGet('/api/continue-watching');
1070 }
1071
1072 async saveContinueWatching(payload = {}) {
1073 this.requireAuth('saveContinueWatching()');
1074
1075 return this.apiPost('/api/continue-watching', {
1076 tmdbId: payload.tmdbId ?? payload.id,
1077 mediaType: payload.mediaType ?? payload.type,
1078 positionSeconds: payload.positionSeconds ?? 0,
1079 durationSeconds: payload.durationSeconds ?? null,
1080 title: payload.title,
1081 posterUrl: payload.posterUrl,
1082 });
1083 }
1084
1085 async deleteContinueWatching(payload = {}) {
1086 this.requireAuth('deleteContinueWatching()');
1087
1088 return this.apiDelete('/api/continue-watching', {
1089 tmdbId: payload.tmdbId ?? payload.id,
1090 mediaType: payload.mediaType ?? payload.type,
1091 });
1092 }
1093
1094 /* =========================
1095 * NOBAR ROOMS
1096 * ========================= */
1097
1098 async getNobarRooms() {
1099 return this.apiGet('/api/nobar/rooms');
1100 }
1101
1102 async downloadFile(url, filePath) {
1103 const res = await this.request(new URL(url), {
1104 headers: {
1105 Accept: '*/*',
1106 },
1107 });
1108
1109 const buffer = Buffer.from(await res.arrayBuffer());
1110 await fs.mkdir(path.dirname(filePath), { recursive: true });
1111 await fs.writeFile(filePath, buffer);
1112
1113 return filePath;
1114 }
1115
1116 static async writeJson(filePath, data) {
1117 await fs.mkdir(path.dirname(filePath), { recursive: true });
1118 await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf8');
1119 return filePath;
1120 }
1121
1122 /* =========================
1123 * GENRE STATIS DARI DATA
1124 * ========================= */
1125
1126 static get movieGenres() {
1127 return [
1128 { id: 28, name: 'Action' },
1129 { id: 12, name: 'Adventure' },
1130 { id: 16, name: 'Animation' },
1131 { id: 35, name: 'Comedy' },
1132 { id: 80, name: 'Crime' },
1133 { id: 99, name: 'Documentary' },
1134 { id: 18, name: 'Drama' },
1135 { id: 10751, name: 'Family' },
1136 { id: 14, name: 'Fantasy' },
1137 { id: 36, name: 'History' },
1138 { id: 27, name: 'Horror' },
1139 { id: 10402, name: 'Music' },
1140 { id: 9648, name: 'Mystery' },
1141 { id: 10749, name: 'Romance' },
1142 { id: 878, name: 'Sci-Fi' },
1143 { id: 10770, name: 'TV Movie' },
1144 { id: 53, name: 'Thriller' },
1145 { id: 10752, name: 'War' },
1146 { id: 37, name: 'Western' },
1147 ];
1148 }
1149
1150 static get tvGenres() {
1151 return [
1152 { id: 10759, name: 'Action & Adventure' },
1153 { id: 16, name: 'Animation' },
1154 { id: 35, name: 'Comedy' },
1155 { id: 80, name: 'Crime' },
1156 { id: 99, name: 'Documentary' },
1157 { id: 18, name: 'Drama' },
1158 { id: 10751, name: 'Family' },
1159 { id: 10762, name: 'Kids' },
1160 { id: 9648, name: 'Mystery' },
1161 { id: 10763, name: 'News' },
1162 { id: 10764, name: 'Reality' },
1163 { id: 10765, name: 'Sci-Fi & Fantasy' },
1164 { id: 10766, name: 'Soap' },
1165 { id: 10767, name: 'Talk' },
1166 { id: 10768, name: 'War & Politics' },
1167 { id: 37, name: 'Western' },
1168 ];
1169 }
1170
1171 static get animeGenres() {
1172 return [
1173 { id: 28, name: 'Action' },
1174 { id: 12, name: 'Adventure' },
1175 { id: 35, name: 'Comedy' },
1176 { id: 18, name: 'Drama' },
1177 { id: 14, name: 'Fantasy' },
1178 { id: 27, name: 'Horror' },
1179 { id: 9648, name: 'Mystery' },
1180 { id: 10749, name: 'Romance' },
1181 { id: 878, name: 'Sci-Fi' },
1182 { id: 53, name: 'Thriller' },
1183 { id: 10751, name: 'Slice of Life' },
1184 ];
1185 }
1186
1187 static findGenre(type, query) {
1188 const list =
1189 type === 'tv'
1190 ? Veloflix.tvGenres
1191 : type === 'anime'
1192 ? Veloflix.animeGenres
1193 : Veloflix.movieGenres;
1194
1195 const q = String(query).toLowerCase();
1196
1197 return list.find(
1198 (g) =>
1199 g.id === Number(query) ||
1200 g.name.toLowerCase().includes(q)
1201 );
1202 }
1203}
1204
1205module.exports = {
1206 Veloflix,
1207 VeloflixError,
1208};
1209
1210if (require.main === module) {
1211 (async () => {
1212 try {
1213 const scraper = new Veloflix({
1214 lang: 'id',
1215 delayMs: 700,
1216 jitterMs: 600,
1217 cacheTtlMs: 60_000,
1218 includeRaw: false,
1219 // cookie: 'next-auth.session-token=...; __Secure-next-auth.session-token=...'
1220 });
1221
1222 // 1) Kategori movie genre Action
1223 console.log('Mengambil kategori movie genre Action...');
1224 const category = await scraper.getCategoryPage({
1225 type: 'movie',
1226 genre: 28,
1227 page: 1,
1228 });
1229
1230 console.log(`Total halaman: ${category.totalPages}`);
1231 console.log(`Item di halaman ini: ${category.items.length}`);
1232 console.log(category.items.slice(0, 2));
1233
1234 // 2) Detail title
1235 console.log('Mengambil detail title...');
1236 const detail = await scraper.getTitle('movie', 969681);
1237 console.log(detail);
1238
1239 // 3) Search
1240 console.log('Mengambil hasil search...');
1241 const searchResults = await scraper.search('spider-man');
1242 console.log(searchResults.slice(0, 3));
1243
1244 // 4) Trending country
1245 console.log('Mengambil trending movie ID...');
1246 const trending = await scraper.getTrendingCountry({
1247 country: 'ID',
1248 type: 'movie',
1249 page: 1,
1250 });
1251 console.log(trending.items.slice(0, 3));
1252
1253 } catch (err) {
1254 console.error('ERROR:', err);
1255 process.exit(1);
1256 }
1257 })();
1258}1,258 lines·34,116 chars·33.3 KB
Wwrap·Ffullscreen