2026-07-30 22:32:44 -04:00
|
|
|
import { proxyImageUrl } from './proxy-image'
|
|
|
|
|
|
2026-05-21 23:07:59 +07:00
|
|
|
type Source = 'ophim' | 'nguonc' | 'kkphim'
|
|
|
|
|
type SourceFilter = Source | 'all'
|
2026-05-17 20:30:19 +07:00
|
|
|
|
|
|
|
|
export interface NormalizedMovie {
|
|
|
|
|
id: string
|
|
|
|
|
source: Source
|
|
|
|
|
name: string
|
|
|
|
|
originName: string
|
|
|
|
|
slug: string
|
|
|
|
|
thumb: string
|
|
|
|
|
poster: string
|
|
|
|
|
year?: number
|
|
|
|
|
time?: string
|
|
|
|
|
episode?: string
|
2026-07-23 02:56:24 -04:00
|
|
|
episodeTotal?: string
|
2026-05-17 20:30:19 +07:00
|
|
|
quality?: string
|
|
|
|
|
lang?: string
|
|
|
|
|
type?: string
|
|
|
|
|
rating?: number
|
2026-05-22 13:20:09 +07:00
|
|
|
updatedAt?: string
|
2026-05-17 20:30:19 +07:00
|
|
|
categories: string[]
|
|
|
|
|
countries: string[]
|
2026-05-23 15:08:57 +07:00
|
|
|
sources?: MovieSourceRef[]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface MovieSourceRef {
|
|
|
|
|
source: Source
|
|
|
|
|
slug: string
|
|
|
|
|
name?: string
|
2026-07-28 21:55:39 -04:00
|
|
|
episode?: string
|
|
|
|
|
episodeTotal?: string
|
2026-05-17 20:30:19 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface NormalizedEpisode {
|
|
|
|
|
name: string
|
|
|
|
|
slug?: string
|
|
|
|
|
linkEmbed?: string
|
|
|
|
|
linkM3u8?: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface NormalizedServer {
|
|
|
|
|
name: string
|
2026-05-23 15:08:57 +07:00
|
|
|
source?: Source
|
|
|
|
|
sourceSlug?: string
|
|
|
|
|
sourceServerIndex?: number
|
2026-05-17 20:30:19 +07:00
|
|
|
episodes: NormalizedEpisode[]
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 14:51:01 +07:00
|
|
|
export interface NormalizedActor {
|
|
|
|
|
name: string
|
|
|
|
|
originalName?: string
|
|
|
|
|
role?: string
|
|
|
|
|
avatar?: string
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 20:30:19 +07:00
|
|
|
export interface MovieDetail extends NormalizedMovie {
|
|
|
|
|
content: string
|
2026-05-18 14:51:01 +07:00
|
|
|
actors: NormalizedActor[]
|
2026-05-17 20:30:19 +07:00
|
|
|
directors: string[]
|
|
|
|
|
trailer?: string
|
|
|
|
|
servers: NormalizedServer[]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const OPHIM_BASE = 'https://ophim1.com'
|
|
|
|
|
const OPHIM_IMAGE = 'https://img.ophim.live/uploads/movies/'
|
|
|
|
|
const NGUONC_BASE = 'https://phim.nguonc.com'
|
2026-05-21 23:07:59 +07:00
|
|
|
const KKPHIM_BASE = 'https://phimapi.com'
|
|
|
|
|
const KKPHIM_IMAGE = 'https://phimimg.com/'
|
2026-05-24 22:35:13 +07:00
|
|
|
const SOURCE_NAMES: Source[] = ['nguonc', 'ophim', 'kkphim']
|
|
|
|
|
const DEFAULT_SOURCE: Source = 'nguonc'
|
2026-05-17 20:30:19 +07:00
|
|
|
|
|
|
|
|
function toArray<T>(value: T[] | T | undefined | null): T[] {
|
|
|
|
|
if (!value) return []
|
|
|
|
|
return Array.isArray(value) ? value : [value]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function text(value: unknown, fallback = '') {
|
|
|
|
|
return typeof value === 'string' ? value : fallback
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function joinImage(base: string, path?: string) {
|
|
|
|
|
if (!path) return ''
|
|
|
|
|
if (/^https?:\/\//i.test(path)) return path
|
|
|
|
|
const cleanBase = base.endsWith('/') ? base : `${base}/`
|
|
|
|
|
const cleanPath = path.startsWith('/') ? path.slice(1) : path
|
|
|
|
|
return `${cleanBase}${cleanPath}`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function joinOphimImage(base: string, path?: string) {
|
|
|
|
|
if (!path) return ''
|
|
|
|
|
if (/^https?:\/\//i.test(path)) return path
|
|
|
|
|
|
|
|
|
|
const url = new URL(base)
|
|
|
|
|
const cleanPath = path.startsWith('/') ? path.slice(1) : path
|
|
|
|
|
const basePath = url.pathname.replace(/\/$/, '')
|
|
|
|
|
|
|
|
|
|
if (cleanPath.startsWith('uploads/')) {
|
|
|
|
|
url.pathname = cleanPath
|
|
|
|
|
} else if (basePath.includes('/uploads/movies')) {
|
|
|
|
|
url.pathname = `${basePath}/${cleanPath}`
|
|
|
|
|
} else {
|
|
|
|
|
url.pathname = `/uploads/movies/${cleanPath}`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return url.toString()
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-21 23:07:59 +07:00
|
|
|
function joinKkphimImage(base: string, path?: string) {
|
|
|
|
|
if (!path) return ''
|
|
|
|
|
if (/^https?:\/\//i.test(path)) return path
|
|
|
|
|
|
|
|
|
|
const url = new URL(base)
|
|
|
|
|
const cleanPath = path.startsWith('/') ? path.slice(1) : path
|
|
|
|
|
const basePath = url.pathname.replace(/\/$/, '')
|
|
|
|
|
url.pathname = basePath && basePath !== '/' ? `${basePath}/${cleanPath}` : cleanPath
|
|
|
|
|
|
|
|
|
|
return url.toString()
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 20:30:19 +07:00
|
|
|
function includesKorea(movie: any) {
|
|
|
|
|
const countries = toArray(movie?.country ?? movie?.countries ?? movie?.quoc_gia)
|
|
|
|
|
if (!countries.length) return true
|
|
|
|
|
|
|
|
|
|
return countries.some((country: any) => {
|
|
|
|
|
const slug = text(country?.slug ?? country).toLowerCase()
|
|
|
|
|
const name = text(country?.name ?? country).toLowerCase()
|
|
|
|
|
return slug === 'han-quoc' || name.includes('hàn quốc') || name.includes('han quoc')
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function stripHtml(value?: string) {
|
|
|
|
|
return text(value)
|
|
|
|
|
.replace(/<[^>]+>/g, ' ')
|
|
|
|
|
.replace(/\s+/g, ' ')
|
|
|
|
|
.trim()
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-23 15:08:57 +07:00
|
|
|
function comparableText(value?: string) {
|
|
|
|
|
return text(value)
|
|
|
|
|
.normalize('NFD')
|
|
|
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
|
|
|
.toLowerCase()
|
|
|
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
|
|
|
.replace(/^-+|-+$/g, '')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function compactTitleKey(value?: string) {
|
|
|
|
|
return comparableText(value)
|
|
|
|
|
.replace(/\b(vietsub|thuyet-minh|long-tieng|hd|full-hd|fhd|bluray|web-dl|webdl)\b/g, '')
|
|
|
|
|
.replace(/\b(phan|mua|season|part|ss|s)-?(\d+)\b/g, 's$2')
|
|
|
|
|
.replace(/\b(19|20)\d{2}\b/g, '')
|
|
|
|
|
.replace(/-{2,}/g, '-')
|
|
|
|
|
.replace(/^-+|-+$/g, '')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function movieTitleAliases(movie: NormalizedMovie) {
|
|
|
|
|
const aliases = new Set<string>()
|
|
|
|
|
const values = [
|
|
|
|
|
movie.originName,
|
|
|
|
|
movie.name,
|
|
|
|
|
movie.slug,
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
for (const value of values) {
|
|
|
|
|
const key = compactTitleKey(value)
|
|
|
|
|
if (!key || key.length < 3) continue
|
|
|
|
|
aliases.add(key)
|
|
|
|
|
|
|
|
|
|
const seriesLike = movie.type !== 'single' || !/^(full|hoan-tat)$/i.test(String(movie.episode || ''))
|
|
|
|
|
if (seriesLike) {
|
|
|
|
|
aliases.add(key.replace(/-(\d+)$/, '-s$1'))
|
|
|
|
|
aliases.add(key.replace(/-s(\d+)$/, '-$1'))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [...aliases]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function movieSourceRef(movie: NormalizedMovie): MovieSourceRef {
|
|
|
|
|
return {
|
|
|
|
|
source: movie.source,
|
|
|
|
|
slug: movie.slug,
|
|
|
|
|
name: movie.name,
|
2026-07-28 21:55:39 -04:00
|
|
|
episode: movie.episode,
|
|
|
|
|
episodeTotal: movie.episodeTotal,
|
2026-05-23 15:08:57 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-21 23:07:59 +07:00
|
|
|
function isSource(value: unknown): value is Source {
|
|
|
|
|
return typeof value === 'string' && SOURCE_NAMES.includes(value as Source)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function interleaveMovies(groups: NormalizedMovie[][]) {
|
|
|
|
|
const maxLength = Math.max(0, ...groups.map((items) => items.length))
|
|
|
|
|
const interleaved: NormalizedMovie[] = []
|
|
|
|
|
|
|
|
|
|
for (let index = 0; index < maxLength; index += 1) {
|
|
|
|
|
for (const items of groups) {
|
|
|
|
|
const movie = items[index]
|
|
|
|
|
if (movie) interleaved.push(movie)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return interleaved
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-28 21:55:39 -04:00
|
|
|
function extractEpisodeNumber(value?: string): number {
|
|
|
|
|
if (!value) return 0
|
|
|
|
|
const match = value.match(/(\d+)(?:\/\d+)?\s*$/)
|
|
|
|
|
if (!match) {
|
|
|
|
|
const anyNumber = value.match(/\d+/)
|
|
|
|
|
return anyNumber ? Number(anyNumber[0]) : 0
|
|
|
|
|
}
|
|
|
|
|
return Number(match[1])
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-28 03:29:55 -04:00
|
|
|
export function groupMovies(items: NormalizedMovie[]) {
|
2026-05-23 15:08:57 +07:00
|
|
|
const groups = new Map<string, NormalizedMovie>()
|
|
|
|
|
const exactAliases = new Map<string, string>()
|
|
|
|
|
const looseAliases = new Map<string, string>()
|
|
|
|
|
const looseAliasCollisions = new Set<string>()
|
2026-05-21 23:07:59 +07:00
|
|
|
|
2026-05-23 15:08:57 +07:00
|
|
|
function rememberLooseAlias(alias: string, groupKey: string) {
|
|
|
|
|
const existingGroupKey = looseAliases.get(alias)
|
|
|
|
|
if (!existingGroupKey) {
|
|
|
|
|
looseAliases.set(alias, groupKey)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (existingGroupKey !== groupKey) {
|
|
|
|
|
looseAliasCollisions.add(alias)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const movie of items) {
|
|
|
|
|
const aliases = movieTitleAliases(movie)
|
|
|
|
|
const key = aliases[0]
|
|
|
|
|
const fallbackKey = `${movie.source}:${movie.slug || movie.name}`
|
|
|
|
|
const exactKeys = movie.year ? aliases.map((alias) => `${alias}:${movie.year}`) : []
|
|
|
|
|
const existingGroupKey = exactKeys
|
|
|
|
|
.map((alias) => exactAliases.get(alias))
|
|
|
|
|
.find(Boolean)
|
|
|
|
|
|| aliases
|
|
|
|
|
.map((alias) => looseAliasCollisions.has(alias) ? undefined : looseAliases.get(alias))
|
|
|
|
|
.find(Boolean)
|
|
|
|
|
const groupKey = existingGroupKey || (movie.year && key ? `${key}:${movie.year}` : key) || fallbackKey
|
|
|
|
|
const existing = groups.get(groupKey)
|
|
|
|
|
if (!existing) {
|
|
|
|
|
groups.set(groupKey, {
|
|
|
|
|
...movie,
|
|
|
|
|
sources: [movieSourceRef(movie)],
|
|
|
|
|
})
|
|
|
|
|
for (const alias of aliases) {
|
|
|
|
|
if (movie.year) exactAliases.set(`${alias}:${movie.year}`, groupKey)
|
|
|
|
|
rememberLooseAlias(alias, groupKey)
|
|
|
|
|
}
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-28 21:55:39 -04:00
|
|
|
const existingRef = existing.sources?.find((source) => source.source === movie.source && source.slug === movie.slug)
|
|
|
|
|
if (existingRef) {
|
|
|
|
|
existingRef.episode = movie.episode
|
|
|
|
|
existingRef.episodeTotal = movie.episodeTotal
|
|
|
|
|
} else {
|
2026-05-23 15:08:57 +07:00
|
|
|
existing.sources = [...(existing.sources || []), movieSourceRef(movie)]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
existing.thumb ||= movie.thumb
|
|
|
|
|
existing.poster ||= movie.poster
|
|
|
|
|
existing.originName ||= movie.originName
|
|
|
|
|
existing.rating ||= movie.rating
|
|
|
|
|
existing.categories = [...new Set([...existing.categories, ...movie.categories])]
|
|
|
|
|
existing.countries = [...new Set([...existing.countries, ...movie.countries])]
|
|
|
|
|
existing.updatedAt = existing.updatedAt && movie.updatedAt
|
|
|
|
|
? (new Date(existing.updatedAt).getTime() > new Date(movie.updatedAt).getTime() ? existing.updatedAt : movie.updatedAt)
|
|
|
|
|
: existing.updatedAt || movie.updatedAt
|
|
|
|
|
|
2026-07-28 21:55:39 -04:00
|
|
|
const existingCurrent = extractEpisodeNumber(existing.episode)
|
|
|
|
|
const newCurrent = extractEpisodeNumber(movie.episode)
|
|
|
|
|
if (newCurrent > existingCurrent) {
|
|
|
|
|
existing.episode = movie.episode
|
|
|
|
|
}
|
|
|
|
|
const existingTotal = extractEpisodeNumber(existing.episodeTotal)
|
|
|
|
|
const newTotal = extractEpisodeNumber(movie.episodeTotal)
|
|
|
|
|
if (newTotal > existingTotal) {
|
|
|
|
|
existing.episodeTotal = movie.episodeTotal
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-23 15:08:57 +07:00
|
|
|
for (const alias of aliases) {
|
|
|
|
|
if (movie.year) exactAliases.set(`${alias}:${movie.year}`, groupKey)
|
|
|
|
|
rememberLooseAlias(alias, groupKey)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [...groups.values()]
|
2026-05-21 23:07:59 +07:00
|
|
|
}
|
|
|
|
|
|
2026-05-18 14:51:01 +07:00
|
|
|
function normalizeActor(actor: any): NormalizedActor | undefined {
|
|
|
|
|
if (typeof actor === 'string') {
|
|
|
|
|
const name = actor.trim()
|
|
|
|
|
return name ? { name } : undefined
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const name = text(actor?.name || actor?.actor_name || actor?.title).trim()
|
|
|
|
|
if (!name) return undefined
|
|
|
|
|
|
2026-07-30 22:32:44 -04:00
|
|
|
const avatarUrl = joinImage('', actor?.avatar || actor?.image || actor?.thumb_url || actor?.poster_url)
|
2026-05-18 14:51:01 +07:00
|
|
|
return {
|
|
|
|
|
name,
|
|
|
|
|
originalName: text(actor?.original_name || actor?.origin_name || actor?.real_name) || undefined,
|
|
|
|
|
role: text(actor?.role || actor?.character || actor?.as || actor?.cast_name) || undefined,
|
2026-07-30 22:32:44 -04:00
|
|
|
avatar: proxyImageUrl(avatarUrl),
|
2026-05-18 14:51:01 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 20:30:19 +07:00
|
|
|
export function normalizeOphimMovie(movie: any, pathImage = OPHIM_IMAGE): NormalizedMovie {
|
2026-07-30 22:32:44 -04:00
|
|
|
const thumbUrl = joinOphimImage(pathImage, movie?.thumb_url)
|
|
|
|
|
const posterUrl = joinOphimImage(pathImage, movie?.poster_url)
|
2026-05-17 20:30:19 +07:00
|
|
|
return {
|
|
|
|
|
id: `ophim:${movie?._id ?? movie?.slug}`,
|
|
|
|
|
source: 'ophim',
|
|
|
|
|
name: text(movie?.name, 'Chưa có tên'),
|
|
|
|
|
originName: text(movie?.origin_name),
|
|
|
|
|
slug: text(movie?.slug),
|
2026-07-30 22:32:44 -04:00
|
|
|
thumb: proxyImageUrl(thumbUrl),
|
|
|
|
|
poster: proxyImageUrl(posterUrl),
|
2026-05-17 20:30:19 +07:00
|
|
|
year: Number(movie?.year) || undefined,
|
|
|
|
|
time: text(movie?.time),
|
|
|
|
|
episode: text(movie?.episode_current),
|
2026-07-23 02:56:24 -04:00
|
|
|
episodeTotal: movie?.episode_total ? String(movie.episode_total).trim() || undefined : undefined,
|
2026-05-17 20:30:19 +07:00
|
|
|
quality: text(movie?.quality),
|
|
|
|
|
lang: text(movie?.lang),
|
|
|
|
|
type: text(movie?.type),
|
|
|
|
|
rating: Number(movie?.tmdb?.vote_average || movie?.imdb?.vote_average) || undefined,
|
2026-05-22 13:20:09 +07:00
|
|
|
updatedAt: text(movie?.modified?.time || movie?.created),
|
2026-05-17 20:30:19 +07:00
|
|
|
categories: toArray(movie?.category).map((item: any) => text(item?.name)).filter(Boolean),
|
|
|
|
|
countries: toArray(movie?.country).map((item: any) => text(item?.name)).filter(Boolean),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-21 23:07:59 +07:00
|
|
|
export function normalizeKkphimMovie(movie: any, pathImage = KKPHIM_IMAGE): NormalizedMovie {
|
2026-07-30 22:32:44 -04:00
|
|
|
const thumbUrl = joinKkphimImage(pathImage, movie?.thumb_url)
|
|
|
|
|
const posterUrl = joinKkphimImage(pathImage, movie?.poster_url)
|
2026-05-21 23:07:59 +07:00
|
|
|
return {
|
|
|
|
|
id: `kkphim:${movie?._id ?? movie?.slug}`,
|
|
|
|
|
source: 'kkphim',
|
2026-07-30 22:32:44 -04:00
|
|
|
name: text(movie?.name, 'Chưa có tên'),
|
2026-05-21 23:07:59 +07:00
|
|
|
originName: text(movie?.origin_name),
|
|
|
|
|
slug: text(movie?.slug),
|
2026-07-30 22:32:44 -04:00
|
|
|
thumb: proxyImageUrl(thumbUrl),
|
|
|
|
|
poster: proxyImageUrl(posterUrl),
|
2026-05-21 23:07:59 +07:00
|
|
|
year: Number(movie?.year) || undefined,
|
|
|
|
|
time: text(movie?.time),
|
|
|
|
|
episode: text(movie?.episode_current),
|
2026-07-23 02:56:24 -04:00
|
|
|
episodeTotal: movie?.episode_total != null ? String(movie.episode_total).trim() || undefined : undefined,
|
2026-05-21 23:07:59 +07:00
|
|
|
quality: text(movie?.quality),
|
|
|
|
|
lang: text(movie?.lang),
|
|
|
|
|
type: text(movie?.type),
|
|
|
|
|
rating: Number(movie?.tmdb?.vote_average || movie?.imdb?.vote_average) || undefined,
|
2026-05-22 13:20:09 +07:00
|
|
|
updatedAt: text(movie?.modified?.time || movie?.created),
|
2026-05-21 23:07:59 +07:00
|
|
|
categories: toArray(movie?.category).map((item: any) => text(item?.name)).filter(Boolean),
|
|
|
|
|
countries: toArray(movie?.country).map((item: any) => text(item?.name)).filter(Boolean),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 20:30:19 +07:00
|
|
|
function normalizeNguoncMovie(movie: any): NormalizedMovie {
|
|
|
|
|
const image = movie?.thumb_url || movie?.poster_url || movie?.image || movie?.thumbnail
|
2026-07-30 22:32:44 -04:00
|
|
|
const thumbUrl = joinImage('', image)
|
|
|
|
|
const posterUrl = joinImage('', movie?.poster_url)
|
2026-05-17 20:30:19 +07:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
id: `nguonc:${movie?._id ?? movie?.slug}`,
|
|
|
|
|
source: 'nguonc',
|
|
|
|
|
name: text(movie?.name || movie?.title, 'Chưa có tên'),
|
|
|
|
|
originName: text(movie?.original_name || movie?.origin_name),
|
|
|
|
|
slug: text(movie?.slug),
|
2026-07-30 22:32:44 -04:00
|
|
|
thumb: proxyImageUrl(thumbUrl),
|
|
|
|
|
poster: proxyImageUrl(posterUrl),
|
2026-05-17 20:30:19 +07:00
|
|
|
year: Number(movie?.year || movie?.release_year) || undefined,
|
|
|
|
|
time: text(movie?.time || movie?.duration),
|
|
|
|
|
episode: text(movie?.episode_current || movie?.current_episode),
|
2026-07-23 02:56:24 -04:00
|
|
|
episodeTotal: (movie?.episode_total || movie?.total_episodes) != null
|
|
|
|
|
? String(movie.episode_total || movie.total_episodes).trim() || undefined
|
|
|
|
|
: undefined,
|
2026-05-17 20:30:19 +07:00
|
|
|
quality: text(movie?.quality),
|
|
|
|
|
lang: text(movie?.language || movie?.lang),
|
|
|
|
|
type: text(movie?.type),
|
|
|
|
|
rating: Number(movie?.rating) || undefined,
|
2026-05-22 13:20:09 +07:00
|
|
|
updatedAt: text(movie?.modified || movie?.created),
|
2026-05-17 20:30:19 +07:00
|
|
|
categories: toArray(movie?.category || movie?.categories).map((item: any) => text(item?.name ?? item)).filter(Boolean),
|
|
|
|
|
countries: toArray(movie?.country || movie?.countries).map((item: any) => text(item?.name ?? item)).filter(Boolean),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function requestJson<T>(url: string): Promise<T> {
|
|
|
|
|
return await $fetch<T>(url, {
|
|
|
|
|
headers: {
|
|
|
|
|
accept: 'application/json',
|
2026-05-24 22:35:13 +07:00
|
|
|
'user-agent': 'CineK/1.0',
|
2026-05-17 20:30:19 +07:00
|
|
|
},
|
|
|
|
|
retry: 0,
|
|
|
|
|
timeout: 12000,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getOphimKoreanMovies(page: number, keyword = '') {
|
|
|
|
|
const url = keyword
|
|
|
|
|
? new URL('/v1/api/tim-kiem', OPHIM_BASE)
|
|
|
|
|
: new URL('/v1/api/quoc-gia/han-quoc', OPHIM_BASE)
|
|
|
|
|
|
|
|
|
|
url.searchParams.set('page', String(page))
|
|
|
|
|
if (keyword) {
|
|
|
|
|
url.searchParams.set('keyword', keyword)
|
|
|
|
|
url.searchParams.set('country', 'han-quoc')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const json: any = await requestJson(url.toString())
|
|
|
|
|
const data = json?.data ?? json
|
|
|
|
|
const imageBase = data?.APP_DOMAIN_CDN_IMAGE || data?.pathImage || OPHIM_IMAGE
|
|
|
|
|
const items = toArray(data?.items ?? json?.items)
|
|
|
|
|
.filter(includesKorea)
|
|
|
|
|
.map((item) => normalizeOphimMovie(item, imageBase))
|
|
|
|
|
.filter((movie) => movie.slug)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
items,
|
|
|
|
|
pagination: data?.params?.pagination ?? data?.pagination ?? json?.pagination ?? {},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getNguoncKoreanMovies(page: number, keyword = '') {
|
|
|
|
|
const url = keyword
|
|
|
|
|
? new URL('/api/films/search', NGUONC_BASE)
|
|
|
|
|
: new URL('/api/films/quoc-gia/han-quoc', NGUONC_BASE)
|
|
|
|
|
|
|
|
|
|
if (keyword) {
|
|
|
|
|
url.searchParams.set('keyword', keyword)
|
|
|
|
|
} else {
|
|
|
|
|
url.searchParams.set('page', String(page))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const json: any = await requestJson(url.toString())
|
|
|
|
|
const data = json?.data ?? json
|
|
|
|
|
const items = toArray(data?.items ?? data?.films ?? json?.items)
|
|
|
|
|
.filter(includesKorea)
|
|
|
|
|
.map(normalizeNguoncMovie)
|
|
|
|
|
.filter((movie) => movie.slug)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
items,
|
|
|
|
|
pagination: data?.pagination ?? json?.pagination ?? {},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-21 23:07:59 +07:00
|
|
|
export async function getKkphimKoreanMovies(page: number, keyword = '') {
|
|
|
|
|
const url = keyword
|
|
|
|
|
? new URL('/v1/api/tim-kiem', KKPHIM_BASE)
|
|
|
|
|
: new URL('/v1/api/quoc-gia/han-quoc', KKPHIM_BASE)
|
2026-05-17 20:30:19 +07:00
|
|
|
|
2026-05-21 23:07:59 +07:00
|
|
|
url.searchParams.set('page', String(page))
|
|
|
|
|
if (keyword) {
|
|
|
|
|
url.searchParams.set('keyword', keyword)
|
|
|
|
|
url.searchParams.set('country', 'han-quoc')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const json: any = await requestJson(url.toString())
|
|
|
|
|
const data = json?.data ?? json
|
|
|
|
|
const imageBase = data?.APP_DOMAIN_CDN_IMAGE || data?.pathImage || KKPHIM_IMAGE
|
|
|
|
|
const items = toArray(data?.items ?? json?.items)
|
|
|
|
|
.filter(includesKorea)
|
|
|
|
|
.map((item) => normalizeKkphimMovie(item, imageBase))
|
|
|
|
|
.filter((movie) => movie.slug)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
items,
|
|
|
|
|
pagination: data?.params?.pagination ?? data?.pagination ?? json?.pagination ?? {},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getKoreanMovies(page: number, keyword = '', source: SourceFilter = 'all') {
|
|
|
|
|
const fetchers = {
|
|
|
|
|
ophim: getOphimKoreanMovies,
|
|
|
|
|
nguonc: getNguoncKoreanMovies,
|
|
|
|
|
kkphim: getKkphimKoreanMovies,
|
|
|
|
|
}
|
|
|
|
|
const selectedSources = isSource(source) ? [source] : SOURCE_NAMES
|
|
|
|
|
const results = await Promise.allSettled(
|
|
|
|
|
selectedSources.map((sourceName) => fetchers[sourceName](page, keyword)),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const resultGroups = results.map((result) => result.status === 'fulfilled' ? result.value.items : [])
|
|
|
|
|
const items = source === 'all' ? interleaveMovies(resultGroups) : resultGroups.flat()
|
2026-05-23 15:08:57 +07:00
|
|
|
const unique = groupMovies(items)
|
2026-05-17 20:30:19 +07:00
|
|
|
|
|
|
|
|
const pagination = results.find((result) => result.status === 'fulfilled')?.value.pagination ?? {}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
items: unique,
|
|
|
|
|
page,
|
|
|
|
|
pagination,
|
|
|
|
|
sources: results.map((result, index) => ({
|
2026-05-21 23:07:59 +07:00
|
|
|
name: selectedSources[index],
|
2026-05-17 20:30:19 +07:00
|
|
|
ok: result.status === 'fulfilled',
|
|
|
|
|
})),
|
2026-05-21 23:07:59 +07:00
|
|
|
source,
|
2026-05-17 20:30:19 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getOphimDetail(slug: string): Promise<MovieDetail> {
|
|
|
|
|
const json: any = await requestJson(`${OPHIM_BASE}/v1/api/phim/${slug}`)
|
|
|
|
|
const data = json?.data ?? json
|
|
|
|
|
const movie = data?.item ?? data?.movie ?? json?.movie ?? json?.item
|
|
|
|
|
const imageBase = data?.APP_DOMAIN_CDN_IMAGE || data?.pathImage || OPHIM_IMAGE
|
|
|
|
|
const normalized = normalizeOphimMovie(movie, imageBase)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
...normalized,
|
|
|
|
|
content: stripHtml(movie?.content),
|
2026-05-18 14:51:01 +07:00
|
|
|
actors: toArray(movie?.actor).map(normalizeActor).filter(Boolean) as NormalizedActor[],
|
2026-05-17 20:30:19 +07:00
|
|
|
directors: toArray(movie?.director).map((item) => text(item)).filter(Boolean),
|
|
|
|
|
trailer: text(movie?.trailer_url),
|
|
|
|
|
servers: toArray(data?.episodes ?? json?.episodes ?? movie?.episodes).map((server: any) => ({
|
|
|
|
|
name: text(server?.server_name, 'Server'),
|
2026-05-23 15:08:57 +07:00
|
|
|
source: 'ophim',
|
|
|
|
|
sourceSlug: normalized.slug,
|
2026-05-17 20:30:19 +07:00
|
|
|
episodes: toArray(server?.server_data).map((episode: any) => ({
|
|
|
|
|
name: text(episode?.name, 'Tập phim'),
|
|
|
|
|
slug: text(episode?.slug),
|
|
|
|
|
linkEmbed: text(episode?.link_embed),
|
|
|
|
|
linkM3u8: text(episode?.link_m3u8),
|
|
|
|
|
})),
|
|
|
|
|
})),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getNguoncDetail(slug: string): Promise<MovieDetail> {
|
|
|
|
|
const json: any = await requestJson(`${NGUONC_BASE}/api/film/${slug}`)
|
|
|
|
|
const movie = json?.movie ?? json?.item ?? json?.data?.item ?? json?.data?.movie ?? json
|
|
|
|
|
const normalized = normalizeNguoncMovie(movie)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
...normalized,
|
|
|
|
|
content: stripHtml(movie?.content || movie?.description),
|
2026-05-18 14:51:01 +07:00
|
|
|
actors: toArray(movie?.actor || movie?.actors).map(normalizeActor).filter(Boolean) as NormalizedActor[],
|
2026-05-17 20:30:19 +07:00
|
|
|
directors: toArray(movie?.director || movie?.directors).map((item: any) => text(item?.name ?? item)).filter(Boolean),
|
|
|
|
|
trailer: text(movie?.trailer_url || movie?.trailer),
|
|
|
|
|
servers: toArray(movie?.episodes ?? json?.episodes ?? json?.data?.episodes).map((server: any) => ({
|
|
|
|
|
name: text(server?.server_name || server?.name, 'Server'),
|
2026-05-23 15:08:57 +07:00
|
|
|
source: 'nguonc',
|
|
|
|
|
sourceSlug: normalized.slug,
|
2026-05-17 20:30:19 +07:00
|
|
|
episodes: toArray(server?.server_data ?? server?.items ?? server?.episodes).map((episode: any) => ({
|
|
|
|
|
name: text(episode?.name || episode?.title, 'Tập phim'),
|
|
|
|
|
slug: text(episode?.slug),
|
|
|
|
|
linkEmbed: text(episode?.link_embed || episode?.embed),
|
|
|
|
|
linkM3u8: text(episode?.link_m3u8 || episode?.m3u8),
|
|
|
|
|
})),
|
|
|
|
|
})),
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-21 23:07:59 +07:00
|
|
|
|
|
|
|
|
export async function getKkphimDetail(slug: string): Promise<MovieDetail> {
|
|
|
|
|
const json: any = await requestJson(`${KKPHIM_BASE}/phim/${slug}`)
|
|
|
|
|
const data = json?.data ?? json
|
|
|
|
|
const movie = data?.item ?? data?.movie ?? json?.movie ?? json?.item
|
|
|
|
|
const imageBase = data?.APP_DOMAIN_CDN_IMAGE || data?.pathImage || KKPHIM_IMAGE
|
|
|
|
|
const normalized = normalizeKkphimMovie(movie, imageBase)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
...normalized,
|
|
|
|
|
content: stripHtml(movie?.content),
|
|
|
|
|
actors: toArray(movie?.actor).map(normalizeActor).filter(Boolean) as NormalizedActor[],
|
|
|
|
|
directors: toArray(movie?.director).map((item) => text(item)).filter(Boolean),
|
|
|
|
|
trailer: text(movie?.trailer_url),
|
|
|
|
|
servers: toArray(data?.episodes ?? json?.episodes ?? movie?.episodes).map((server: any) => ({
|
|
|
|
|
name: text(server?.server_name, 'Server'),
|
2026-05-23 15:08:57 +07:00
|
|
|
source: 'kkphim',
|
|
|
|
|
sourceSlug: normalized.slug,
|
2026-05-21 23:07:59 +07:00
|
|
|
episodes: toArray(server?.server_data).map((episode: any) => ({
|
|
|
|
|
name: text(episode?.name, 'Táºp phim'),
|
|
|
|
|
slug: text(episode?.slug),
|
|
|
|
|
linkEmbed: text(episode?.link_embed),
|
|
|
|
|
linkM3u8: text(episode?.link_m3u8),
|
|
|
|
|
})),
|
|
|
|
|
})),
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-23 15:08:57 +07:00
|
|
|
|
2026-05-24 22:35:13 +07:00
|
|
|
export function parseSourceRefs(value: unknown, fallbackSource: unknown, fallbackSlug: string): MovieSourceRef[] {
|
2026-05-23 15:08:57 +07:00
|
|
|
const refs = typeof value === 'string'
|
|
|
|
|
? value.split(',').map((item) => {
|
|
|
|
|
const [source, ...slugParts] = item.split(':')
|
|
|
|
|
const slug = slugParts.join(':')
|
|
|
|
|
return isSource(source) && slug ? { source, slug } : undefined
|
|
|
|
|
}).filter(Boolean) as MovieSourceRef[]
|
|
|
|
|
: []
|
|
|
|
|
|
2026-05-24 22:35:13 +07:00
|
|
|
const uniqueRefs = refs.filter((ref, index, allRefs) =>
|
|
|
|
|
index === allRefs.findIndex((item) => item.source === ref.source && item.slug === ref.slug),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if (uniqueRefs.length) {
|
|
|
|
|
const preferredSource = isSource(fallbackSource) ? fallbackSource : DEFAULT_SOURCE
|
|
|
|
|
return [
|
|
|
|
|
...uniqueRefs.filter((ref) => ref.source === preferredSource),
|
|
|
|
|
...uniqueRefs.filter((ref) => ref.source !== preferredSource),
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const source = isSource(fallbackSource) ? fallbackSource : DEFAULT_SOURCE
|
|
|
|
|
return [{ source, slug: fallbackSlug }]
|
2026-05-23 15:08:57 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getMovieDetailGroup(refs: MovieSourceRef[]): Promise<MovieDetail> {
|
|
|
|
|
const detailBySource = {
|
|
|
|
|
ophim: getOphimDetail,
|
|
|
|
|
nguonc: getNguoncDetail,
|
|
|
|
|
kkphim: getKkphimDetail,
|
|
|
|
|
}
|
|
|
|
|
const uniqueRefs = refs.filter((ref, index, allRefs) =>
|
|
|
|
|
index === allRefs.findIndex((item) => item.source === ref.source && item.slug === ref.slug),
|
|
|
|
|
)
|
|
|
|
|
const results = await Promise.allSettled(
|
|
|
|
|
uniqueRefs.map((ref) => detailBySource[ref.source](ref.slug)),
|
|
|
|
|
)
|
|
|
|
|
const details = results
|
|
|
|
|
.map((result) => result.status === 'fulfilled' ? result.value : null)
|
|
|
|
|
.filter(Boolean) as MovieDetail[]
|
|
|
|
|
|
|
|
|
|
if (!details.length) {
|
|
|
|
|
const firstRef = uniqueRefs[0]
|
|
|
|
|
return await detailBySource[firstRef.source](firstRef.slug)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const primary = details[0]
|
|
|
|
|
const sources = details.map(movieSourceRef)
|
|
|
|
|
const servers = details.flatMap((detail) =>
|
|
|
|
|
detail.servers.map((server, sourceServerIndex) => ({
|
|
|
|
|
...server,
|
|
|
|
|
name: `${detail.source.toUpperCase()} - ${server.name}`,
|
|
|
|
|
source: detail.source,
|
|
|
|
|
sourceSlug: detail.slug,
|
|
|
|
|
sourceServerIndex,
|
|
|
|
|
})),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
...primary,
|
|
|
|
|
sources,
|
|
|
|
|
servers,
|
|
|
|
|
actors: details.find((detail) => detail.actors.length)?.actors || primary.actors,
|
|
|
|
|
directors: details.find((detail) => detail.directors.length)?.directors || primary.directors,
|
|
|
|
|
content: details.find((detail) => detail.content && detail.content.length > primary.content.length)?.content || primary.content,
|
|
|
|
|
}
|
|
|
|
|
}
|