feat: add episode total handling and display across movie pages

- Implemented `getEpisodeDisplay` function to format episode information for movies.
- Updated movie display components to show episode information using the new function.
- Added `episodeTotal` field to the movies schema and updated related API endpoints to handle this new field.
- Enhanced movie synchronization process to fetch and store episode totals from external sources.
- Created a new error page component for better user experience on 404 errors.
- Added migration scripts to update the database schema for episode totals.
This commit is contained in:
ngthanhvu
2026-07-23 02:56:24 -04:00
parent a35403a5b9
commit f42dff9b0a
19 changed files with 983 additions and 124 deletions
+25 -5
View File
@@ -12,6 +12,7 @@ const props = defineProps<{
year?: number
time?: string
episode?: string
episodeTotal?: string
quality?: string
lang?: string
categories?: string[]
@@ -20,6 +21,21 @@ const props = defineProps<{
}
}>()
const episodeDisplay = computed(() => {
const ep = props.movie.episode
const total = props.movie.episodeTotal
if (!ep) return undefined
const totalNum = total ? total.replace(/[^0-9]/g, '') : ''
const epNum = ep.replace(/[^0-9]/g, '')
if (epNum && totalNum && epNum !== totalNum) {
return `Tập ${epNum}/${totalNum}`
}
return ep
})
const isPreviewVisible = ref(false)
const isDescriptionExpanded = ref(false)
const previewStyle = ref<Record<string, string>>({})
@@ -82,16 +98,20 @@ onBeforeUnmount(() => {
@mouseenter="showPreview" @mouseleave="scheduleHide" @focus="showPreview" @blur="scheduleHide">
<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">
<img :src="movie.thumb || movie.poster" :alt="movie.name"
<img :src="movie.poster || movie.thumb" :alt="movie.name"
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">
<p class="line-clamp-2 text-sm font-bold leading-snug text-white">{{ movie.name }}</p>
<p class="mt-1 truncate text-xs text-yellow-100">{{ movie.episode || movie.year || movie.quality }}</p>
<p class="mt-1 truncate text-xs text-yellow-100">{{ episodeDisplay || movie.year || movie.quality }}</p>
</div>
<span v-if="movie.sources?.length && movie.sources.length > 1"
class="absolute left-2 top-2 rounded bg-yellow-400 px-2 py-1 text-xs font-black text-slate-950">
{{ movie.sources.length }} server
</span>
<span v-if="episodeDisplay"
class="absolute right-2 top-2 rounded bg-red-600/90 px-2 py-1 text-xs font-black text-white backdrop-blur-sm">
{{ episodeDisplay }}
</span>
</div>
</NuxtLink>
@@ -103,7 +123,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"
:style="previewStyle" @mouseenter="keepPreview" @mouseleave="scheduleHide">
<div class="relative h-44 overflow-hidden">
<img :src="movie.poster || movie.thumb" :alt="movie.name" class="h-full w-full object-cover">
<img :src="movie.thumb || movie.poster" :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-x-0 bottom-0 p-4">
<h3 class="line-clamp-2 text-xl font-black leading-tight text-white">
@@ -135,8 +155,8 @@ onBeforeUnmount(() => {
movie.quality }}</span>
<span v-if="movie.lang" class="rounded bg-yellow-300 px-2.5 py-1.5 text-slate-950">{{ movie.lang }}</span>
<span v-if="movie.time" class="rounded bg-white px-2.5 py-1.5 text-slate-950">{{ movie.time }}</span>
<span v-if="movie.episode" class="rounded bg-black/28 px-2.5 py-1.5 text-white ring-1 ring-white/10">{{
movie.episode }}</span>
<span v-if="episodeDisplay" class="rounded bg-black/28 px-2.5 py-1.5 text-white ring-1 ring-white/10">{{
episodeDisplay }}</span>
</div>
<div class="mt-3">
+85
View File
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { ArrowLeft, Home, Search } from 'lucide-vue-next'
const props = defineProps<{
error: {
statusCode: number
message: string
}
}>()
const router = useRouter()
const keyword = ref('')
function handleSearch() {
if (keyword.value.trim()) {
router.push({ path: '/phim', query: { q: keyword.value.trim() } })
}
}
function goBack() {
router.back()
}
function goHome() {
router.push('/')
}
useHead({
title: `${props.error?.statusCode || 404} - Không tìm thấy trang - CineK`,
meta: [
{ name: 'robots', content: 'noindex' },
],
})
</script>
<template>
<main class="min-h-screen overflow-hidden bg-black">
<AppHeader />
<section
class="relative mx-auto flex min-h-[80vh] max-w-390 items-center justify-center px-4 pt-24 sm:px-6 lg:px-8 xl:px-10">
<!-- Background glow -->
<div class="pointer-events-none absolute inset-0 overflow-hidden">
<div
class="absolute left-1/2 top-1/2 size-96 -translate-x-1/2 -translate-y-1/2 rounded-full bg-yellow-400/5 blur-3xl" />
<div
class="absolute left-1/4 top-1/3 size-64 -translate-x-1/2 -translate-y-1/2 rounded-full bg-yellow-300/5 blur-2xl" />
</div>
<!-- Dot pattern -->
<div class="absolute inset-0 pointer-events-none opacity-20 hidden md:block"
style="background-image:radial-gradient(rgba(250,204,21,0.3) 0.5px, transparent 1px);background-size:4px 4px">
</div>
<div class="relative z-10 w-full max-w-xl text-center">
<!-- 404 Number -->
<div class="relative mb-8">
<span class="text-[10rem] font-black leading-none tracking-tighter text-white sm:text-[12rem]">
{{ error?.statusCode || 404 }}
</span>
<div class="absolute inset-0 blur-3xl opacity-20">
<span class="text-[10rem] font-black leading-none tracking-tighter text-yellow-400 sm:text-[12rem]">
{{ error?.statusCode || 404 }}
</span>
</div>
</div>
<!-- Error message -->
<p class="mt-3 text-base leading-relaxed text-slate-400 sm:text-lg">
Trang bạn đang tìm không tồn tại hoặc đã bị xóa.
</p>
<!-- Action buttons -->
<div class="mt-8 flex items-center justify-center gap-3">
<button type="button"
class="inline-flex h-12 items-center gap-2 rounded-xl bg-yellow-400 px-5 text-sm font-black text-slate-950 transition hover:bg-yellow-300"
@click="goHome">
<Home class="size-4" />
Về trang chủ
</button>
</div>
</div>
</section>
<AppFooter />
</main>
</template>
+224 -7
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { BarChart3, Film, RefreshCw, TrendingUp, X } from 'lucide-vue-next'
import { BarChart3, Film, Play, RefreshCw, TrendingUp, Tv, Users, X } from 'lucide-vue-next'
definePageMeta({
layout: 'admin',
@@ -35,6 +35,36 @@ async function handleSync() {
syncing.value = false
}
}
function percentOf(value: number, total: number) {
if (!total) return 0
return Math.round((value / total) * 100)
}
// Donut chart helpers
function donutSegment(value: number, total: number, startAngle: number, radius = 40) {
if (!total || !value) return ''
const angle = (value / total) * 360
const startRad = ((startAngle - 90) * Math.PI) / 180
const endRad = (((startAngle + angle) - 90) * Math.PI) / 180
const x1 = 50 + radius * Math.cos(startRad)
const y1 = 50 + radius * Math.sin(startRad)
const x2 = 50 + radius * Math.cos(endRad)
const y2 = 50 + radius * Math.sin(endRad)
const largeArc = angle > 180 ? 1 : 0
return `M 50 50 L ${x1} ${y1} A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2} Z`
}
// Bar chart max value
const maxViews = computed(() => {
const movies = stats.value?.topMovies || []
return Math.max(...movies.map(m => m.views || 0), 1)
})
function barWidth(views: number) {
if (!maxViews.value) return 0
return Math.max((views / maxViews.value) * 100, 4)
}
</script>
<template>
@@ -52,7 +82,8 @@ async function handleSync() {
</button>
</div>
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<!-- Stats Cards -->
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5 transition hover:border-white/20">
<div class="flex items-start justify-between">
<div>
@@ -68,7 +99,7 @@ async function handleSync() {
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5 transition hover:border-white/20">
<div class="flex items-start justify-between">
<div>
<p class="text-sm font-semibold text-slate-400">Phim đang hiển thị</p>
<p class="text-sm font-semibold text-slate-400">Đang hiển thị</p>
<p class="mt-2 text-3xl font-black text-green-400">{{ stats?.active?.toLocaleString() || 0 }}</p>
</div>
<div class="grid size-11 place-items-center rounded-xl bg-green-400/10 text-green-400">
@@ -80,16 +111,202 @@ async function handleSync() {
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5 transition hover:border-white/20">
<div class="flex items-start justify-between">
<div>
<p class="text-sm font-semibold text-slate-400">Phim chưa hiển thị</p>
<p class="mt-2 text-3xl font-black text-slate-400">{{ stats?.inactive?.toLocaleString() || 0 }}</p>
<p class="text-sm font-semibold text-slate-400">Phim bộ</p>
<p class="mt-2 text-3xl font-black text-blue-400">{{ stats?.series?.toLocaleString() || 0 }}</p>
</div>
<div class="grid size-11 place-items-center rounded-xl bg-slate-400/10 text-slate-400">
<BarChart3 class="size-5" />
<div class="grid size-11 place-items-center rounded-xl bg-blue-400/10 text-blue-400">
<Tv class="size-5" />
</div>
</div>
</div>
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5 transition hover:border-white/20">
<div class="flex items-start justify-between">
<div>
<p class="text-sm font-semibold text-slate-400">Phim lẻ</p>
<p class="mt-2 text-3xl font-black text-purple-400">{{ stats?.single?.toLocaleString() || 0 }}</p>
</div>
<div class="grid size-11 place-items-center rounded-xl bg-purple-400/10 text-purple-400">
<Play class="size-5" />
</div>
</div>
</div>
</div>
<!-- Charts Row -->
<div class="mb-6 grid gap-4 lg:grid-cols-3">
<!-- Donut Chart: Source Distribution -->
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5">
<p class="text-sm font-semibold text-slate-400">Phân bổ nguồn phim</p>
<div class="mt-4 flex items-center justify-center">
<div class="relative">
<svg viewBox="0 0 100 100" class="size-44">
<!-- OPhim segment -->
<template v-if="stats?.ophim">
<path :d="donutSegment(stats.ophim, stats.total, 0)" fill="#facc15" />
</template>
<!-- NguonC segment -->
<template v-if="stats?.nguonc">
<path :d="donutSegment(stats.nguonc, stats.total, (stats.ophim || 0) / (stats.total || 1) * 360)" fill="#4ade80" />
</template>
<!-- KKPhim segment -->
<template v-if="stats?.kkphim">
<path :d="donutSegment(stats.kkphim, stats.total, ((stats.ophim || 0) + (stats.nguonc || 0)) / (stats.total || 1) * 360)" fill="#60a5fa" />
</template>
<!-- Inner circle for donut effect -->
<circle cx="50" cy="50" r="24" fill="#0f172a" />
</svg>
<div class="absolute inset-0 grid place-items-center">
<div class="text-center">
<p class="text-2xl font-black text-white">{{ stats?.total?.toLocaleString() || 0 }}</p>
<p class="text-[10px] text-slate-400">total</p>
</div>
</div>
</div>
</div>
<div class="mt-4 space-y-2">
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2">
<span class="size-3 rounded-full bg-yellow-400" />
<span class="text-slate-300">OPhim</span>
</div>
<span class="font-semibold text-white">{{ stats?.ophim || 0 }} <span class="text-slate-500">({{ percentOf(stats?.ophim || 0, stats?.total || 0) }}%)</span></span>
</div>
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2">
<span class="size-3 rounded-full bg-green-400" />
<span class="text-slate-300">NguonC</span>
</div>
<span class="font-semibold text-white">{{ stats?.nguonc || 0 }} <span class="text-slate-500">({{ percentOf(stats?.nguonc || 0, stats?.total || 0) }}%)</span></span>
</div>
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2">
<span class="size-3 rounded-full bg-blue-400" />
<span class="text-slate-300">KKPhim</span>
</div>
<span class="font-semibold text-white">{{ stats?.kkphim || 0 }} <span class="text-slate-500">({{ percentOf(stats?.kkphim || 0, stats?.total || 0) }}%)</span></span>
</div>
</div>
</div>
<!-- Donut Chart: Series vs Single -->
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5">
<p class="text-sm font-semibold text-slate-400">Phim bộ vs Phim lẻ</p>
<div class="mt-4 flex items-center justify-center">
<div class="relative">
<svg viewBox="0 0 100 100" class="size-44">
<template v-if="stats?.series">
<path :d="donutSegment(stats.series, stats.total, 0)" fill="#3b82f6" />
</template>
<template v-if="stats?.single">
<path :d="donutSegment(stats.single, stats.total, (stats.series || 0) / (stats.total || 1) * 360)" fill="#a855f7" />
</template>
<circle cx="50" cy="50" r="24" fill="#0f172a" />
</svg>
<div class="absolute inset-0 grid place-items-center">
<div class="text-center">
<p class="text-2xl font-black text-white">{{ stats?.total?.toLocaleString() || 0 }}</p>
<p class="text-[10px] text-slate-400">total</p>
</div>
</div>
</div>
</div>
<div class="mt-4 space-y-2">
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2">
<span class="size-3 rounded-full bg-blue-500" />
<span class="text-slate-300">Phim bộ</span>
</div>
<span class="font-semibold text-white">{{ stats?.series || 0 }} <span class="text-slate-500">({{ percentOf(stats?.series || 0, stats?.total || 0) }}%)</span></span>
</div>
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2">
<span class="size-3 rounded-full bg-purple-500" />
<span class="text-slate-300">Phim lẻ</span>
</div>
<span class="font-semibold text-white">{{ stats?.single || 0 }} <span class="text-slate-500">({{ percentOf(stats?.single || 0, stats?.total || 0) }}%)</span></span>
</div>
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2">
<span class="size-3 rounded-full bg-green-400" />
<span class="text-slate-300">Đang hiển thị</span>
</div>
<span class="font-semibold text-green-400">{{ stats?.active || 0 }}</span>
</div>
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2">
<span class="size-3 rounded-full bg-slate-500" />
<span class="text-slate-300">Chưa hiển thị</span>
</div>
<span class="font-semibold text-slate-400">{{ stats?.inactive || 0 }}</span>
</div>
</div>
</div>
<!-- Bar Chart: Top Movies by Views -->
<div class="rounded-xl border border-white/10 bg-slate-900/50 p-5">
<p class="text-sm font-semibold text-slate-400">Top 5 phim nhiều lượt xem</p>
<div class="mt-4 space-y-3">
<div v-for="(movie, i) in stats?.topMovies || []" :key="movie.slug">
<div class="mb-1 flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="w-5 text-center text-xs font-black" :class="i === 0 ? 'text-yellow-400' : i === 1 ? 'text-slate-300' : i === 2 ? 'text-amber-600' : 'text-slate-500'">{{ i + 1 }}</span>
<span class="truncate text-xs text-white">{{ movie.name }}</span>
</div>
<span class="text-xs font-semibold text-slate-400">{{ movie.views?.toLocaleString() }}</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-white/10">
<div class="h-full rounded-full transition-all duration-500"
:class="i === 0 ? 'bg-yellow-400' : i === 1 ? 'bg-slate-300' : i === 2 ? 'bg-amber-500' : 'bg-slate-500'"
:style="{ width: barWidth(movie.views || 0) + '%' }" />
</div>
</div>
<p v-if="!stats?.topMovies?.length" class="text-center text-xs text-slate-500">Chưa dữ liệu</p>
</div>
<div class="mt-5 border-t border-white/10 pt-4">
<div class="flex items-center justify-between text-sm">
<span class="text-slate-400">Tổng lượt xem</span>
<span class="text-lg font-black text-white">{{ stats?.totalViews?.toLocaleString() || 0 }}</span>
</div>
</div>
</div>
</div>
<!-- Active vs Inactive Comparison Bar -->
<div class="mb-6 rounded-xl border border-white/10 bg-slate-900/50 p-5">
<p class="text-sm font-semibold text-slate-400">Trạng thái hiển thị</p>
<div class="mt-4 flex gap-6">
<div class="flex-1">
<div class="mb-2 flex items-center justify-between text-sm">
<div class="flex items-center gap-2">
<span class="size-3 rounded-full bg-green-400" />
<span class="text-white">Đang hiển thị</span>
</div>
<span class="font-semibold text-green-400">{{ stats?.active?.toLocaleString() || 0 }}</span>
</div>
<div class="h-4 overflow-hidden rounded-full bg-white/10">
<div class="h-full rounded-full bg-green-400 transition-all duration-500" :style="{ width: percentOf(stats?.active || 0, stats?.total || 0) + '%' }" />
</div>
<p class="mt-1 text-right text-xs text-slate-500">{{ percentOf(stats?.active || 0, stats?.total || 0) }}%</p>
</div>
<div class="flex-1">
<div class="mb-2 flex items-center justify-between text-sm">
<div class="flex items-center gap-2">
<span class="size-3 rounded-full bg-slate-500" />
<span class="text-white">Chưa hiển thị</span>
</div>
<span class="font-semibold text-slate-400">{{ stats?.inactive?.toLocaleString() || 0 }}</span>
</div>
<div class="h-4 overflow-hidden rounded-full bg-white/10">
<div class="h-full rounded-full bg-slate-500 transition-all duration-500" :style="{ width: percentOf(stats?.inactive || 0, stats?.total || 0) + '%' }" />
</div>
<p class="mt-1 text-right text-xs text-slate-500">{{ percentOf(stats?.inactive || 0, stats?.total || 0) }}%</p>
</div>
</div>
</div>
<!-- Sync Modal -->
<Teleport to="body">
<Transition name="modal-fade">
<div v-if="syncOpen" class="fixed inset-0 z-70 grid place-items-center px-3">
+27 -3
View File
@@ -12,6 +12,8 @@ useHead({
const searchInput = ref('')
const debouncedKeyword = ref('')
const statusFilter = ref('')
const sourceFilter = ref('')
const typeFilter = ref('')
const currentPage = ref(1)
const syncing = ref(false)
const syncOpen = ref(false)
@@ -34,11 +36,13 @@ const { data, refresh } = await useFetch('/api/admin/movies', {
page: currentPage.value,
keyword: debouncedKeyword.value,
status: statusFilter.value,
source: sourceFilter.value,
type: typeFilter.value,
limit: 20,
})),
})
watch(statusFilter, () => {
watch([statusFilter, sourceFilter, typeFilter], () => {
currentPage.value = 1
})
@@ -121,10 +125,23 @@ const totalPages = computed(() => data.value?.totalPages || 1)
</div>
<select v-model="statusFilter"
class="h-10 rounded-lg border border-white/10 bg-white/5 px-4 text-sm text-white outline-none focus:border-yellow-400/50">
<option value="" class="bg-slate-900">Tất cả</option>
<option value="" class="bg-slate-900">Tất cả trạng thái</option>
<option value="active" class="bg-slate-900">Đang hiển thị</option>
<option value="inactive" class="bg-slate-900">Chưa hiển thị</option>
</select>
<select v-model="sourceFilter"
class="h-10 rounded-lg border border-white/10 bg-white/5 px-4 text-sm text-white outline-none focus:border-yellow-400/50">
<option value="" class="bg-slate-900">Tất cả nguồn</option>
<option value="ophim" class="bg-slate-900">OPhim</option>
<option value="nguonc" class="bg-slate-900">NguonC</option>
<option value="kkphim" class="bg-slate-900">KKPhim</option>
</select>
<select v-model="typeFilter"
class="h-10 rounded-lg border border-white/10 bg-white/5 px-4 text-sm text-white outline-none focus:border-yellow-400/50">
<option value="" class="bg-slate-900">Tất cả loại</option>
<option value="series" class="bg-slate-900">Phim bộ</option>
<option value="single" class="bg-slate-900">Phim lẻ</option>
</select>
</div>
<div class="overflow-x-auto">
@@ -134,6 +151,7 @@ const totalPages = computed(() => data.value?.totalPages || 1)
<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">Nguồn</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">Trạng thái</th>
<th class="px-4 py-3 text-center">Chỉnh sửa</th>
@@ -156,6 +174,12 @@ const totalPages = computed(() => data.value?.totalPages || 1)
{{ movie.source?.toUpperCase() }}
</span>
</td>
<td class="px-4 py-3 text-center text-sm text-slate-300">
<template v-if="movie.episode && movie.episodeTotal && !movie.episode.includes('/')">
<span class="font-semibold text-yellow-300">Tập {{ movie.episode.replace(/[^0-9]/g, '') }}/{{ movie.episodeTotal.replace(/[^0-9]/g, '') }}</span>
</template>
<span v-else>{{ movie.episode || '-' }}</span>
</td>
<td class="px-4 py-3 text-center text-sm text-slate-300">
{{ (movie.views || 0).toLocaleString() }}
</td>
@@ -192,7 +216,7 @@ const totalPages = computed(() => data.value?.totalPages || 1)
</td>
</tr>
<tr v-if="!movies.length">
<td colspan="7" class="px-4 py-12 text-center text-sm text-slate-400">
<td colspan="8" class="px-4 py-12 text-center text-sm text-slate-400">
{{ debouncedKeyword ? 'Không tìm thấy phim nào.' : 'Chưa có phim nào. Bấm "Đồng bộ từ API" để lấy phim.' }}
</td>
</tr>
+103 -61
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ChevronLeft, ChevronRight, Heart, Info, Play, Trash2 } from 'lucide-vue-next'
import { ChevronLeft, ChevronRight, Heart, Info, Play, Plus, Trash2 } from 'lucide-vue-next'
import type { WatchHistoryItem } from '~/composables/useWatchHistory'
const { data, pending, error } = useFetch('/api/movies', {
@@ -150,6 +150,21 @@ function movieLink(movie: any) {
}
}
function getEpisodeDisplay(movie: any) {
const ep = movie?.episode
const total = movie?.episodeTotal
if (!ep) return undefined
const totalNum = total ? total.replace(/[^0-9]/g, '') : ''
const epNum = ep.replace(/[^0-9]/g, '')
if (epNum && totalNum && epNum !== totalNum) {
return `Tập ${epNum}/${totalNum}`
}
return ep
}
function selectHero(index: number) {
heroIndex.value = index
}
@@ -286,100 +301,127 @@ useHead({
<main class="min-h-screen overflow-hidden bg-black">
<AppHeader />
<section v-if="hero"
class="relative h-155 min-h-155 overflow-hidden pt-20 sm:h-170 sm:min-h-170 sm:pt-24 lg:h-180 lg:min-h-190 lg:pt-0">
<section v-if="hero" class="relative w-full h-[70vh] sm:h-[80vh] md:h-[85vh] lg:h-[90vh] overflow-hidden">
<TransitionGroup name="hero-fade">
<img v-for="(slide, index) in heroSlides" v-show="index === heroIndex" :key="`${slide.source}-${slide.slug}`"
:src="slide.poster || slide.thumb" :alt="slide.name"
class="absolute inset-0 h-full w-full object-cover object-center opacity-72 lg:object-[72%_center]">
class="absolute inset-0 h-full w-full object-cover object-top lg:object-[72%_center]">
</TransitionGroup>
<div class="absolute inset-0 bg-linear-to-r from-black via-black/76 to-black/14" />
<div class="absolute inset-0 bg-linear-to-t from-black via-black/16 to-black/34" />
<!-- Dot pattern overlay (desktop) -->
<div class="absolute inset-0 pointer-events-none opacity-30 hidden md:block"
style="background-image:radial-gradient(rgba(0,0,0,0.4) 0.4px, transparent 1px);background-size:3px 3px">
</div>
<!-- Mobile gradient overlay -->
<div class="absolute inset-0 pointer-events-none md:hidden" style="background:linear-gradient(to top, #0f111a 5%, rgba(15,17,21,0.6) 30%, transparent 60%),
radial-gradient(ellipse at center, transparent 60%, rgba(15,17,26,0.7) 100%)">
</div>
<!-- Desktop gradient overlay -->
<div class="absolute inset-0 pointer-events-none hidden md:block" style="background:linear-gradient(to right, rgba(15,17,26,0.6) 0%, rgba(15,17,26,0.1) 30%, transparent 60%),
linear-gradient(to top, #0f111a 0%, transparent 40%),
radial-gradient(ellipse at center, transparent 65%, rgba(15,17,26,0.8) 100%)">
</div>
<!-- Content overlay -->
<div class="absolute inset-0 z-20 flex items-end pb-0 md:pb-[6vw] lg:pb-[5vw] xl:pb-32 pointer-events-none">
<div class="max-w-350 w-full mx-auto px-4 md:px-8 pointer-events-none">
<div
class="relative mx-auto h-full max-w-390 px-4 pb-22 pt-20 sm:px-6 sm:pb-26 sm:pt-14 lg:px-8 lg:pb-0 lg:pt-38 xl:px-10">
<div class="max-w-140">
<h1 class="text-4xl font-black leading-tight text-white drop-shadow-2xl sm:text-5xl lg:text-[50px]">
class="max-w-xl mx-auto md:mx-0 md:max-w-[50vw] lg:max-w-2xl flex flex-col items-center md:items-start text-center md:text-left">
<!-- Title -->
<div class="mb-1 md:mb-4 lg:mb-6">
<NuxtLink :to="movieLink(hero)" class="md:pointer-events-none" aria-label="Xem chi tiết phim">
<h1 class="text-lg md:text-3xl lg:text-5xl font-black leading-tight text-white drop-shadow-2xl">
{{ hero.name }}
</h1>
<p class="mt-3 max-w-2xl text-sm font-black text-yellow-300 sm:text-base">
</NuxtLink>
</div>
<!-- Original name -->
<p class="text-xs md:text-sm lg:text-base font-medium text-[#FECF59] mb-2 md:mb-3 lg:mb-4 drop-shadow">
{{ hero.originName || 'Kho phim Hàn Vietsub mới cập nhật' }}
</p>
<div class="mt-4 flex flex-wrap gap-1.5 text-[13px] font-black text-white sm:gap-2">
<span v-if="hero.rating"
class="rounded-md bg-yellow-400 px-2.5 py-1.5 text-slate-950 shadow-lg shadow-yellow-950/30">
TMDb {{ hero.rating.toFixed(1) }}
</span>
<span v-if="hero.quality" class="rounded-md border border-white/20 bg-white/15 px-2.5 py-1.5">
<!-- Badges (mobile) -->
<div
class="flex justify-center md:justify-start items-center flex-wrap gap-2 mb-3 md:mb-4 text-[10px] md:text-xs text-white/90">
<div v-if="hero.rating"
class="flex items-center text-[11px] font-bold rounded overflow-hidden border border-solid border-[rgba(1,180,228,0.5)]">
<span class="bg-[#01B4E4] text-white px-1.5 py-0.5">TMDb</span>
<span class="bg-[rgba(1,180,228,0.1)] text-white px-1.5 py-0.5">{{ hero.rating.toFixed(1) }}</span>
</div>
<span v-if="hero.quality"
class="inline-flex items-center justify-center rounded-sm text-[#141414] font-black leading-none tracking-normal h-[22px] px-2 text-[11px]"
style="background-color:#ffd875;background-image:linear-gradient(220deg, #ffd875 0%, #ffe7a8 45%, #ffffff 100%)">
{{ hero.quality }}
</span>
<span v-if="hero.lang" class="rounded-md border border-white/20 bg-white/15 px-2.5 py-1.5">
{{ hero.lang }}
</span>
<span v-if="hero.year" class="rounded-md border border-white/20 bg-white/15 px-2.5 py-1.5">
{{ hero.year }}
</span>
<span v-if="hero.episode" class="rounded-md border border-white/20 bg-white/15 px-2.5 py-1.5">
{{ hero.episode }}
<span v-if="getEpisodeDisplay(hero)" class="px-2 py-0.75 rounded border border-white bg-black/40">
{{ getEpisodeDisplay(hero) }}
</span>
</div>
<div v-if="hero.categories?.length" class="mt-2 flex flex-wrap gap-1.5 text-[11px] font-black text-white">
<!-- Genre tags (desktop only) -->
<div v-if="hero.categories?.length"
class="hidden md:flex justify-center md:justify-start items-center flex-wrap gap-2 mb-4">
<span v-for="category in hero.categories.slice(0, 4)" :key="category"
class="rounded-md bg-white/12 px-2.5 py-1.5 backdrop-blur">
class="text-[10px] md:text-xs text-white/80 bg-white/10 px-2 py-0.75 rounded-md">
{{ category }}
</span>
</div>
<p class="mt-6 line-clamp-3 max-w-130 text-base font-bold leading-7 text-white drop-shadow">
<!-- Description (desktop only) -->
<div class="hidden md:block mb-4 lg:mb-8 max-w-sm lg:max-w-lg">
<p class="text-xs lg:text-sm text-white font-light leading-relaxed line-clamp-2 lg:line-clamp-3">
{{ heroDescription }}
</p>
<div class="mt-9 flex items-center gap-3">
<NuxtLink :to="movieLink(hero)"
class="grid size-14 shrink-0 place-items-center rounded-full bg-yellow-400 text-slate-950 shadow-2xl shadow-yellow-950/50 transition hover:scale-105 hover:bg-yellow-300 sm:size-16"
aria-label="Xem ngay">
<Play class="ml-1 size-7 fill-current" />
</NuxtLink>
<button type="button"
class="grid size-10 shrink-0 place-items-center rounded-full bg-white/15 text-white backdrop-blur transition hover:bg-white/25 sm:size-11"
aria-label="Yêu thích">
<Heart class="size-4 sm:size-5" />
</button>
<NuxtLink :to="movieLink(hero)"
class="grid size-10 shrink-0 place-items-center rounded-full bg-white/15 text-white backdrop-blur transition hover:bg-white/25 sm:size-11"
aria-label="Thông tin phim">
<Info class="size-4 sm:size-5" />
</NuxtLink>
</div>
</div>
<!-- Action buttons (desktop only) -->
<div class="hidden md:flex justify-center md:justify-start items-center gap-4 pointer-events-auto relative z-40">
<!-- Watch Now button -->
<NuxtLink :to="movieLink(hero)"
class="w-12 h-12 md:w-14 md:h-14 lg:w-16 lg:h-16 text-[#0f1115] rounded-full flex items-center justify-center transition-transform hover:scale-105 shadow-[0_0_15px_rgba(254,207,89,0.5)] shrink-0 pointer-events-auto"
style="background:linear-gradient(39deg, rgb(254, 207, 89), rgb(255, 241, 204))" aria-label="Xem ngay">
<Play class="size-6 md:size-7 lg:size-8 fill-current" />
</NuxtLink>
<!-- Info + Add to List combo button -->
<div
class="absolute bottom-14 right-4 hidden max-w-[calc(100%-2rem)] items-center gap-3 sm:flex sm:right-6 lg:bottom-18 lg:right-8 xl:right-10">
class="flex bg-white/5 border border-white/20 rounded-full backdrop-blur-md h-10 md:h-12 lg:h-14 items-center overflow-hidden pointer-events-auto">
<!-- Add to list button -->
<button type="button"
class="hidden size-9 shrink-0 place-items-center rounded-full border border-white/15 bg-black/35 text-white backdrop-blur transition hover:bg-white/15"
aria-label="Phim trước" @click="previousHero">
<ChevronLeft class="size-4 sm:size-5" />
class="group/btn w-16 md:w-20 h-full flex items-center justify-center transition-all hover:bg-white/10"
aria-label="Thêm vào danh sách">
<Plus class="size-5 md:size-6 text-white" />
</button>
<!-- Divider -->
<div class="w-px h-6 bg-white/30"></div>
<!-- Info/details button -->
<NuxtLink :to="movieLink(hero)"
class="group/link w-16 md:w-20 h-full flex items-center justify-center transition-colors text-white hover:text-[#FECF59]"
aria-label="Thông tin phim">
<Info class="size-6 md:size-7 text-white fill-none stroke-current group-hover/link:text-[#FECF59] transition-all" />
</NuxtLink>
</div>
</div>
</div>
</div>
</div>
<div class="no-scrollbar flex gap-3 overflow-x-auto pb-2">
<!-- Thumbnail navigation -->
<div
class="absolute bottom-4 md:bottom-8 left-1/2 -translate-x-1/2 md:left-auto md:right-8 md:translate-x-0 z-30 flex gap-2 overflow-x-auto max-w-[calc(100%-2rem)] md:max-w-md lg:max-w-lg pb-2 md:pb-0 snap-x px-2 pointer-events-auto scroll-smooth [&::-webkit-scrollbar]:hidden"
style="scrollbar-width:none;-ms-overflow-style:none">
<button v-for="(slide, index) in heroSlides" :key="`${slide.source}-${slide.slug}-thumb`" type="button"
class="relative h-14.5 w-26 shrink-0 overflow-hidden rounded-md border bg-slate-900 shadow-lg shadow-black/30 transition"
:class="index === heroIndex ? 'border-white ring-2 ring-white/40' : 'border-white/15 opacity-70 hover:opacity-100'"
class="relative shrink-0 w-[14vw] sm:w-[10vw] md:w-[7vw] lg:w-[6vw] xl:w-[5vw] max-w-10 md:max-w-18.75 lg:max-w-21.25 aspect-square md:aspect-video transition-all duration-300 rounded-full md:rounded-lg overflow-hidden snap-center transform-gpu border-2"
:class="index === heroIndex ? 'border-white/90 opacity-100 scale-100 shadow-md' : 'border-transparent opacity-80 scale-95 hover:scale-100 hover:opacity-100'"
style="-webkit-mask-image:-webkit-radial-gradient(white, black)" :aria-label="`Chuyển đến phim ${index + 1}`"
@click="selectHero(index)">
<img :src="slide.thumb || slide.poster" :alt="slide.name" class="h-full w-full object-cover">
<img :src="slide.thumb || slide.poster" :alt="slide.name"
class="absolute inset-0 h-full w-full object-cover rounded-full md:rounded-lg">
</button>
</div>
<button type="button"
class="hidden size-9 shrink-0 place-items-center rounded-full border border-white/15 bg-black/35 text-white backdrop-blur transition hover:bg-white/15"
aria-label="Phim tiếp theo" @click="nextHero">
<ChevronRight class="size-4 sm:size-5" />
</button>
</div>
</div>
</section>
<section v-else-if="pending"
+17 -2
View File
@@ -62,6 +62,21 @@ function formatWeekday(dateKey: string) {
return weekdays[date.getDay()] || ''
}
function getEpisodeDisplay(movie: any) {
const ep = movie?.episode
const total = movie?.episodeTotal
if (!ep) return 'Mới cập nhật'
const totalNum = total ? total.replace(/[^0-9]/g, '') : ''
const epNum = ep.replace(/[^0-9]/g, '')
if (epNum && totalNum && epNum !== totalNum) {
return `Tập ${epNum}/${totalNum}`
}
return ep
}
// Build map ngày -> phim
const moviesByDate = computed(() => {
const map = new Map<string, any[]>()
@@ -190,14 +205,14 @@ useHead({
<NuxtLink v-for="movie in selectedDay.items" :key="`${movie.source}-${movie.slug}`"
:to="{ path: `/phim/${movie.slug}`, query: { source: movie.source } }"
class="schedule-card">
<img :src="movie.thumb || movie.poster" :alt="movie.name"
<img :src="movie.poster || movie.thumb" :alt="movie.name"
class="schedule-card-image">
<div class="schedule-card-body">
<h2 class="schedule-card-title">
{{ movie.name }}
</h2>
<p class="schedule-card-episode">
{{ movie.episode || 'Mới cập nhật' }}
{{ getEpisodeDisplay(movie) }}
</p>
</div>
</NuxtLink>
+1 -1
View File
@@ -236,7 +236,7 @@ useHead(() => ({
<div class="relative mx-auto max-w-390 px-3 pb-8 pt-40 sm:px-6 sm:pb-12 sm:pt-52 lg:px-8 lg:pt-56 xl:px-10">
<div class="lg:grid lg:grid-cols-[22rem_minmax(0,1fr)] lg:gap-6">
<aside class="mb-3 flex flex-col items-center text-center lg:mb-6 lg:items-start lg:text-left">
<img :src="movie.thumb || movie.poster" :alt="movie.name"
<img :src="movie.poster || movie.thumb" :alt="movie.name"
class="mx-auto aspect-2/3 w-29 rounded-md object-cover shadow-xl shadow-black/40 sm:w-52 lg:w-full">
<h1 class="mt-4 text-[1.35rem] font-black leading-tight sm:text-2xl lg:mt-5">{{ movie.name }}</h1>
+23 -3
View File
@@ -72,6 +72,21 @@ function movieLink(movie: any) {
}
}
function getEpisodeDisplay(movie: any) {
const ep = movie.episode
const total = movie.episodeTotal
if (!ep) return undefined
const totalNum = total ? total.replace(/[^0-9]/g, '') : ''
const epNum = ep.replace(/[^0-9]/g, '')
if (epNum && totalNum && epNum !== totalNum) {
return `Tập ${epNum}/${totalNum}`
}
return ep
}
function nextPage() {
page.value += 1
window.scrollTo({ top: 0, behavior: 'smooth' })
@@ -164,15 +179,20 @@ useHead({
:to="movieLink(movie)" class="group">
<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">
<img :src="movie.thumb || movie.poster" :alt="movie.name"
<img :src="movie.poster || movie.thumb" :alt="movie.name"
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">
<p class="line-clamp-2 text-sm font-bold leading-snug text-white">{{ movie.name }}</p>
<p class="mt-1 truncate text-xs text-yellow-100">{{ movie.episode || movie.year || movie.quality }}</p>
<p class="mt-1 truncate text-xs text-yellow-100">{{ getEpisodeDisplay(movie) || movie.year || movie.quality }}</p>
</div>
<span v-if="movie.sources?.length > 1" class="absolute left-2 top-2 rounded bg-yellow-400 px-2 py-1 text-xs font-black text-slate-950">
<span v-if="movie.sources?.length > 1"
class="absolute left-2 top-2 rounded bg-yellow-400 px-2 py-1 text-xs font-black text-slate-950">
{{ movie.sources.length }} server
</span>
<span v-if="getEpisodeDisplay(movie)"
class="absolute right-2 top-2 rounded bg-red-600/90 px-2 py-1 text-xs font-black text-white backdrop-blur-sm">
{{ getEpisodeDisplay(movie) }}
</span>
</div>
</NuxtLink>
</div>
+1 -1
View File
@@ -1289,7 +1289,7 @@ useHead(() => ({
<div class="mt-5 grid gap-6 lg:grid-cols-[minmax(0,1fr)_24rem]">
<section>
<div class="flex gap-4">
<img :src="movie.thumb || movie.poster" :alt="movie.name"
<img :src="movie.poster || movie.thumb" :alt="movie.name"
class="hidden h-28 w-20 rounded-md object-cover sm:block">
<div>
<h1 class="text-[1.35rem] font-black leading-tight sm:text-2xl">{{ movie.name }}</h1>
+12 -11
View File
@@ -1,5 +1,5 @@
import { movies } from '../../database/schema'
import { desc, eq, like, sql } from 'drizzle-orm'
import { desc, eq, like, and, sql } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
const db = useDb()
@@ -9,17 +9,18 @@ export default defineEventHandler(async (event) => {
const offset = (page - 1) * limit
const keyword = typeof query.keyword === 'string' ? query.keyword.trim() : ''
const status = typeof query.status === 'string' ? query.status : ''
const source = typeof query.source === 'string' ? query.source : ''
const type = typeof query.type === 'string' ? query.type : ''
let whereClause = undefined
if (keyword && status === 'active') {
whereClause = sql`${like(movies.name, `%${keyword}%`)} AND ${movies.active} = true`
} else if (keyword) {
whereClause = like(movies.name, `%${keyword}%`)
} else if (status === 'active') {
whereClause = eq(movies.active, true)
} else if (status === 'inactive') {
whereClause = eq(movies.active, false)
}
const conditions: any[] = []
if (keyword) conditions.push(like(movies.name, `%${keyword}%`))
if (status === 'active') conditions.push(eq(movies.active, true))
else if (status === 'inactive') conditions.push(eq(movies.active, false))
if (source) conditions.push(eq(movies.source, source))
if (type) conditions.push(eq(movies.type, type))
const whereClause = conditions.length > 0 ? and(...conditions) : undefined
const [items, countResult] = await Promise.all([
db
+24 -1
View File
@@ -1,5 +1,5 @@
import { movies } from '../../database/schema'
import { sql } from 'drizzle-orm'
import { sql, eq } from 'drizzle-orm'
export default defineEventHandler(async () => {
const db = useDb()
@@ -8,11 +8,34 @@ export default defineEventHandler(async () => {
total: sql<number>`count(*)`,
active: sql<number>`SUM(CASE WHEN ${movies.active} = true THEN 1 ELSE 0 END)`,
inactive: sql<number>`SUM(CASE WHEN ${movies.active} = false THEN 1 ELSE 0 END)`,
series: sql<number>`SUM(CASE WHEN ${movies.type} = 'series' THEN 1 ELSE 0 END)`,
single: sql<number>`SUM(CASE WHEN ${movies.type} = 'single' THEN 1 ELSE 0 END)`,
totalViews: sql<number>`COALESCE(SUM(${movies.views}), 0)`,
ophim: sql<number>`SUM(CASE WHEN ${movies.source} = 'ophim' THEN 1 ELSE 0 END)`,
nguonc: sql<number>`SUM(CASE WHEN ${movies.source} = 'nguonc' THEN 1 ELSE 0 END)`,
kkphim: sql<number>`SUM(CASE WHEN ${movies.source} = 'kkphim' THEN 1 ELSE 0 END)`,
}).from(movies)
const topMovies = await db.select({
name: movies.name,
views: movies.views,
slug: movies.slug,
source: movies.source,
}).from(movies)
.where(eq(movies.active, true))
.orderBy(sql`${movies.views} DESC`)
.limit(5)
return {
total: stats?.total ?? 0,
active: stats?.active ?? 0,
inactive: stats?.inactive ?? 0,
series: stats?.series ?? 0,
single: stats?.single ?? 0,
totalViews: stats?.totalViews ?? 0,
ophim: stats?.ophim ?? 0,
nguonc: stats?.nguonc ?? 0,
kkphim: stats?.kkphim ?? 0,
topMovies: topMovies || [],
}
})
+46 -1
View File
@@ -1,9 +1,50 @@
import { movies } from '../../database/schema'
import { eq, and } from 'drizzle-orm'
import { getOphimKoreanMovies, getNguoncKoreanMovies, getKkphimKoreanMovies } from '../../utils/movies'
import {
getOphimKoreanMovies,
getNguoncKoreanMovies,
getKkphimKoreanMovies,
getOphimDetail,
getNguoncDetail,
getKkphimDetail,
} from '../../utils/movies'
const SYNC_PAGES = 10
const ALL_SOURCES = ['ophim', 'nguonc', 'kkphim'] as const
const DETAIL_CONCURRENCY = 5
const detailFetchers = {
ophim: getOphimDetail,
nguonc: getNguoncDetail,
kkphim: getKkphimDetail,
}
async function fetchEpisodeTotal(source: string, slug: string): Promise<string | undefined> {
const fetcher = detailFetchers[source as keyof typeof detailFetchers]
if (!fetcher) return undefined
try {
const detail = await fetcher(slug)
return detail.episodeTotal || undefined
} catch {
return undefined
}
}
async function enrichWithEpisodeTotal(moviesList: any[]): Promise<void> {
const moviesNeedTotal = moviesList.filter((m) => m.type !== 'single' && !m.episodeTotal && m.slug)
for (let i = 0; i < moviesNeedTotal.length; i += DETAIL_CONCURRENCY) {
const batch = moviesNeedTotal.slice(i, i + DETAIL_CONCURRENCY)
const results = await Promise.allSettled(
batch.map((movie) => fetchEpisodeTotal(movie.source, movie.slug)),
)
results.forEach((result, idx) => {
if (result.status === 'fulfilled' && result.value) {
batch[idx].episodeTotal = result.value
}
})
}
}
export default defineEventHandler(async (event) => {
const db = useDb()
@@ -38,6 +79,8 @@ export default defineEventHandler(async (event) => {
}
}
await enrichWithEpisodeTotal(allMovies)
let created = 0
let updated = 0
@@ -59,6 +102,7 @@ export default defineEventHandler(async (event) => {
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,
@@ -82,6 +126,7 @@ export default defineEventHandler(async (event) => {
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,
+1
View File
@@ -72,6 +72,7 @@ function mapMovieToResponse(movie: any) {
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,
+17 -2
View File
@@ -19,7 +19,9 @@ export default defineEventHandler(async (event) => {
.where(and(eq(movies.slug, slug), eq(movies.source, source), eq(movies.active, true)))
.limit(1)
if (!existing.length) {
let recordToUpdate = existing.length ? existing[0] : null
if (!recordToUpdate) {
const fallback = await db
.select()
.from(movies)
@@ -29,9 +31,22 @@ export default defineEventHandler(async (event) => {
if (!fallback.length) {
throw createError({ statusCode: 404, statusMessage: 'Phim không tồn tại hoặc đã bị ẩn' })
}
recordToUpdate = fallback[0]
}
const refs = parseSourceRefs(query.sources || query.srcs, query.source, slug)
return await getMovieDetailGroup(refs)
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 `episode_total` varchar(100);
@@ -0,0 +1,336 @@
{
"version": "5",
"dialect": "mysql",
"id": "0c944d62-4add-4bdb-b5cf-764479ba4b22",
"prevId": "12998654-722b-4627-9970-1f407a7d7edd",
"tables": {
"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
},
"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
},
"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": {}
}
}
@@ -43,6 +43,13 @@
"when": 1784709136940,
"tag": "0005_illegal_purple_man",
"breakpoints": true
},
{
"idx": 6,
"version": "5",
"when": 1784780104334,
"tag": "0006_sour_retro_girl",
"breakpoints": true
}
]
}
+1
View File
@@ -23,6 +23,7 @@ export const movies = mysqlTable('movies', {
year: int('year'),
time: varchar('time', { length: 100 }),
episode: varchar('episode', { length: 100 }),
episodeTotal: varchar('episode_total', { length: 100 }),
quality: varchar('quality', { length: 50 }),
lang: varchar('lang', { length: 50 }),
type: varchar('type', { length: 50 }),
+6
View File
@@ -12,6 +12,7 @@ export interface NormalizedMovie {
year?: number
time?: string
episode?: string
episodeTotal?: string
quality?: string
lang?: string
type?: string
@@ -294,6 +295,7 @@ export function normalizeOphimMovie(movie: any, pathImage = OPHIM_IMAGE): Normal
year: Number(movie?.year) || undefined,
time: text(movie?.time),
episode: text(movie?.episode_current),
episodeTotal: movie?.episode_total ? String(movie.episode_total).trim() || undefined : undefined,
quality: text(movie?.quality),
lang: text(movie?.lang),
type: text(movie?.type),
@@ -316,6 +318,7 @@ export function normalizeKkphimMovie(movie: any, pathImage = KKPHIM_IMAGE): Norm
year: Number(movie?.year) || undefined,
time: text(movie?.time),
episode: text(movie?.episode_current),
episodeTotal: movie?.episode_total != null ? String(movie.episode_total).trim() || undefined : undefined,
quality: text(movie?.quality),
lang: text(movie?.lang),
type: text(movie?.type),
@@ -340,6 +343,9 @@ function normalizeNguoncMovie(movie: any): NormalizedMovie {
year: Number(movie?.year || movie?.release_year) || undefined,
time: text(movie?.time || movie?.duration),
episode: text(movie?.episode_current || movie?.current_episode),
episodeTotal: (movie?.episode_total || movie?.total_episodes) != null
? String(movie.episode_total || movie.total_episodes).trim() || undefined
: undefined,
quality: text(movie?.quality),
lang: text(movie?.language || movie?.lang),
type: text(movie?.type),