Veloflix ( Veloflix Website Nonton Film Gratis )

JavaScriptPublic
by zx-apiAug 12, 2026, 03:39 PMExpires: Never232 views
Veloflix ( Veloflix Website Nonton Film Gratis )
Raw
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(/&lt;/g, '<')
27    .replace(/&gt;/g, '>')
28    .replace(/&nbsp;/g, ' ')
29    .replace(/&amp;/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