mirror of
https://github.com/ngthanhvu/kr-phim.git
synced 2026-08-10 16:07:47 +00:00
feat: implement admin interface for managing custom movie sources and servers with new API endpoint and database schema updates
This commit is contained in:
@@ -48,7 +48,7 @@ defineExpose({ goTo })
|
|||||||
class="absolute inset-0 h-full w-full" @slide-change="onSlideChange">
|
class="absolute inset-0 h-full w-full" @slide-change="onSlideChange">
|
||||||
<SwiperSlide v-for="(slide, index) in props.slides" :key="`${slide.source}-${slide.slug}-${index}`"
|
<SwiperSlide v-for="(slide, index) in props.slides" :key="`${slide.source}-${slide.slug}-${index}`"
|
||||||
class="h-full w-full">
|
class="h-full w-full">
|
||||||
<img :src="slide.thumb || slide.poster" :alt="slide.name"
|
<img :src="slide.poster || slide.thumb" :alt="slide.name"
|
||||||
class="absolute inset-[-1px] h-[calc(100%+2px)] w-[calc(100%+2px)] max-w-none object-cover object-top lg:object-[72%_center]">
|
class="absolute inset-[-1px] h-[calc(100%+2px)] w-[calc(100%+2px)] max-w-none object-cover object-top lg:object-[72%_center]">
|
||||||
</SwiperSlide>
|
</SwiperSlide>
|
||||||
</Swiper>
|
</Swiper>
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ onBeforeUnmount(() => {
|
|||||||
@mouseenter="showPreview" @mouseleave="scheduleHide" @focus="showPreview" @blur="scheduleHide">
|
@mouseenter="showPreview" @mouseleave="scheduleHide" @focus="showPreview" @blur="scheduleHide">
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0 overflow-hidden rounded-md bg-slate-900 shadow-xl shadow-black/25 ring-1 ring-white/10 transition duration-300 group-hover:ring-yellow-300/60">
|
class="absolute inset-0 overflow-hidden rounded-md bg-slate-900 shadow-xl shadow-black/25 ring-1 ring-white/10 transition duration-300 group-hover:ring-yellow-300/60">
|
||||||
<img :src="movie.poster || movie.thumb" :alt="movie.name"
|
<img :src="movie.thumb || movie.poster" :alt="movie.name"
|
||||||
class="h-full w-full object-cover transition duration-500 group-hover:scale-105">
|
class="h-full w-full object-cover transition duration-500 group-hover:scale-105">
|
||||||
<div class="absolute inset-x-0 bottom-0 bg-linear-to-t from-slate-950 via-slate-950/70 to-transparent p-3">
|
<div class="absolute inset-x-0 bottom-0 bg-linear-to-t from-slate-950 via-slate-950/70 to-transparent p-3">
|
||||||
<p class="line-clamp-2 text-sm font-bold leading-snug text-white">{{ movie.name }}</p>
|
<p class="line-clamp-2 text-sm font-bold leading-snug text-white">{{ movie.name }}</p>
|
||||||
@@ -121,7 +121,7 @@ onBeforeUnmount(() => {
|
|||||||
class="fixed z-90 hidden origin-top-left overflow-hidden rounded-xl bg-[#07111d] text-white shadow-2xl shadow-black/60 ring-1 ring-yellow-300/45 sm:block"
|
class="fixed z-90 hidden origin-top-left overflow-hidden rounded-xl bg-[#07111d] text-white shadow-2xl shadow-black/60 ring-1 ring-yellow-300/45 sm:block"
|
||||||
:style="previewStyle" @mouseenter="keepPreview" @mouseleave="scheduleHide">
|
:style="previewStyle" @mouseenter="keepPreview" @mouseleave="scheduleHide">
|
||||||
<div class="relative h-44 overflow-hidden">
|
<div class="relative h-44 overflow-hidden">
|
||||||
<img :src="movie.thumb || movie.poster" :alt="movie.name" class="h-full w-full object-cover object-top">
|
<img :src="movie.poster || movie.thumb" :alt="movie.name" class="h-full w-full object-cover object-top">
|
||||||
<div class="absolute inset-0 bg-linear-to-t from-[#07111d] via-[#07111d]/30 to-transparent" />
|
<div class="absolute inset-0 bg-linear-to-t from-[#07111d] via-[#07111d]/30 to-transparent" />
|
||||||
<div class="absolute inset-x-0 bottom-0 p-4">
|
<div class="absolute inset-x-0 bottom-0 p-4">
|
||||||
<h3 class="line-clamp-2 text-xl font-black leading-tight text-white">
|
<h3 class="line-clamp-2 text-xl font-black leading-tight text-white">
|
||||||
|
|||||||
+528
-107
@@ -7,15 +7,55 @@ const route = useRoute()
|
|||||||
const movieId = Number(route.params.id)
|
const movieId = Number(route.params.id)
|
||||||
|
|
||||||
const { data: movie, refresh } = await useFetch(`/api/admin/movies/${movieId}`)
|
const { data: movie, refresh } = await useFetch(`/api/admin/movies/${movieId}`)
|
||||||
|
const { data: sourceData } = await useFetch(`/api/admin/movies/${movieId}/sources`, { lazy: true, default: () => ({ movie: null, sources: [] }) })
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
title: computed(() => movie.value ? `Chỉnh sửa: ${movie.value.name} - CineK Admin` : 'Chỉnh sửa phim - CineK Admin'),
|
title: computed(() => movie.value ? `Chỉnh sửa: ${movie.value.name} - CineK Admin` : 'Chỉnh sửa phim - CineK Admin'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
interface SourceEpisode {
|
||||||
|
name: string
|
||||||
|
slug?: string
|
||||||
|
linkEmbed?: string
|
||||||
|
linkM3u8?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SourceServer {
|
||||||
|
name: string
|
||||||
|
source: string
|
||||||
|
sourceSlug: string
|
||||||
|
episodes: SourceEpisode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SourceInfo {
|
||||||
|
source: string
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
content: string
|
||||||
|
actors: { name: string, originalName?: string, role?: string, avatar?: string }[]
|
||||||
|
servers: SourceServer[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomEpisode {
|
||||||
|
name: string
|
||||||
|
linkEmbed: string
|
||||||
|
linkM3u8: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomServer {
|
||||||
|
name: string
|
||||||
|
episodes: CustomEpisode[]
|
||||||
|
}
|
||||||
|
|
||||||
const customPoster = ref('')
|
const customPoster = ref('')
|
||||||
const customThumb = ref('')
|
const customThumb = ref('')
|
||||||
const customContent = ref('')
|
const customContent = ref('')
|
||||||
const customEpisodes = ref<{ name: string, linkEmbed: string, linkM3u8: string }[]>([])
|
const customServers = ref<CustomServer[]>([])
|
||||||
|
const customActors = ref<{ name: string, originalName: string, role: string, avatar: string }[]>([])
|
||||||
|
const expandedSources = ref<Record<string, boolean>>({})
|
||||||
|
const selectedTargetServer = ref(0)
|
||||||
|
const collapsedServers = ref<Record<number, boolean>>({})
|
||||||
|
const expandedLinks = ref<Record<string, boolean>>({})
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const saved = ref(false)
|
const saved = ref(false)
|
||||||
|
|
||||||
@@ -24,18 +64,154 @@ watch(movie, (m) => {
|
|||||||
customPoster.value = m.customPoster || ''
|
customPoster.value = m.customPoster || ''
|
||||||
customThumb.value = m.customThumb || ''
|
customThumb.value = m.customThumb || ''
|
||||||
customContent.value = m.customContent || ''
|
customContent.value = m.customContent || ''
|
||||||
customEpisodes.value = m.customEpisodes?.length
|
customActors.value = m.actors?.length
|
||||||
? m.customEpisodes.map((ep: any) => ({ name: ep.name || '', linkEmbed: ep.linkEmbed || '', linkM3u8: ep.linkM3u8 || '' }))
|
? m.actors.map((a: any) => ({ name: a.name || '', originalName: a.originalName || '', role: a.role || '', avatar: a.avatar || '' }))
|
||||||
|
: []
|
||||||
|
customServers.value = m.customServers?.length
|
||||||
|
? m.customServers.map((server: any) => ({
|
||||||
|
name: server.name || '',
|
||||||
|
episodes: (server.episodes || []).map((ep: any) => ({ name: ep.name || '', linkEmbed: ep.linkEmbed || '', linkM3u8: ep.linkM3u8 || '' })),
|
||||||
|
}))
|
||||||
: []
|
: []
|
||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
function addEpisode() {
|
const availableEpisodes = computed(() => {
|
||||||
customEpisodes.value.push({ name: '', linkEmbed: '', linkM3u8: '' })
|
const list: { key: string, label: string, linkEmbed?: string, linkM3u8?: string }[] = []
|
||||||
|
const sources = (sourceData.value?.sources || []) as SourceInfo[]
|
||||||
|
sources.forEach((source) => {
|
||||||
|
source.servers.forEach((server) => {
|
||||||
|
server.episodes.forEach((ep, index) => {
|
||||||
|
list.push({
|
||||||
|
key: `${source.source}::${server.name}::${index}::${ep.name}`,
|
||||||
|
label: `[${source.source.toUpperCase()}] ${server.name} - ${ep.name || `Tập ${index + 1}`}`,
|
||||||
|
linkEmbed: ep.linkEmbed,
|
||||||
|
linkM3u8: ep.linkM3u8,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return list
|
||||||
|
})
|
||||||
|
|
||||||
|
const apiContent = computed(() => {
|
||||||
|
const sources = (sourceData.value?.sources || []) as SourceInfo[]
|
||||||
|
const order = ['nguonc', 'ophim', 'kkphim']
|
||||||
|
for (const name of order) {
|
||||||
|
const found = sources.find((s) => s.source === name)
|
||||||
|
if (found?.content?.trim()) return found.content.trim()
|
||||||
|
}
|
||||||
|
return sources.find((s) => s.content?.trim())?.content?.trim() || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const apiActors = computed(() => {
|
||||||
|
const sources = (sourceData.value?.sources || []) as SourceInfo[]
|
||||||
|
const order = ['nguonc', 'ophim', 'kkphim']
|
||||||
|
for (const name of order) {
|
||||||
|
const found = sources.find((s) => s.source === name)
|
||||||
|
if (found?.actors?.length) return found.actors
|
||||||
|
}
|
||||||
|
return sources.find((s) => s.actors?.length)?.actors || []
|
||||||
|
})
|
||||||
|
|
||||||
|
function addServer() {
|
||||||
|
customServers.value.push({ name: '', episodes: [] })
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeEpisode(index: number) {
|
function removeServer(serverIndex: number) {
|
||||||
customEpisodes.value.splice(index, 1)
|
customServers.value.splice(serverIndex, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addServerEpisode(serverIndex: number) {
|
||||||
|
customServers.value[serverIndex].episodes.push({ name: '', linkEmbed: '', linkM3u8: '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeServerEpisode(serverIndex: number, episodeIndex: number) {
|
||||||
|
customServers.value[serverIndex].episodes.splice(episodeIndex, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySourceLink(serverIndex: number, episodeIndex: number, key: string) {
|
||||||
|
const item = availableEpisodes.value.find((ep) => ep.key === key)
|
||||||
|
if (!item) return
|
||||||
|
const ep = customServers.value[serverIndex].episodes[episodeIndex]
|
||||||
|
if (ep) {
|
||||||
|
ep.linkEmbed = item.linkEmbed || ''
|
||||||
|
ep.linkM3u8 = item.linkM3u8 || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSourceLinkChange(serverIndex: number, episodeIndex: number, event: Event) {
|
||||||
|
const value = (event.target as HTMLSelectElement).value
|
||||||
|
applySourceLink(serverIndex, episodeIndex, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSource(source: string) {
|
||||||
|
expandedSources.value[source] = !expandedSources.value[source]
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleServer(index: number) {
|
||||||
|
collapsedServers.value[index] = !collapsedServers.value[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleLinks(key: string) {
|
||||||
|
expandedLinks.value[key] = !expandedLinks.value[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
function useApiContent() {
|
||||||
|
if (apiContent.value) customContent.value = apiContent.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentRows = computed(() => {
|
||||||
|
if (!customContent.value) return 6
|
||||||
|
const lines = customContent.value.split('\n').length
|
||||||
|
const estimatedWrappedLines = Math.ceil(customContent.value.length / 38)
|
||||||
|
return Math.max(6, Math.max(lines, estimatedWrappedLines))
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => customServers.value.length, (length) => {
|
||||||
|
if (selectedTargetServer.value >= length) {
|
||||||
|
selectedTargetServer.value = Math.max(0, length - 1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function ensureTargetServer() {
|
||||||
|
if (!customServers.value.length) {
|
||||||
|
customServers.value.push({ name: 'Server 1', episodes: [] })
|
||||||
|
}
|
||||||
|
if (selectedTargetServer.value >= customServers.value.length) {
|
||||||
|
selectedTargetServer.value = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function quickAddEpisode(sourceIndex: number, serverIndex: number, episodeIndex: number) {
|
||||||
|
const sources = (sourceData.value?.sources || []) as SourceInfo[]
|
||||||
|
const source = sources[sourceIndex]
|
||||||
|
const server = source?.servers?.[serverIndex]
|
||||||
|
const ep = server?.episodes?.[episodeIndex]
|
||||||
|
if (!ep) return
|
||||||
|
ensureTargetServer()
|
||||||
|
const target = customServers.value[selectedTargetServer.value]
|
||||||
|
target.episodes.push({
|
||||||
|
name: ep.name || '',
|
||||||
|
linkEmbed: ep.linkEmbed || '',
|
||||||
|
linkM3u8: ep.linkM3u8 || '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function quickAddAllEpisodes(sourceIndex: number, serverIndex: number) {
|
||||||
|
const sources = (sourceData.value?.sources || []) as SourceInfo[]
|
||||||
|
const source = sources[sourceIndex]
|
||||||
|
const server = source?.servers?.[serverIndex]
|
||||||
|
if (!server?.episodes?.length) return
|
||||||
|
ensureTargetServer()
|
||||||
|
const target = customServers.value[selectedTargetServer.value]
|
||||||
|
server.episodes.forEach((ep) => {
|
||||||
|
target.episodes.push({
|
||||||
|
name: ep.name || '',
|
||||||
|
linkEmbed: ep.linkEmbed || '',
|
||||||
|
linkM3u8: ep.linkM3u8 || '',
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
@@ -48,7 +224,8 @@ async function handleSave() {
|
|||||||
customPoster: customPoster.value,
|
customPoster: customPoster.value,
|
||||||
customThumb: customThumb.value,
|
customThumb: customThumb.value,
|
||||||
customContent: customContent.value,
|
customContent: customContent.value,
|
||||||
customEpisodes: customEpisodes.value,
|
actors: customActors.value,
|
||||||
|
customServers: customServers.value,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
saved.value = true
|
saved.value = true
|
||||||
@@ -66,6 +243,25 @@ function clearField(field: 'customPoster' | 'customThumb' | 'customContent') {
|
|||||||
if (field === 'customThumb') customThumb.value = ''
|
if (field === 'customThumb') customThumb.value = ''
|
||||||
if (field === 'customContent') customContent.value = ''
|
if (field === 'customContent') customContent.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addActor() {
|
||||||
|
customActors.value.push({ name: '', originalName: '', role: '', avatar: '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeActor(index: number) {
|
||||||
|
customActors.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function useApiActors() {
|
||||||
|
if (!apiActors.value.length) return
|
||||||
|
const existingNames = new Set(customActors.value.map((a) => a.name.trim().toLowerCase()))
|
||||||
|
for (const a of apiActors.value) {
|
||||||
|
if (!existingNames.has(a.name.trim().toLowerCase())) {
|
||||||
|
customActors.value.push({ name: a.name, originalName: a.originalName || '', role: a.role || '', avatar: a.avatar || '' })
|
||||||
|
existingNames.add(a.name.trim().toLowerCase())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -104,164 +300,389 @@ function clearField(field: 'customPoster' | 'customThumb' | 'customContent') {
|
|||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]">
|
<div class="grid gap-6 lg:grid-cols-12 items-start">
|
||||||
<div class="space-y-6">
|
<!-- LEFT COLUMN: Main Content & Episode Management (7 cols) -->
|
||||||
|
<div class="space-y-6 lg:col-span-7">
|
||||||
|
<!-- Mô tả phim -->
|
||||||
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5">
|
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5">
|
||||||
<div class="mb-4 flex items-center gap-3">
|
<div class="mb-4 flex items-center justify-between gap-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
<div class="grid size-9 place-items-center rounded-lg bg-blue-400/10">
|
<div class="grid size-9 place-items-center rounded-lg bg-blue-400/10">
|
||||||
<AppIcon name="type" class="size-4 text-blue-400" />
|
<AppIcon name="type" class="size-4 text-blue-400" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-base font-black text-white">Mô tả phim</h2>
|
<h2 class="text-base font-black text-white">Mô tả phim</h2>
|
||||||
<p class="text-xs text-slate-400">Để trống để dùng mô tả từ API nguồn</p>
|
<p class="text-xs text-slate-400">
|
||||||
|
{{ customContent ? 'Mô tả tuỳ chỉnh (Đã áp dụng)' : 'Mô tả mặc định từ API' }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<textarea v-model="customContent" rows="8" placeholder="Nhập mô tả phim..."
|
<button v-if="apiContent && !customContent" type="button"
|
||||||
class="w-full rounded-lg border border-white/10 bg-white/5 px-4 py-3 text-sm leading-6 text-white outline-none placeholder:text-slate-500 focus:border-yellow-400/50" />
|
class="inline-flex h-7 items-center gap-1 rounded-lg bg-yellow-400/10 px-2.5 text-xs font-bold text-yellow-400 transition hover:bg-yellow-400/20"
|
||||||
<button v-if="customContent" type="button"
|
@click="useApiContent">
|
||||||
class="mt-2 text-xs font-semibold text-red-400 transition hover:text-red-300"
|
<AppIcon name="pen" class="size-3" />
|
||||||
|
Dùng mô tả này
|
||||||
|
</button>
|
||||||
|
<button v-else-if="customContent" type="button"
|
||||||
|
class="text-xs font-semibold text-red-400 transition hover:text-red-300"
|
||||||
@click="clearField('customContent')">
|
@click="clearField('customContent')">
|
||||||
Xoá mô tả tuỳ chỉnh
|
Quay lại mặc định
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- State 1: Chưa bấm dùng mô tả tuỳ chỉnh -> Hiển thị mô tả mặc định API -->
|
||||||
|
<div v-if="!customContent" class="rounded-lg border border-white/10 bg-white/5 p-3.5">
|
||||||
|
<div class="mb-2 flex items-center justify-between">
|
||||||
|
<span class="inline-flex items-center gap-1.5 rounded-full bg-blue-400/10 px-2.5 py-0.5 text-[10px] font-black uppercase text-blue-400">
|
||||||
|
<span class="size-1.5 rounded-full bg-blue-400"></span>
|
||||||
|
Nội dung từ API nguồn
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs leading-6 text-slate-300 whitespace-pre-line">
|
||||||
|
{{ apiContent || 'Chưa có mô tả từ nguồn API.' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- State 2: Đã bấm dùng mô tả / chỉnh sửa -> Khung nhập full ra toàn bộ chiều cao -->
|
||||||
|
<div v-else>
|
||||||
|
<textarea v-model="customContent" :rows="contentRows" placeholder="Nhập mô tả phim..."
|
||||||
|
style="field-sizing: content; min-height: 140px;"
|
||||||
|
class="w-full rounded-lg border border-yellow-400/40 bg-white/5 p-3 text-xs leading-6 text-white outline-none placeholder:text-slate-500 focus:border-yellow-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Diễn viên -->
|
||||||
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5">
|
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5">
|
||||||
<div class="mb-4 flex items-center gap-3">
|
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
<div class="grid size-9 place-items-center rounded-lg bg-amber-400/10">
|
<div class="flex items-center gap-3">
|
||||||
<AppIcon name="film" class="size-4 text-amber-400" />
|
<div class="grid size-9 place-items-center rounded-lg bg-pink-400/10">
|
||||||
|
<AppIcon name="users" class="size-4 text-pink-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-base font-black text-white">Diễn viên</h2>
|
||||||
|
<p class="text-xs text-slate-400">Danh sách diễn viên của phim ({{ customActors.length }})</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button v-if="apiActors.length" type="button"
|
||||||
|
class="inline-flex h-8 items-center gap-1.5 rounded-lg bg-yellow-400/10 px-3 text-xs font-bold text-yellow-400 transition hover:bg-yellow-400/20"
|
||||||
|
@click="useApiActors">
|
||||||
|
<AppIcon name="pen" class="size-3.5" />
|
||||||
|
Import từ API ({{ apiActors.length }})
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
class="inline-flex h-8 items-center gap-1.5 rounded-lg bg-pink-500/20 px-3 text-xs font-bold text-pink-400 transition hover:bg-pink-500/30"
|
||||||
|
@click="addActor">
|
||||||
|
<AppIcon name="plus" class="size-3.5" />
|
||||||
|
Thêm diễn viên
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="customActors.length" class="max-h-64 overflow-y-auto space-y-1.5 pr-1">
|
||||||
|
<div v-for="(actor, index) in customActors" :key="index"
|
||||||
|
class="flex items-center gap-1.5 rounded-lg border border-white/10 bg-white/5 p-1.5">
|
||||||
|
<img v-if="actor.avatar" :src="actor.avatar" :alt="actor.name"
|
||||||
|
class="size-6 shrink-0 rounded-full object-cover ring-1 ring-white/10">
|
||||||
|
<div v-else class="grid size-6 shrink-0 place-items-center rounded-full bg-pink-400/10 text-[10px] font-black text-pink-400">
|
||||||
|
{{ (actor.name || '?')[0].toUpperCase() }}
|
||||||
|
</div>
|
||||||
|
<input v-model="actor.name" type="text" placeholder="Tên diễn viên"
|
||||||
|
class="h-7 w-28 shrink-0 rounded border border-white/10 bg-white/5 px-2 text-xs text-white placeholder:text-slate-500 outline-none focus:border-yellow-400/50 sm:w-32">
|
||||||
|
<input v-model="actor.originalName" type="text" placeholder="Tên gốc"
|
||||||
|
class="h-7 w-24 shrink-0 rounded border border-white/10 bg-white/5 px-2 text-xs text-white placeholder:text-slate-500 outline-none focus:border-yellow-400/50 sm:w-28">
|
||||||
|
<input v-model="actor.role" type="text" placeholder="Vai diễn"
|
||||||
|
class="h-7 w-20 shrink-0 rounded border border-white/10 bg-white/5 px-2 text-xs text-white placeholder:text-slate-500 outline-none focus:border-yellow-400/50 sm:w-24">
|
||||||
|
<input v-model="actor.avatar" type="url" placeholder="Avatar URL"
|
||||||
|
class="h-7 min-w-0 flex-1 rounded border border-white/10 bg-white/5 px-2 text-xs text-white placeholder:text-slate-500 outline-none focus:border-yellow-400/50">
|
||||||
|
<button type="button"
|
||||||
|
class="grid size-7 shrink-0 place-items-center rounded text-red-400 transition hover:bg-red-400/10"
|
||||||
|
@click="removeActor(index)">
|
||||||
|
<AppIcon name="trash" class="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="rounded-lg border border-dashed border-white/10 p-6 text-center">
|
||||||
|
<p class="text-sm text-slate-500">Chưa có diễn viên nào. Bấm "Thêm diễn viên" hoặc "Import từ API" để bắt đầu.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Server & tập phim -->
|
||||||
|
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5">
|
||||||
|
<div class="mb-4 flex items-center justify-between gap-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="grid size-9 place-items-center rounded-lg bg-emerald-400/10">
|
||||||
|
<AppIcon name="server" class="size-4 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-base font-black text-white">Server & tập phim</h3>
|
||||||
|
<p class="text-xs text-slate-400">Danh sách server phát hiển thị trên website</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
|
||||||
<h2 class="text-base font-black text-white">Tập phim tuỳ chỉnh</h2>
|
|
||||||
<p class="text-xs text-slate-400">Thêm tập phim thủ công khi API thiếu tập hoặc link bị die. Để trống tất cả để dùng từ API.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="inline-flex h-8 items-center gap-1.5 rounded-lg bg-white/10 px-3 text-xs font-semibold text-white transition hover:bg-white/20"
|
class="inline-flex h-8 items-center gap-1.5 rounded-lg bg-emerald-500/20 px-3 text-xs font-bold text-emerald-400 transition hover:bg-emerald-500/30"
|
||||||
@click="addEpisode">
|
@click="addServer">
|
||||||
|
<AppIcon name="plus" class="size-3.5" />
|
||||||
|
Thêm server
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="customServers.length" class="space-y-3">
|
||||||
|
<div v-for="(server, serverIndex) in customServers" :key="serverIndex"
|
||||||
|
class="rounded-lg border border-white/10 bg-white/5">
|
||||||
|
<div class="flex items-center justify-between gap-3 p-3">
|
||||||
|
<button type="button" class="flex min-w-0 flex-1 items-center gap-2 text-left"
|
||||||
|
@click="toggleServer(serverIndex)">
|
||||||
|
<AppIcon name="chevron-down" class="size-4 shrink-0 text-slate-400 transition"
|
||||||
|
:class="collapsedServers[serverIndex] ? '-rotate-90' : ''" />
|
||||||
|
<span class="truncate text-sm font-bold text-white">{{ server.name || `Server ${serverIndex + 1}` }}</span>
|
||||||
|
<span class="shrink-0 rounded-full bg-white/10 px-2 py-0.5 text-[10px] font-semibold text-slate-300">
|
||||||
|
{{ server.episodes.length }} tập
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
class="grid size-7 shrink-0 place-items-center rounded-lg text-red-400 transition hover:bg-red-400/10"
|
||||||
|
@click="removeServer(serverIndex)">
|
||||||
|
<AppIcon name="trash" class="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!collapsedServers[serverIndex]" class="border-t border-white/10 p-3">
|
||||||
|
<input v-model="server.name" type="text" placeholder="Tên server (vd: Vietsub, Thuyết minh...)"
|
||||||
|
class="mb-3 h-9 w-full rounded-md border border-white/10 bg-white/5 px-3 text-sm font-bold text-white placeholder:text-slate-500 outline-none focus:border-yellow-400/50">
|
||||||
|
|
||||||
|
<div v-if="server.episodes.length" class="space-y-1.5">
|
||||||
|
<div v-for="(ep, episodeIndex) in server.episodes" :key="episodeIndex"
|
||||||
|
class="rounded-md border border-white/10 bg-white/5">
|
||||||
|
<div class="flex items-center gap-2 p-2">
|
||||||
|
<span class="w-7 shrink-0 text-center text-xs font-black text-slate-400">{{ episodeIndex + 1 }}</span>
|
||||||
|
<input v-model="ep.name" type="text" placeholder="Tên tập"
|
||||||
|
class="h-8 w-24 shrink-0 rounded-md border border-white/10 bg-white/5 px-2 text-xs text-white placeholder:text-slate-500 outline-none focus:border-yellow-400/50">
|
||||||
|
<select
|
||||||
|
class="h-8 min-w-0 flex-1 rounded-md border border-white/10 bg-white/5 px-2 text-xs text-white outline-none focus:border-yellow-400/50"
|
||||||
|
@change="handleSourceLinkChange(serverIndex, episodeIndex, $event)">
|
||||||
|
<option value="" class="bg-slate-900 text-white">Chọn link từ API</option>
|
||||||
|
<option v-for="item in availableEpisodes" :key="item.key" :value="item.key"
|
||||||
|
class="bg-slate-900 text-white">
|
||||||
|
{{ item.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<button type="button"
|
||||||
|
class="grid h-7 w-7 shrink-0 place-items-center rounded text-slate-400 transition hover:bg-white/10 hover:text-white"
|
||||||
|
:class="expandedLinks[`${serverIndex}-${episodeIndex}`] ? 'bg-yellow-400/10 text-yellow-400' : ''"
|
||||||
|
:title="expandedLinks[`${serverIndex}-${episodeIndex}`] ? 'Ẩn link' : 'Sửa link'"
|
||||||
|
@click="toggleLinks(`${serverIndex}-${episodeIndex}`)">
|
||||||
|
<AppIcon name="circle" class="size-3" />
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
class="grid h-7 w-7 shrink-0 place-items-center rounded text-red-400 transition hover:bg-red-400/10"
|
||||||
|
@click="removeServerEpisode(serverIndex, episodeIndex)">
|
||||||
|
<AppIcon name="trash" class="size-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="expandedLinks[`${serverIndex}-${episodeIndex}`]" class="border-t border-white/10 p-2">
|
||||||
|
<div class="grid gap-2 sm:grid-cols-2">
|
||||||
|
<input v-model="ep.linkEmbed" type="url" placeholder="Link embed (iframe)"
|
||||||
|
class="h-8 w-full rounded-md border border-white/10 bg-white/5 px-2 text-xs text-white placeholder:text-slate-500 outline-none focus:border-yellow-400/50">
|
||||||
|
<input v-model="ep.linkM3u8" type="url" placeholder="Link HLS (.m3u8)"
|
||||||
|
class="h-8 w-full rounded-md border border-white/10 bg-white/5 px-2 text-xs text-white placeholder:text-slate-500 outline-none focus:border-yellow-400/50">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button"
|
||||||
|
class="mt-2 inline-flex h-8 items-center gap-1.5 rounded-lg border border-dashed border-white/20 px-3 text-xs font-semibold text-white transition hover:bg-white/10"
|
||||||
|
@click="addServerEpisode(serverIndex)">
|
||||||
<AppIcon name="plus" class="size-3.5" />
|
<AppIcon name="plus" class="size-3.5" />
|
||||||
Thêm tập
|
Thêm tập
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="customEpisodes.length" class="space-y-3">
|
|
||||||
<div v-for="(ep, index) in customEpisodes" :key="index"
|
|
||||||
class="rounded-lg border border-white/10 bg-white/5 p-4">
|
|
||||||
<div class="mb-3 flex items-center justify-between">
|
|
||||||
<span class="text-xs font-black uppercase text-slate-400">Tập {{ index + 1 }}</span>
|
|
||||||
<button type="button"
|
|
||||||
class="grid size-7 place-items-center rounded-lg text-red-400 transition hover:bg-red-400/10"
|
|
||||||
@click="removeEpisode(index)">
|
|
||||||
<AppIcon name="trash" class="size-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<input v-model="ep.name" type="text" placeholder="Tên tập (vd: Tập 1, Full, Preview...)"
|
|
||||||
class="h-9 w-full rounded-md border border-white/10 bg-white/5 px-3 text-sm text-white placeholder:text-slate-500 outline-none focus:border-yellow-400/50">
|
|
||||||
<input v-model="ep.linkEmbed" type="url" placeholder="Link embed (iframe)"
|
|
||||||
class="h-9 w-full rounded-md border border-white/10 bg-white/5 px-3 text-sm text-white placeholder:text-slate-500 outline-none focus:border-yellow-400/50">
|
|
||||||
<input v-model="ep.linkM3u8" type="url" placeholder="Link HLS (.m3u8)"
|
|
||||||
class="h-9 w-full rounded-md border border-white/10 bg-white/5 px-3 text-sm text-white placeholder:text-slate-500 outline-none focus:border-yellow-400/50">
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="rounded-lg border border-dashed border-white/10 p-6 text-center">
|
<div v-else class="rounded-lg border border-dashed border-white/10 p-6 text-center">
|
||||||
<p class="text-sm text-slate-500">Chưa có tập tuỳ chỉnh. Bấm "Thêm tập" để bắt đầu.</p>
|
<p class="text-sm text-slate-500">Chưa có server nào. Bấm "Thêm server" để bắt đầu.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- RIGHT COLUMN: Metadata & API Sources Sidebar (5 cols) -->
|
||||||
|
<div class="space-y-6 lg:col-span-5">
|
||||||
|
<!-- Thông tin phim -->
|
||||||
|
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5">
|
||||||
|
<div class="mb-4 flex items-center gap-3">
|
||||||
|
<div class="grid size-9 place-items-center rounded-lg bg-amber-400/10">
|
||||||
|
<AppIcon name="info" class="size-4 text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-base font-black text-white">Thông tin phim</h2>
|
||||||
|
<p class="text-xs text-slate-400">Thông tin chi tiết từ hệ thống</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<dl class="grid grid-cols-2 gap-x-3 gap-y-2.5 text-xs">
|
||||||
|
<div>
|
||||||
|
<dt class="text-slate-500">Nguồn</dt>
|
||||||
|
<dd class="truncate font-semibold text-white">{{ movie.source?.toUpperCase() }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-slate-500">Slug</dt>
|
||||||
|
<dd class="truncate text-white" :title="movie.slug">{{ movie.slug }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-slate-500">Năm phát hành</dt>
|
||||||
|
<dd class="text-white">{{ movie.year || '—' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-slate-500">Số tập</dt>
|
||||||
|
<dd class="text-white">{{ movie.episode || '—' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-slate-500">Chất lượng</dt>
|
||||||
|
<dd class="text-white">{{ movie.quality || '—' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-slate-500">Trạng thái</dt>
|
||||||
|
<dd :class="movie.active ? 'font-semibold text-green-400' : 'text-slate-400'">
|
||||||
|
{{ movie.active ? 'Đang hiển thị' : 'Ẩn' }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2">
|
||||||
|
<dt class="text-slate-500">Đồng bộ gần nhất</dt>
|
||||||
|
<dd class="text-white">{{ new Date(movie.syncedAt).toLocaleDateString('vi-VN') }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<div v-if="movie.categories?.length" class="mt-4 border-t border-white/10 pt-3">
|
||||||
|
<h4 class="mb-1.5 text-[11px] font-black uppercase text-slate-400">Thể loại</h4>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<span v-for="cat in movie.categories" :key="cat"
|
||||||
|
class="rounded-full bg-white/8 px-2.5 py-0.5 text-[11px] font-semibold text-slate-300">
|
||||||
|
{{ cat }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ảnh phim -->
|
||||||
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5">
|
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5">
|
||||||
<div class="mb-4 flex items-center gap-3">
|
<div class="mb-4 flex items-center gap-3">
|
||||||
<div class="grid size-9 place-items-center rounded-lg bg-purple-400/10">
|
<div class="grid size-9 place-items-center rounded-lg bg-purple-400/10">
|
||||||
<AppIcon name="image" class="size-4 text-purple-400" />
|
<AppIcon name="image" class="size-4 text-purple-400" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-base font-black text-white">Ảnh poster</h2>
|
<h2 class="text-base font-black text-white">Hình ảnh</h2>
|
||||||
<p class="text-xs text-slate-400">URL ảnh poster (để trống dùng từ API)</p>
|
<p class="text-xs text-slate-400">Poster & Thumbnail tuỳ chỉnh</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-semibold text-slate-400">Poster (ảnh ngang)</label>
|
||||||
<input v-model="customPoster" type="url" placeholder="https://..."
|
<input v-model="customPoster" type="url" placeholder="https://..."
|
||||||
class="h-10 w-full rounded-lg border border-white/10 bg-white/5 px-4 text-sm text-white placeholder:text-slate-500 outline-none focus:border-yellow-400/50">
|
class="h-9 w-full rounded-lg border border-white/10 bg-white/5 px-3 text-xs text-white placeholder:text-slate-500 outline-none focus:border-yellow-400/50">
|
||||||
<div v-if="customPoster || movie.poster" class="mt-3">
|
<div v-if="customPoster || movie.poster" class="mt-2 flex items-center justify-between gap-2">
|
||||||
<img :src="customPoster || movie.poster" :alt="movie.name"
|
<img :src="customPoster || movie.poster" :alt="movie.name"
|
||||||
class="h-48 w-auto rounded-lg object-cover ring-1 ring-white/10">
|
class="h-20 w-32 rounded-lg object-cover ring-1 ring-white/10">
|
||||||
</div>
|
|
||||||
<button v-if="customPoster" type="button"
|
<button v-if="customPoster" type="button"
|
||||||
class="mt-2 text-xs font-semibold text-red-400 transition hover:text-red-300"
|
class="text-xs font-semibold text-red-400 transition hover:text-red-300"
|
||||||
@click="clearField('customPoster')">
|
@click="clearField('customPoster')">
|
||||||
Xoá poster tuỳ chỉnh
|
Xoá poster
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5">
|
|
||||||
<div class="mb-4 flex items-center gap-3">
|
|
||||||
<div class="grid size-9 place-items-center rounded-lg bg-pink-400/10">
|
|
||||||
<AppIcon name="image" class="size-4 text-pink-400" />
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-base font-black text-white">Ảnh thumbnail</h2>
|
<label class="mb-1 block text-xs font-semibold text-slate-400">Thumbnail (ảnh dọc)</label>
|
||||||
<p class="text-xs text-slate-400">URL ảnh thumbnail (để trống dùng từ API)</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<input v-model="customThumb" type="url" placeholder="https://..."
|
<input v-model="customThumb" type="url" placeholder="https://..."
|
||||||
class="h-10 w-full rounded-lg border border-white/10 bg-white/5 px-4 text-sm text-white placeholder:text-slate-500 outline-none focus:border-yellow-400/50">
|
class="h-9 w-full rounded-lg border border-white/10 bg-white/5 px-3 text-xs text-white placeholder:text-slate-500 outline-none focus:border-yellow-400/50">
|
||||||
<div v-if="customThumb || movie.thumb" class="mt-3">
|
<div v-if="customThumb || movie.thumb" class="mt-2 flex items-center justify-between gap-2">
|
||||||
<img :src="customThumb || movie.thumb" :alt="movie.name"
|
<img :src="customThumb || movie.thumb" :alt="movie.name"
|
||||||
class="h-32 w-auto rounded-lg object-cover ring-1 ring-white/10">
|
class="h-20 w-14 rounded-lg object-cover ring-1 ring-white/10">
|
||||||
</div>
|
|
||||||
<button v-if="customThumb" type="button"
|
<button v-if="customThumb" type="button"
|
||||||
class="mt-2 text-xs font-semibold text-red-400 transition hover:text-red-300"
|
class="text-xs font-semibold text-red-400 transition hover:text-red-300"
|
||||||
@click="clearField('customThumb')">
|
@click="clearField('customThumb')">
|
||||||
Xoá thumbnail tuỳ chỉnh
|
Xoá thumbnail
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<aside class="space-y-4">
|
<!-- Nguồn API có sẵn -->
|
||||||
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5">
|
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5">
|
||||||
<h3 class="mb-3 text-sm font-black uppercase text-slate-400">Thông tin gốc</h3>
|
<div class="mb-4 flex items-center justify-between gap-3">
|
||||||
<dl class="space-y-2 text-sm">
|
<div class="flex items-center gap-3">
|
||||||
<div class="flex justify-between">
|
<div class="grid size-9 place-items-center rounded-lg bg-cyan-400/10">
|
||||||
<dt class="text-slate-500">Nguồn</dt>
|
<AppIcon name="layers" class="size-4 text-cyan-400" />
|
||||||
<dd class="font-semibold text-white">{{ movie.source?.toUpperCase() }}</dd>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
<div>
|
||||||
<dt class="text-slate-500">Slug</dt>
|
<h3 class="text-base font-black text-white">Nguồn API có sẵn</h3>
|
||||||
<dd class="truncate text-white">{{ movie.slug }}</dd>
|
<p class="text-xs text-slate-400">Dữ liệu tập phim từ các API</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
|
||||||
<dt class="text-slate-500">Năm</dt>
|
|
||||||
<dd class="text-white">{{ movie.year || '—' }}</dd>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
|
||||||
<dt class="text-slate-500">Tập</dt>
|
|
||||||
<dd class="text-white">{{ movie.episode || '—' }}</dd>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<dt class="text-slate-500">Chất lượng</dt>
|
|
||||||
<dd class="text-white">{{ movie.quality || '—' }}</dd>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<dt class="text-slate-500">Trạng thái</dt>
|
|
||||||
<dd :class="movie.active ? 'text-green-400' : 'text-slate-400'">
|
|
||||||
{{ movie.active ? 'Đang hiển thị' : 'Ẩn' }}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<dt class="text-slate-500">Đồng bộ</dt>
|
|
||||||
<dd class="text-white">{{ new Date(movie.syncedAt).toLocaleDateString('vi-VN') }}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="movie.categories?.length" class="rounded-xl border border-white/10 bg-slate-900/50 p-5">
|
<div v-if="sourceData?.sources?.length" class="space-y-3">
|
||||||
<h3 class="mb-3 text-sm font-black uppercase text-slate-400">Thể loại</h3>
|
<div class="mb-4 flex flex-col gap-2 rounded-lg border border-white/10 bg-white/5 p-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div class="flex flex-wrap gap-1.5">
|
<label class="text-xs font-semibold text-slate-400">Thêm vào server:</label>
|
||||||
<span v-for="cat in movie.categories" :key="cat"
|
<select v-model="selectedTargetServer"
|
||||||
class="rounded-full bg-white/8 px-2.5 py-1 text-xs font-semibold text-slate-300">
|
class="h-8 rounded-md border border-white/10 bg-white/5 px-2.5 text-xs text-white outline-none focus:border-yellow-400/50">
|
||||||
{{ cat }}
|
<option v-for="(server, index) in customServers" :key="index" :value="index"
|
||||||
|
class="bg-slate-900 text-white">
|
||||||
|
{{ server.name || `Server ${index + 1}` }} ({{ server.episodes.length }} tập)
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<button v-if="!customServers.length" type="button"
|
||||||
|
class="inline-flex h-8 items-center gap-1.5 rounded-lg bg-white/10 px-3 text-xs font-semibold text-white transition hover:bg-white/20"
|
||||||
|
@click="addServer">
|
||||||
|
<AppIcon name="plus" class="size-3.5" />
|
||||||
|
Tạo server
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="(source, sourceIndex) in sourceData.sources" :key="source.source"
|
||||||
|
class="rounded-lg border border-white/10 bg-white/5">
|
||||||
|
<button type="button"
|
||||||
|
class="flex w-full items-center justify-between px-4 py-3 text-left transition hover:bg-white/5"
|
||||||
|
@click="toggleSource(source.source)">
|
||||||
|
<span class="text-sm font-black text-white">
|
||||||
|
{{ source.source.toUpperCase() }} · {{ source.name }}
|
||||||
</span>
|
</span>
|
||||||
|
<AppIcon name="chevron-down"
|
||||||
|
class="size-4 text-slate-400 transition"
|
||||||
|
:class="expandedSources[source.source] ? 'rotate-180' : ''" />
|
||||||
|
</button>
|
||||||
|
<div v-if="expandedSources[source.source]" class="border-t border-white/10 p-4">
|
||||||
|
<div v-for="(server, serverIndex) in source.servers" :key="serverIndex" class="mb-4 last:mb-0">
|
||||||
|
<div class="mb-2 flex items-center justify-between gap-2">
|
||||||
|
<h4 class="text-xs font-black uppercase text-slate-400">{{ server.name }}</h4>
|
||||||
|
<button type="button"
|
||||||
|
class="inline-flex h-7 items-center gap-1 rounded-lg bg-yellow-400/10 px-2 text-xs font-semibold text-yellow-400 transition hover:bg-yellow-400/20"
|
||||||
|
@click="quickAddAllEpisodes(sourceIndex, serverIndex)">
|
||||||
|
<AppIcon name="plus" class="size-3" />
|
||||||
|
Thêm tất cả
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||||
|
<div v-for="(ep, epIndex) in server.episodes" :key="epIndex"
|
||||||
|
class="group relative rounded-md border border-white/10 bg-white/5 px-2.5 py-1.5">
|
||||||
|
<p class="truncate text-xs font-semibold text-white">{{ ep.name || `Tập ${epIndex + 1}` }}</p>
|
||||||
|
<p v-if="ep.linkM3u8" class="truncate text-[10px] text-slate-500">{{ ep.linkM3u8 }}</p>
|
||||||
|
<p v-else-if="ep.linkEmbed" class="truncate text-[10px] text-slate-500">{{ ep.linkEmbed }}</p>
|
||||||
|
<button type="button"
|
||||||
|
class="absolute right-1 top-1 grid size-6 place-items-center rounded bg-yellow-400/10 text-yellow-400 opacity-0 transition hover:bg-yellow-400/20 group-hover:opacity-100"
|
||||||
|
:title="`Thêm ${ep.name || `Tập ${epIndex + 1}`} vào server`"
|
||||||
|
@click="quickAddEpisode(sourceIndex, serverIndex, epIndex)">
|
||||||
|
<AppIcon name="plus" class="size-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="rounded-lg border border-dashed border-white/10 p-6 text-center">
|
||||||
|
<p class="text-sm text-slate-500">Chưa có nguồn API nào hoặc chưa đồng bộ tập phim.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const syncOpen = ref(false)
|
|||||||
const deleting = ref(false)
|
const deleting = ref(false)
|
||||||
const deleteConfirmOpen = ref(false)
|
const deleteConfirmOpen = ref(false)
|
||||||
const syncSources = ref({ ophim: true, nguonc: true, kkphim: true })
|
const syncSources = ref({ ophim: true, nguonc: true, kkphim: true })
|
||||||
|
const syncResult = ref<{ total: number, created: number, updated: number, sourceStats: Record<string, { fetched: number, error?: string }> } | null>(null)
|
||||||
|
|
||||||
let searchTimeout: ReturnType<typeof setTimeout> | undefined
|
let searchTimeout: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
@@ -60,12 +61,13 @@ async function handleSync() {
|
|||||||
if (!sources.length) return
|
if (!sources.length) return
|
||||||
|
|
||||||
syncing.value = true
|
syncing.value = true
|
||||||
|
syncResult.value = null
|
||||||
try {
|
try {
|
||||||
await $fetch('/api/admin/sync', {
|
const result: any = await $fetch('/api/admin/sync', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { sources },
|
body: { sources },
|
||||||
})
|
})
|
||||||
syncOpen.value = false
|
syncResult.value = result
|
||||||
await refresh()
|
await refresh()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Sync failed:', err)
|
console.error('Sync failed:', err)
|
||||||
@@ -148,7 +150,7 @@ const totalPages = computed(() => data.value?.totalPages || 1)
|
|||||||
<tr class="border-b border-white/10 text-center text-xs font-semibold uppercase text-slate-400">
|
<tr class="border-b border-white/10 text-center text-xs font-semibold uppercase text-slate-400">
|
||||||
<th class="w-12 px-4 py-3 text-center">STT</th>
|
<th class="w-12 px-4 py-3 text-center">STT</th>
|
||||||
<th class="px-4 py-3 text-center">Phim</th>
|
<th class="px-4 py-3 text-center">Phim</th>
|
||||||
<th class="px-4 py-3 text-center">Nguồn</th>
|
<th class="px-4 py-3 text-center">Nguồn trùng</th>
|
||||||
<th class="px-4 py-3 text-center">Tập</th>
|
<th class="px-4 py-3 text-center">Tập</th>
|
||||||
<th class="px-4 py-3 text-center">Lượt xem</th>
|
<th class="px-4 py-3 text-center">Lượt xem</th>
|
||||||
<th class="px-4 py-3 text-center">Trạng thái</th>
|
<th class="px-4 py-3 text-center">Trạng thái</th>
|
||||||
@@ -169,7 +171,7 @@ const totalPages = computed(() => data.value?.totalPages || 1)
|
|||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-center">
|
<td class="px-4 py-3 text-center">
|
||||||
<span class="rounded-full bg-white/10 px-2.5 py-1 text-xs font-semibold text-white">
|
<span class="rounded-full bg-white/10 px-2.5 py-1 text-xs font-semibold text-white">
|
||||||
{{ movie.source?.toUpperCase() }}
|
{{ (movie.sources || []).length }} nguồn
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-center text-sm text-slate-300">
|
<td class="px-4 py-3 text-center text-sm text-slate-300">
|
||||||
@@ -195,9 +197,11 @@ const totalPages = computed(() => data.value?.totalPages || 1)
|
|||||||
class="rounded bg-purple-400/10 px-2 py-0.5 text-[10px] font-semibold text-purple-400">Poster</span>
|
class="rounded bg-purple-400/10 px-2 py-0.5 text-[10px] font-semibold text-purple-400">Poster</span>
|
||||||
<span v-if="movie.customThumb"
|
<span v-if="movie.customThumb"
|
||||||
class="rounded bg-pink-400/10 px-2 py-0.5 text-[10px] font-semibold text-pink-400">Thumb</span>
|
class="rounded bg-pink-400/10 px-2 py-0.5 text-[10px] font-semibold text-pink-400">Thumb</span>
|
||||||
<span v-if="movie.customEpisodes?.length"
|
<span v-if="movie.customServers?.length"
|
||||||
class="rounded bg-amber-400/10 px-2 py-0.5 text-[10px] font-semibold text-amber-400">{{ movie.customEpisodes.length }} tập</span>
|
class="rounded bg-emerald-400/10 px-2 py-0.5 text-[10px] font-semibold text-emerald-400">
|
||||||
<span v-if="!movie.customContent && !movie.customPoster && !movie.customThumb && !movie.customEpisodes?.length"
|
{{ movie.customServers.length }} server · {{ movie.customServers.reduce((t: number, s: any) => t + (s.episodes?.length || 0), 0) }} tập
|
||||||
|
</span>
|
||||||
|
<span v-if="!movie.customContent && !movie.customPoster && !movie.customThumb && !movie.customServers?.length"
|
||||||
class="text-xs text-slate-500">Mặc định</span>
|
class="text-xs text-slate-500">Mặc định</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -282,6 +286,24 @@ const totalPages = computed(() => data.value?.totalPages || 1)
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="syncResult" class="mt-4 rounded-lg border border-white/10 bg-white/5 p-4">
|
||||||
|
<p class="text-sm font-semibold text-white">Kết quả đồng bộ</p>
|
||||||
|
<div class="mt-2 space-y-1 text-xs text-slate-400">
|
||||||
|
<p>Tổng phim lấy được: <span class="font-bold text-white">{{ syncResult.total }}</span></p>
|
||||||
|
<p>Đã tạo: <span class="font-bold text-green-400">{{ syncResult.created }}</span></p>
|
||||||
|
<p>Đã cập nhật: <span class="font-bold text-yellow-400">{{ syncResult.updated }}</span></p>
|
||||||
|
<div v-if="syncResult.sourceStats" class="mt-2">
|
||||||
|
<p class="font-semibold text-white">Theo nguồn:</p>
|
||||||
|
<ul class="mt-1 space-y-0.5">
|
||||||
|
<li v-for="(stats, source) in syncResult.sourceStats" :key="source">
|
||||||
|
<span class="uppercase text-white">{{ source }}</span>: {{ stats.fetched }} phim
|
||||||
|
<span v-if="stats.error" class="text-red-400">({{ stats.error }})</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 flex justify-end gap-3">
|
<div class="mt-6 flex justify-end gap-3">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="h-10 rounded-lg border border-white/10 px-4 text-sm font-semibold text-white transition hover:bg-white/10"
|
class="h-10 rounded-lg border border-white/10 px-4 text-sm font-semibold text-white transition hover:bg-white/10"
|
||||||
|
|||||||
@@ -436,6 +436,22 @@ useHead({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section v-else class="flex min-h-screen items-center justify-center px-4 pt-16 sm:px-6 lg:px-8">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="mx-auto mb-6 flex size-20 items-center justify-center rounded-full bg-yellow-400/10">
|
||||||
|
<AppIcon name="film" class="size-10 text-yellow-400" />
|
||||||
|
</div>
|
||||||
|
<h1 class="text-2xl font-black text-white sm:text-3xl">Web đang chưa cập nhật phim</h1>
|
||||||
|
<p class="mx-auto mt-3 max-w-md text-sm text-slate-400 sm:text-base">
|
||||||
|
Hiện tại chưa có bộ phim nào. Vui lòng quay lại sau để khám phá kho phim mới nhất nhé!
|
||||||
|
</p>
|
||||||
|
<NuxtLink to="/phim"
|
||||||
|
class="mt-6 inline-flex items-center gap-2 rounded-full bg-yellow-400 px-6 py-2.5 text-sm font-black text-slate-950 transition hover:bg-yellow-300">
|
||||||
|
Khám phá kho phim
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="relative z-10 mx-auto max-w-390 px-4 pb-16 pt-8 sm:px-6 lg:px-8 xl:px-10">
|
<div class="relative z-10 mx-auto max-w-390 px-4 pb-16 pt-8 sm:px-6 lg:px-8 xl:px-10">
|
||||||
<div v-if="sourceStatus.length" class="hidden">
|
<div v-if="sourceStatus.length" class="hidden">
|
||||||
<span v-for="source in sourceStatus" :key="source.name"
|
<span v-for="source in sourceStatus" :key="source.name"
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ useHead({
|
|||||||
<NuxtLink v-for="movie in selectedDay.items" :key="`${movie.source}-${movie.slug}`"
|
<NuxtLink v-for="movie in selectedDay.items" :key="`${movie.source}-${movie.slug}`"
|
||||||
:to="{ path: `/phim/${movie.slug}`, query: { source: movie.source } }"
|
:to="{ path: `/phim/${movie.slug}`, query: { source: movie.source } }"
|
||||||
class="schedule-card">
|
class="schedule-card">
|
||||||
<img :src="movie.poster || movie.thumb" :alt="movie.name"
|
<img :src="movie.thumb || movie.poster" :alt="movie.name"
|
||||||
class="schedule-card-image">
|
class="schedule-card-image">
|
||||||
<div class="schedule-card-body">
|
<div class="schedule-card-body">
|
||||||
<h2 class="schedule-card-title">
|
<h2 class="schedule-card-title">
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ useHead(() => ({
|
|||||||
<div class="shrink-0 w-40 md:w-56 lg:w-64 max-w-70 mx-auto md:mx-0 flex flex-col gap-8">
|
<div class="shrink-0 w-40 md:w-56 lg:w-64 max-w-70 mx-auto md:mx-0 flex flex-col gap-8">
|
||||||
<div
|
<div
|
||||||
class="relative rounded-xl overflow-hidden shadow-2xl shadow-[#0f111a]/50 ring-1 ring-white/10 bg-[#16161e] w-full aspect-2/3">
|
class="relative rounded-xl overflow-hidden shadow-2xl shadow-[#0f111a]/50 ring-1 ring-white/10 bg-[#16161e] w-full aspect-2/3">
|
||||||
<img :src="movie.poster || movie.thumb" :alt="movie.name"
|
<img :src="movie.thumb || movie.poster" :alt="movie.name"
|
||||||
class="w-full h-full object-cover relative z-10">
|
class="w-full h-full object-cover relative z-10">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ useHead({
|
|||||||
:to="movieLink(movie)" class="group">
|
:to="movieLink(movie)" class="group">
|
||||||
<div
|
<div
|
||||||
class="relative aspect-2/3 overflow-hidden rounded-md bg-slate-900 shadow-xl shadow-black/25 ring-1 ring-white/10 transition duration-300 group-hover:-translate-y-1 group-hover:ring-yellow-300/60">
|
class="relative aspect-2/3 overflow-hidden rounded-md bg-slate-900 shadow-xl shadow-black/25 ring-1 ring-white/10 transition duration-300 group-hover:-translate-y-1 group-hover:ring-yellow-300/60">
|
||||||
<img :src="movie.poster || movie.thumb" :alt="movie.name"
|
<img :src="movie.thumb || movie.poster" :alt="movie.name"
|
||||||
class="h-full w-full object-cover transition duration-500 group-hover:scale-105">
|
class="h-full w-full object-cover transition duration-500 group-hover:scale-105">
|
||||||
<div class="absolute inset-x-0 bottom-0 bg-linear-to-t from-slate-950 via-slate-950/70 to-transparent p-3">
|
<div class="absolute inset-x-0 bottom-0 bg-linear-to-t from-slate-950 via-slate-950/70 to-transparent p-3">
|
||||||
<p class="line-clamp-2 text-sm font-bold leading-snug text-white">{{ movie.name }}</p>
|
<p class="line-clamp-2 text-sm font-bold leading-snug text-white">{{ movie.name }}</p>
|
||||||
|
|||||||
@@ -565,7 +565,7 @@ useHead(() => ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button v-else type="button" class="absolute inset-0 text-white" @click="startPlayer">
|
<button v-else type="button" class="absolute inset-0 text-white" @click="startPlayer">
|
||||||
<img :src="movie.thumb || movie.poster" :alt="movie.name" class="h-full w-full object-cover opacity-45">
|
<img :src="movie.poster || movie.thumb" :alt="movie.name" class="h-full w-full object-cover opacity-45">
|
||||||
<div class="absolute inset-0 bg-black/45" />
|
<div class="absolute inset-0 bg-black/45" />
|
||||||
<div class="absolute inset-0 grid place-items-center">
|
<div class="absolute inset-0 grid place-items-center">
|
||||||
<span
|
<span
|
||||||
@@ -612,7 +612,7 @@ useHead(() => ({
|
|||||||
<div class="mb-6 flex items-start gap-6 lg:gap-8">
|
<div class="mb-6 flex items-start gap-6 lg:gap-8">
|
||||||
<div
|
<div
|
||||||
class="hidden shrink-0 aspect-2/3 w-32 rounded-xl overflow-hidden shadow-2xl sm:block md:w-40 lg:w-37.5">
|
class="hidden shrink-0 aspect-2/3 w-32 rounded-xl overflow-hidden shadow-2xl sm:block md:w-40 lg:w-37.5">
|
||||||
<img :src="movie.poster || movie.thumb" :alt="movie.name" class="w-full h-full object-cover">
|
<img :src="movie.thumb || movie.poster" :alt="movie.name" class="w-full h-full object-cover">
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 flex flex-col gap-4 pt-1 w-full text-center sm:text-left">
|
<div class="flex-1 flex flex-col gap-4 pt-1 w-full text-center sm:text-left">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -32,6 +32,34 @@ export default defineEventHandler(async (event) => {
|
|||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ('actors' in body) {
|
||||||
|
updates.actors = Array.isArray(body.actors) && body.actors.length
|
||||||
|
? body.actors.filter((a: any) => a.name?.trim()).map((a: any) => ({
|
||||||
|
name: a.name.trim(),
|
||||||
|
originalName: a.originalName?.trim() || undefined,
|
||||||
|
role: a.role?.trim() || undefined,
|
||||||
|
avatar: a.avatar?.trim() || undefined,
|
||||||
|
}))
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('customServers' in body) {
|
||||||
|
updates.customServers = Array.isArray(body.customServers) && body.customServers.length
|
||||||
|
? body.customServers
|
||||||
|
.filter((server: any) => server.name?.trim())
|
||||||
|
.map((server: any) => ({
|
||||||
|
name: server.name.trim(),
|
||||||
|
episodes: Array.isArray(server.episodes)
|
||||||
|
? server.episodes.filter((ep: any) => ep.name?.trim()).map((ep: any) => ({
|
||||||
|
name: ep.name.trim(),
|
||||||
|
linkEmbed: ep.linkEmbed?.trim() || null,
|
||||||
|
linkM3u8: ep.linkM3u8?.trim() || null,
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
}))
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
if (!Object.keys(updates).length) {
|
if (!Object.keys(updates).length) {
|
||||||
throw createError({ statusCode: 400, message: 'Không có dữ liệu cập nhật' })
|
throw createError({ statusCode: 400, message: 'Không có dữ liệu cập nhật' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { movies } from '../../../../database/schema'
|
||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import { getOphimDetail, getNguoncDetail, getKkphimDetail, type NormalizedServer, type NormalizedActor } from '../../../../utils/movies'
|
||||||
|
|
||||||
|
const detailFetchers = {
|
||||||
|
ophim: getOphimDetail,
|
||||||
|
nguonc: getNguoncDetail,
|
||||||
|
kkphim: getKkphimDetail,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const db = useDb()
|
||||||
|
const id = Number(getRouterParam(event, 'id'))
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
throw createError({ statusCode: 400, message: 'Thiếu ID phim' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.select().from(movies).where(eq(movies.id, id)).limit(1)
|
||||||
|
if (!result.length) {
|
||||||
|
throw createError({ statusCode: 404, message: 'Không tìm thấy phim' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const movie = result[0]
|
||||||
|
const sourceRefs = (movie.sources || []).filter((ref: any) => ref.source && ref.slug)
|
||||||
|
|
||||||
|
if (!sourceRefs.length) {
|
||||||
|
return { movie, sources: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
sourceRefs.map((ref: any) => detailFetchers[ref.source as keyof typeof detailFetchers](ref.slug)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const sourceServers: { source: string, slug: string, name: string, content: string, actors: NormalizedActor[], servers: NormalizedServer[] }[] = []
|
||||||
|
|
||||||
|
results.forEach((result, index) => {
|
||||||
|
const ref = sourceRefs[index]
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
sourceServers.push({
|
||||||
|
source: ref.source,
|
||||||
|
slug: ref.slug,
|
||||||
|
name: ref.name || result.value.name,
|
||||||
|
content: result.value.content || '',
|
||||||
|
actors: result.value.actors || [],
|
||||||
|
servers: (result.value.servers || []).map((server: NormalizedServer) => ({
|
||||||
|
...server,
|
||||||
|
source: ref.source,
|
||||||
|
sourceSlug: ref.slug,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
movie,
|
||||||
|
sources: sourceServers,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { movies } from '../../database/schema'
|
import { movies } from '../../database/schema'
|
||||||
import { eq, and } from 'drizzle-orm'
|
import { eq, and, or } from 'drizzle-orm'
|
||||||
import {
|
import {
|
||||||
getOphimKoreanMovies,
|
getOphimKoreanMovies,
|
||||||
getNguoncKoreanMovies,
|
getNguoncKoreanMovies,
|
||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
getOphimDetail,
|
getOphimDetail,
|
||||||
getNguoncDetail,
|
getNguoncDetail,
|
||||||
getKkphimDetail,
|
getKkphimDetail,
|
||||||
|
groupMovies,
|
||||||
|
type NormalizedMovie,
|
||||||
} from '../../utils/movies'
|
} from '../../utils/movies'
|
||||||
|
|
||||||
const SYNC_PAGES = 10
|
const SYNC_PAGES = 10
|
||||||
@@ -19,28 +21,41 @@ const detailFetchers = {
|
|||||||
kkphim: getKkphimDetail,
|
kkphim: getKkphimDetail,
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchEpisodeTotal(source: string, slug: string): Promise<string | undefined> {
|
async function fetchMovieDetail(source: string, slug: string): Promise<{ episodeTotal?: string, actors?: { name: string, originalName?: string, role?: string, avatar?: string }[] } | undefined> {
|
||||||
const fetcher = detailFetchers[source as keyof typeof detailFetchers]
|
const fetcher = detailFetchers[source as keyof typeof detailFetchers]
|
||||||
if (!fetcher) return undefined
|
if (!fetcher) return undefined
|
||||||
try {
|
try {
|
||||||
const detail = await fetcher(slug)
|
const detail = await fetcher(slug)
|
||||||
return detail.episodeTotal || undefined
|
return {
|
||||||
|
episodeTotal: detail.episodeTotal || undefined,
|
||||||
|
actors: detail.actors?.length ? detail.actors.map((a) => ({
|
||||||
|
name: a.name,
|
||||||
|
originalName: a.originalName,
|
||||||
|
role: a.role,
|
||||||
|
avatar: a.avatar,
|
||||||
|
})) : undefined,
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function enrichWithEpisodeTotal(moviesList: any[]): Promise<void> {
|
async function enrichMovieDetails(moviesList: any[]): Promise<void> {
|
||||||
const moviesNeedTotal = moviesList.filter((m) => m.type !== 'single' && !m.episodeTotal && m.slug)
|
const moviesNeedDetail = moviesList.filter((m) => m.slug && (m.type !== 'single' || !m.actors))
|
||||||
|
|
||||||
for (let i = 0; i < moviesNeedTotal.length; i += DETAIL_CONCURRENCY) {
|
for (let i = 0; i < moviesNeedDetail.length; i += DETAIL_CONCURRENCY) {
|
||||||
const batch = moviesNeedTotal.slice(i, i + DETAIL_CONCURRENCY)
|
const batch = moviesNeedDetail.slice(i, i + DETAIL_CONCURRENCY)
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
batch.map((movie) => fetchEpisodeTotal(movie.source, movie.slug)),
|
batch.map((movie) => fetchMovieDetail(movie.source, movie.slug)),
|
||||||
)
|
)
|
||||||
results.forEach((result, idx) => {
|
results.forEach((result, idx) => {
|
||||||
if (result.status === 'fulfilled' && result.value) {
|
if (result.status === 'fulfilled' && result.value) {
|
||||||
batch[idx].episodeTotal = result.value
|
if (result.value.episodeTotal) {
|
||||||
|
batch[idx].episodeTotal = result.value.episodeTotal
|
||||||
|
}
|
||||||
|
if (result.value.actors) {
|
||||||
|
batch[idx].actors = result.value.actors
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -62,79 +77,94 @@ export default defineEventHandler(async (event) => {
|
|||||||
nguonc: getNguoncKoreanMovies,
|
nguonc: getNguoncKoreanMovies,
|
||||||
kkphim: getKkphimKoreanMovies,
|
kkphim: getKkphimKoreanMovies,
|
||||||
}
|
}
|
||||||
|
const sourceStats: Record<string, { fetched: number, error?: string }> = {}
|
||||||
|
|
||||||
for (const sourceName of sources) {
|
for (const sourceName of sources) {
|
||||||
const fetcher = fetchers[sourceName]
|
const fetcher = fetchers[sourceName]
|
||||||
if (!fetcher) continue
|
if (!fetcher) {
|
||||||
|
sourceStats[sourceName] = { fetched: 0, error: 'Không tìm thấy fetcher' }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let fetched = 0
|
||||||
for (let page = 1; page <= SYNC_PAGES; page++) {
|
for (let page = 1; page <= SYNC_PAGES; page++) {
|
||||||
try {
|
try {
|
||||||
const result = await fetcher(page)
|
const result = await fetcher(page)
|
||||||
for (const movie of result.items) {
|
for (const movie of result.items) {
|
||||||
allMovies.push({ ...movie, source: sourceName })
|
allMovies.push({ ...movie, source: sourceName })
|
||||||
}
|
}
|
||||||
|
fetched += result.items.length
|
||||||
if (!result.items.length) break
|
if (!result.items.length) break
|
||||||
} catch {
|
} catch (err: any) {
|
||||||
|
sourceStats[sourceName] = { fetched, error: err?.message || `Lỗi ở trang ${page}` }
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!sourceStats[sourceName]) {
|
||||||
|
sourceStats[sourceName] = { fetched }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await enrichWithEpisodeTotal(allMovies)
|
await enrichMovieDetails(allMovies)
|
||||||
|
|
||||||
|
// Group movies across sources using the existing grouping logic
|
||||||
|
const grouped = groupMovies(allMovies as NormalizedMovie[])
|
||||||
|
|
||||||
let created = 0
|
let created = 0
|
||||||
let updated = 0
|
let updated = 0
|
||||||
|
|
||||||
for (const movie of allMovies) {
|
for (const primary of grouped) {
|
||||||
const existing = await db
|
const sourceRefs = (primary.sources || []).map((ref: any) => ({ source: ref.source, slug: ref.slug, name: ref.name || primary.name }))
|
||||||
|
const sourceSlugConditions = sourceRefs
|
||||||
|
.filter((ref: any) => ref.source && ref.slug)
|
||||||
|
.map((ref: any) => and(eq(movies.source, ref.source), eq(movies.slug, ref.slug)))
|
||||||
|
|
||||||
|
// Find existing record by any (source, slug) in the group, or same name/year
|
||||||
|
let existing = null
|
||||||
|
if (sourceSlugConditions.length) {
|
||||||
|
const existingBySource = await db.select().from(movies).where(or(...sourceSlugConditions)).limit(1)
|
||||||
|
if (existingBySource.length) existing = existingBySource[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
const nameConditions: any[] = [eq(movies.name, primary.name)]
|
||||||
|
if (primary.year) nameConditions.push(eq(movies.year, primary.year))
|
||||||
|
const existingByName = await db
|
||||||
.select()
|
.select()
|
||||||
.from(movies)
|
.from(movies)
|
||||||
.where(and(eq(movies.source, movie.source), eq(movies.slug, movie.slug)))
|
.where(and(...nameConditions))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
if (existingByName.length) existing = existingByName[0]
|
||||||
|
}
|
||||||
|
|
||||||
if (existing.length) {
|
const movieData = {
|
||||||
await db
|
name: primary.name,
|
||||||
.update(movies)
|
originName: primary.originName || null,
|
||||||
.set({
|
thumb: primary.thumb || null,
|
||||||
name: movie.name,
|
poster: primary.poster || null,
|
||||||
originName: movie.originName || null,
|
year: primary.year || null,
|
||||||
thumb: movie.thumb || null,
|
time: primary.time || null,
|
||||||
poster: movie.poster || null,
|
episode: primary.episode || null,
|
||||||
year: movie.year || null,
|
episodeTotal: primary.episodeTotal || null,
|
||||||
time: movie.time || null,
|
quality: primary.quality || null,
|
||||||
episode: movie.episode || null,
|
lang: primary.lang || null,
|
||||||
episodeTotal: movie.episodeTotal || null,
|
type: primary.type || null,
|
||||||
quality: movie.quality || null,
|
rating: primary.rating || null,
|
||||||
lang: movie.lang || null,
|
categories: primary.categories || null,
|
||||||
type: movie.type || null,
|
countries: primary.countries || null,
|
||||||
rating: movie.rating || null,
|
actors: (primary as any).actors || null,
|
||||||
categories: movie.categories || null,
|
sources: sourceRefs,
|
||||||
countries: movie.countries || null,
|
apiUpdatedAt: primary.updatedAt ? new Date(primary.updatedAt) : null,
|
||||||
sources: movie.sources || null,
|
|
||||||
apiUpdatedAt: movie.updatedAt ? new Date(movie.updatedAt) : null,
|
|
||||||
syncedAt: new Date(),
|
syncedAt: new Date(),
|
||||||
})
|
}
|
||||||
.where(eq(movies.id, existing[0].id))
|
|
||||||
|
if (existing) {
|
||||||
|
await db.update(movies).set(movieData).where(eq(movies.id, existing.id))
|
||||||
updated++
|
updated++
|
||||||
} else {
|
} else {
|
||||||
await db.insert(movies).values({
|
await db.insert(movies).values({
|
||||||
source: movie.source,
|
source: primary.source,
|
||||||
slug: movie.slug,
|
slug: primary.slug,
|
||||||
name: movie.name,
|
...movieData,
|
||||||
originName: movie.originName || null,
|
|
||||||
thumb: movie.thumb || null,
|
|
||||||
poster: movie.poster || null,
|
|
||||||
year: movie.year || null,
|
|
||||||
time: movie.time || null,
|
|
||||||
episode: movie.episode || null,
|
|
||||||
episodeTotal: movie.episodeTotal || null,
|
|
||||||
quality: movie.quality || null,
|
|
||||||
lang: movie.lang || null,
|
|
||||||
type: movie.type || null,
|
|
||||||
rating: movie.rating || null,
|
|
||||||
categories: movie.categories || null,
|
|
||||||
countries: movie.countries || null,
|
|
||||||
sources: movie.sources || null,
|
|
||||||
apiUpdatedAt: movie.updatedAt ? new Date(movie.updatedAt) : null,
|
|
||||||
active: false,
|
active: false,
|
||||||
})
|
})
|
||||||
created++
|
created++
|
||||||
@@ -147,5 +177,6 @@ export default defineEventHandler(async (event) => {
|
|||||||
total: allMovies.length,
|
total: allMovies.length,
|
||||||
created,
|
created,
|
||||||
updated,
|
updated,
|
||||||
|
sourceStats,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,53 @@
|
|||||||
import { movies } from '../../database/schema'
|
import { movies } from '../../database/schema'
|
||||||
import { eq, and } from 'drizzle-orm'
|
import { eq, and } from 'drizzle-orm'
|
||||||
import { getMovieDetailGroup, parseSourceRefs } from '../../utils/movies'
|
import type { MovieDetail, NormalizedServer } from '../../utils/movies'
|
||||||
|
|
||||||
|
function mapCustomServers(movie: any): NormalizedServer[] {
|
||||||
|
const servers = movie.customServers || movie.custom_servers
|
||||||
|
if (!Array.isArray(servers) || !servers.length) return []
|
||||||
|
|
||||||
|
return servers.map((server: any) => ({
|
||||||
|
name: String(server.name || 'Server'),
|
||||||
|
source: movie.source,
|
||||||
|
sourceSlug: movie.slug,
|
||||||
|
episodes: (server.episodes || []).map((ep: any) => ({
|
||||||
|
name: String(ep.name || ''),
|
||||||
|
linkEmbed: ep.linkEmbed || undefined,
|
||||||
|
linkM3u8: ep.linkM3u8 || undefined,
|
||||||
|
})).filter((ep: any) => ep.name || ep.linkEmbed || ep.linkM3u8),
|
||||||
|
})).filter((server: any) => server.episodes.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapMovieToDetail(movie: any): MovieDetail {
|
||||||
|
const servers = mapCustomServers(movie)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `${movie.source}:${movie.slug}`,
|
||||||
|
source: movie.source,
|
||||||
|
name: movie.name,
|
||||||
|
originName: movie.originName || '',
|
||||||
|
slug: movie.slug,
|
||||||
|
thumb: movie.customThumb || movie.thumb || '',
|
||||||
|
poster: movie.customPoster || movie.poster || '',
|
||||||
|
year: movie.year || undefined,
|
||||||
|
time: movie.time || undefined,
|
||||||
|
episode: movie.episode || undefined,
|
||||||
|
episodeTotal: movie.episodeTotal || undefined,
|
||||||
|
quality: movie.quality || undefined,
|
||||||
|
lang: movie.lang || undefined,
|
||||||
|
type: movie.type || undefined,
|
||||||
|
rating: movie.rating || undefined,
|
||||||
|
updatedAt: movie.apiUpdatedAt ? new Date(movie.apiUpdatedAt).toISOString() : undefined,
|
||||||
|
categories: movie.categories || [],
|
||||||
|
countries: movie.countries || [],
|
||||||
|
sources: movie.sources || [],
|
||||||
|
content: movie.customContent || movie.content || '',
|
||||||
|
actors: movie.actors || [],
|
||||||
|
directors: [],
|
||||||
|
trailer: undefined,
|
||||||
|
servers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const slug = getRouterParam(event, 'slug')
|
const slug = getRouterParam(event, 'slug')
|
||||||
@@ -34,19 +81,5 @@ export default defineEventHandler(async (event) => {
|
|||||||
recordToUpdate = fallback[0]
|
recordToUpdate = fallback[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
const refs = parseSourceRefs(query.sources || query.srcs, query.source, slug)
|
return mapMovieToDetail(recordToUpdate)
|
||||||
|
|
||||||
const detail = await getMovieDetailGroup(refs)
|
|
||||||
|
|
||||||
if (detail.episodeTotal && recordToUpdate) {
|
|
||||||
try {
|
|
||||||
await db
|
|
||||||
.update(movies)
|
|
||||||
.set({ episodeTotal: detail.episodeTotal })
|
|
||||||
.where(eq(movies.id, recordToUpdate.id))
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return detail
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `movies` ADD `custom_servers` json;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `movies` ADD `custom_servers` json;
|
||||||
@@ -0,0 +1,573 @@
|
|||||||
|
{
|
||||||
|
"version": "5",
|
||||||
|
"dialect": "mysql",
|
||||||
|
"id": "0aab58f1-81f3-4328-8120-60b81eb7cbfd",
|
||||||
|
"prevId": "cdff5cf9-c17b-4367-a3db-46bd089b4b44",
|
||||||
|
"tables": {
|
||||||
|
"comment_votes": {
|
||||||
|
"name": "comment_votes",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"comment_id": {
|
||||||
|
"name": "comment_id",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"vote": {
|
||||||
|
"name": "vote",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(now())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"idx_user_comment": {
|
||||||
|
"name": "idx_user_comment",
|
||||||
|
"columns": [
|
||||||
|
"user_id",
|
||||||
|
"comment_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"comment_votes_id": {
|
||||||
|
"name": "comment_votes_id",
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraint": {}
|
||||||
|
},
|
||||||
|
"comments": {
|
||||||
|
"name": "comments",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"name": "source",
|
||||||
|
"type": "varchar(50)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "''"
|
||||||
|
},
|
||||||
|
"slug": {
|
||||||
|
"name": "slug",
|
||||||
|
"type": "varchar(500)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"movie_name": {
|
||||||
|
"name": "movie_name",
|
||||||
|
"type": "varchar(500)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"name": "content",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"parent_id": {
|
||||||
|
"name": "parent_id",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"pinned": {
|
||||||
|
"name": "pinned",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"spoiler": {
|
||||||
|
"name": "spoiler",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"anonymous": {
|
||||||
|
"name": "anonymous",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"like_count": {
|
||||||
|
"name": "like_count",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"dislike_count": {
|
||||||
|
"name": "dislike_count",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(now())"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"onUpdate": true,
|
||||||
|
"default": "(now())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"idx_source_slug": {
|
||||||
|
"name": "idx_source_slug",
|
||||||
|
"columns": [
|
||||||
|
"source",
|
||||||
|
"slug"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"idx_user_id": {
|
||||||
|
"name": "idx_user_id",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"idx_parent_id": {
|
||||||
|
"name": "idx_parent_id",
|
||||||
|
"columns": [
|
||||||
|
"parent_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"comments_id": {
|
||||||
|
"name": "comments_id",
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraint": {}
|
||||||
|
},
|
||||||
|
"movies": {
|
||||||
|
"name": "movies",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"name": "source",
|
||||||
|
"type": "varchar(50)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"slug": {
|
||||||
|
"name": "slug",
|
||||||
|
"type": "varchar(500)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "varchar(500)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"origin_name": {
|
||||||
|
"name": "origin_name",
|
||||||
|
"type": "varchar(500)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"thumb": {
|
||||||
|
"name": "thumb",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"poster": {
|
||||||
|
"name": "poster",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"year": {
|
||||||
|
"name": "year",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"time": {
|
||||||
|
"name": "time",
|
||||||
|
"type": "varchar(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"episode": {
|
||||||
|
"name": "episode",
|
||||||
|
"type": "varchar(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"episode_total": {
|
||||||
|
"name": "episode_total",
|
||||||
|
"type": "varchar(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"quality": {
|
||||||
|
"name": "quality",
|
||||||
|
"type": "varchar(50)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"lang": {
|
||||||
|
"name": "lang",
|
||||||
|
"type": "varchar(50)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "varchar(50)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"rating": {
|
||||||
|
"name": "rating",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"views": {
|
||||||
|
"name": "views",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"name": "content",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"name": "categories",
|
||||||
|
"type": "json",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"countries": {
|
||||||
|
"name": "countries",
|
||||||
|
"type": "json",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"sources": {
|
||||||
|
"name": "sources",
|
||||||
|
"type": "json",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"custom_poster": {
|
||||||
|
"name": "custom_poster",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"custom_thumb": {
|
||||||
|
"name": "custom_thumb",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"custom_content": {
|
||||||
|
"name": "custom_content",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"custom_episodes": {
|
||||||
|
"name": "custom_episodes",
|
||||||
|
"type": "json",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"custom_servers": {
|
||||||
|
"name": "custom_servers",
|
||||||
|
"type": "json",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"active": {
|
||||||
|
"name": "active",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"api_updated_at": {
|
||||||
|
"name": "api_updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"synced_at": {
|
||||||
|
"name": "synced_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(now())"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(now())"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"onUpdate": true,
|
||||||
|
"default": "(now())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"movies_id": {
|
||||||
|
"name": "movies_id",
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraint": {}
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"name": "users",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "int",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "varchar(200)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"name": "password",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"name": "role",
|
||||||
|
"type": "varchar(50)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'user'"
|
||||||
|
},
|
||||||
|
"avatar": {
|
||||||
|
"name": "avatar",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"gender": {
|
||||||
|
"name": "gender",
|
||||||
|
"type": "varchar(20)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"reset_token": {
|
||||||
|
"name": "reset_token",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"reset_token_expires": {
|
||||||
|
"name": "reset_token_expires",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"active": {
|
||||||
|
"name": "active",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(now())"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"onUpdate": true,
|
||||||
|
"default": "(now())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"users_id": {
|
||||||
|
"name": "users_id",
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"users_email_unique": {
|
||||||
|
"name": "users_email_unique",
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"checkConstraint": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"tables": {},
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -92,6 +92,13 @@
|
|||||||
"when": 1784882595519,
|
"when": 1784882595519,
|
||||||
"tag": "0012_bumpy_the_watchers",
|
"tag": "0012_bumpy_the_watchers",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 13,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1785165500506,
|
||||||
|
"tag": "0013_late_vapor",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -35,11 +35,13 @@ export const movies = mysqlTable('movies', {
|
|||||||
content: text('content'),
|
content: text('content'),
|
||||||
categories: json('categories').$type<string[]>(),
|
categories: json('categories').$type<string[]>(),
|
||||||
countries: json('countries').$type<string[]>(),
|
countries: json('countries').$type<string[]>(),
|
||||||
|
actors: json('actors').$type<{ name: string, originalName?: string, role?: string, avatar?: string }[]>(),
|
||||||
sources: json('sources').$type<{ source: string, slug: string, name?: string }[]>(),
|
sources: json('sources').$type<{ source: string, slug: string, name?: string }[]>(),
|
||||||
customPoster: text('custom_poster'),
|
customPoster: text('custom_poster'),
|
||||||
customThumb: text('custom_thumb'),
|
customThumb: text('custom_thumb'),
|
||||||
customContent: text('custom_content'),
|
customContent: text('custom_content'),
|
||||||
customEpisodes: json('custom_episodes').$type<{ name: string, linkEmbed?: string, linkM3u8?: string }[]>(),
|
customEpisodes: json('custom_episodes').$type<{ name: string, linkEmbed?: string, linkM3u8?: string }[]>(),
|
||||||
|
customServers: json('custom_servers').$type<{ name: string, episodes: { name: string, linkEmbed?: string, linkM3u8?: string }[] }[]>(),
|
||||||
active: boolean('active').notNull().default(false),
|
active: boolean('active').notNull().default(false),
|
||||||
apiUpdatedAt: timestamp('api_updated_at'),
|
apiUpdatedAt: timestamp('api_updated_at'),
|
||||||
syncedAt: timestamp('synced_at').notNull().defaultNow(),
|
syncedAt: timestamp('synced_at').notNull().defaultNow(),
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ function interleaveMovies(groups: NormalizedMovie[][]) {
|
|||||||
return interleaved
|
return interleaved
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupMovies(items: NormalizedMovie[]) {
|
export function groupMovies(items: NormalizedMovie[]) {
|
||||||
const groups = new Map<string, NormalizedMovie>()
|
const groups = new Map<string, NormalizedMovie>()
|
||||||
const exactAliases = new Map<string, string>()
|
const exactAliases = new Map<string, string>()
|
||||||
const looseAliases = new Map<string, string>()
|
const looseAliases = new Map<string, string>()
|
||||||
@@ -291,7 +291,7 @@ export function normalizeOphimMovie(movie: any, pathImage = OPHIM_IMAGE): Normal
|
|||||||
originName: text(movie?.origin_name),
|
originName: text(movie?.origin_name),
|
||||||
slug: text(movie?.slug),
|
slug: text(movie?.slug),
|
||||||
thumb: joinOphimImage(pathImage, movie?.thumb_url),
|
thumb: joinOphimImage(pathImage, movie?.thumb_url),
|
||||||
poster: joinOphimImage(pathImage, movie?.poster_url || movie?.thumb_url),
|
poster: joinOphimImage(pathImage, movie?.poster_url),
|
||||||
year: Number(movie?.year) || undefined,
|
year: Number(movie?.year) || undefined,
|
||||||
time: text(movie?.time),
|
time: text(movie?.time),
|
||||||
episode: text(movie?.episode_current),
|
episode: text(movie?.episode_current),
|
||||||
@@ -314,7 +314,7 @@ export function normalizeKkphimMovie(movie: any, pathImage = KKPHIM_IMAGE): Norm
|
|||||||
originName: text(movie?.origin_name),
|
originName: text(movie?.origin_name),
|
||||||
slug: text(movie?.slug),
|
slug: text(movie?.slug),
|
||||||
thumb: joinKkphimImage(pathImage, movie?.thumb_url),
|
thumb: joinKkphimImage(pathImage, movie?.thumb_url),
|
||||||
poster: joinKkphimImage(pathImage, movie?.poster_url || movie?.thumb_url),
|
poster: joinKkphimImage(pathImage, movie?.poster_url),
|
||||||
year: Number(movie?.year) || undefined,
|
year: Number(movie?.year) || undefined,
|
||||||
time: text(movie?.time),
|
time: text(movie?.time),
|
||||||
episode: text(movie?.episode_current),
|
episode: text(movie?.episode_current),
|
||||||
@@ -339,7 +339,7 @@ function normalizeNguoncMovie(movie: any): NormalizedMovie {
|
|||||||
originName: text(movie?.original_name || movie?.origin_name),
|
originName: text(movie?.original_name || movie?.origin_name),
|
||||||
slug: text(movie?.slug),
|
slug: text(movie?.slug),
|
||||||
thumb: joinImage('', image),
|
thumb: joinImage('', image),
|
||||||
poster: joinImage('', movie?.poster_url || image),
|
poster: joinImage('', movie?.poster_url),
|
||||||
year: Number(movie?.year || movie?.release_year) || undefined,
|
year: Number(movie?.year || movie?.release_year) || undefined,
|
||||||
time: text(movie?.time || movie?.duration),
|
time: text(movie?.time || movie?.duration),
|
||||||
episode: text(movie?.episode_current || movie?.current_episode),
|
episode: text(movie?.episode_current || movie?.current_episode),
|
||||||
|
|||||||
Reference in New Issue
Block a user