From 72bc657d86284e003ae76d647d89fa4525bedd14 Mon Sep 17 00:00:00 2001 From: ngthanhvu Date: Tue, 28 Jul 2026 03:29:55 -0400 Subject: [PATCH] feat: implement admin interface for managing custom movie sources and servers with new API endpoint and database schema updates --- app/components/HeroSlider.vue | 2 +- app/components/HomeMovieCard.vue | 4 +- app/pages/admin/phim/[id].vue | 691 ++++++++++++++---- app/pages/admin/phim/index.vue | 36 +- app/pages/index.vue | 16 + app/pages/lich-chieu.vue | 2 +- app/pages/phim/[slug].vue | 2 +- app/pages/phim/index.vue | 2 +- app/pages/xem/[slug].vue | 4 +- server/api/admin/movies/[id].put.ts | 28 + server/api/admin/movies/[id]/sources.get.ts | 59 ++ server/api/admin/sync.post.ts | 149 ++-- server/api/movies/[slug].get.ts | 65 +- .../migrations/0013_add_custom_servers.sql | 1 + .../database/migrations/0013_late_vapor.sql | 1 + .../migrations/meta/0013_snapshot.json | 573 +++++++++++++++ server/database/migrations/meta/_journal.json | 7 + server/database/schema.ts | 2 + server/utils/movies.ts | 8 +- 19 files changed, 1423 insertions(+), 229 deletions(-) create mode 100644 server/api/admin/movies/[id]/sources.get.ts create mode 100644 server/database/migrations/0013_add_custom_servers.sql create mode 100644 server/database/migrations/0013_late_vapor.sql create mode 100644 server/database/migrations/meta/0013_snapshot.json diff --git a/app/components/HeroSlider.vue b/app/components/HeroSlider.vue index 06d0fef..c64601b 100644 --- a/app/components/HeroSlider.vue +++ b/app/components/HeroSlider.vue @@ -48,7 +48,7 @@ defineExpose({ goTo }) class="absolute inset-0 h-full w-full" @slide-change="onSlideChange"> - diff --git a/app/components/HomeMovieCard.vue b/app/components/HomeMovieCard.vue index f74e7a4..4a78743 100644 --- a/app/components/HomeMovieCard.vue +++ b/app/components/HomeMovieCard.vue @@ -96,7 +96,7 @@ onBeforeUnmount(() => { @mouseenter="showPreview" @mouseleave="scheduleHide" @focus="showPreview" @blur="scheduleHide">
-

{{ movie.name }}

@@ -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" :style="previewStyle" @mouseenter="keepPreview" @mouseleave="scheduleHide">
- +

diff --git a/app/pages/admin/phim/[id].vue b/app/pages/admin/phim/[id].vue index dec09a3..84f3d46 100644 --- a/app/pages/admin/phim/[id].vue +++ b/app/pages/admin/phim/[id].vue @@ -7,15 +7,55 @@ const route = useRoute() const movieId = Number(route.params.id) 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({ 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 customThumb = ref('') const customContent = ref('') -const customEpisodes = ref<{ name: string, linkEmbed: string, linkM3u8: string }[]>([]) +const customServers = ref([]) +const customActors = ref<{ name: string, originalName: string, role: string, avatar: string }[]>([]) +const expandedSources = ref>({}) +const selectedTargetServer = ref(0) +const collapsedServers = ref>({}) +const expandedLinks = ref>({}) const saving = ref(false) const saved = ref(false) @@ -24,18 +64,154 @@ watch(movie, (m) => { customPoster.value = m.customPoster || '' customThumb.value = m.customThumb || '' customContent.value = m.customContent || '' - customEpisodes.value = m.customEpisodes?.length - ? m.customEpisodes.map((ep: any) => ({ name: ep.name || '', linkEmbed: ep.linkEmbed || '', linkM3u8: ep.linkM3u8 || '' })) + customActors.value = m.actors?.length + ? 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 }) -function addEpisode() { - customEpisodes.value.push({ name: '', linkEmbed: '', linkM3u8: '' }) +const availableEpisodes = computed(() => { + 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) { - customEpisodes.value.splice(index, 1) +function removeServer(serverIndex: number) { + 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() { @@ -48,7 +224,8 @@ async function handleSave() { customPoster: customPoster.value, customThumb: customThumb.value, customContent: customContent.value, - customEpisodes: customEpisodes.value, + actors: customActors.value, + customServers: customServers.value, }, }) saved.value = true @@ -66,6 +243,25 @@ function clearField(field: 'customPoster' | 'customThumb' | 'customContent') { if (field === 'customThumb') customThumb.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()) + } + } +}