feat: add HLS proxy, view tracking, and player improvements

- Add HLS proxy middleware for CORS bypass
- Add view tracking API endpoint
- Improve HLS player with proxy fallback mechanism
- Add custom episode support in admin
- Update admin movie list with view count
- Add description expand/collapse on movie detail
- Fix hydration warnings in watch page
This commit is contained in:
ngthanhvu
2026-07-22 11:43:14 -04:00
parent d982449464
commit 221c132e60
15 changed files with 765 additions and 69 deletions
+5 -3
View File
@@ -89,7 +89,7 @@ watch(() => route.path, () => {
</script> </script>
<template> <template>
<header class="fixed inset-x-0 top-0 z-50 bg-gradient-to-b from-black/80 via-black/40 to-transparent"> <header class="fixed inset-x-0 top-0 z-50 bg-linear-to-b from-black/80 via-black/40 to-transparent">
<nav class="mx-auto flex max-w-390 items-center gap-3 px-4 py-3 sm:px-6 lg:gap-6 lg:px-8 xl:px-10"> <nav class="mx-auto flex max-w-390 items-center gap-3 px-4 py-3 sm:px-6 lg:gap-6 lg:px-8 xl:px-10">
<AppLogo /> <AppLogo />
@@ -115,7 +115,8 @@ watch(() => route.path, () => {
class="inline-flex h-9 shrink-0 cursor-pointer items-center gap-1.5 rounded-full bg-white px-2.5 pl-1 text-xs font-black text-slate-950 shadow-xl shadow-black/20 transition hover:bg-yellow-100" class="inline-flex h-9 shrink-0 cursor-pointer items-center gap-1.5 rounded-full bg-white px-2.5 pl-1 text-xs font-black text-slate-950 shadow-xl shadow-black/20 transition hover:bg-yellow-100"
aria-label="Tài khoản thành viên" @click="handleMemberClick"> aria-label="Tài khoản thành viên" @click="handleMemberClick">
<template v-if="user"> <template v-if="user">
<span class="grid size-7 place-items-center rounded-full bg-yellow-400/20 text-xs font-black text-yellow-400"> <span
class="grid size-7 place-items-center rounded-full bg-yellow-400/20 text-xs font-black text-yellow-400">
{{ displayInitial }} {{ displayInitial }}
</span> </span>
<span class="max-w-24 truncate">{{ displayName }}</span> <span class="max-w-24 truncate">{{ displayName }}</span>
@@ -171,7 +172,8 @@ watch(() => route.path, () => {
</div> </div>
<div v-if="user" class="flex items-center gap-3 border-b border-white/10 px-4 py-4"> <div v-if="user" class="flex items-center gap-3 border-b border-white/10 px-4 py-4">
<span class="grid size-9 place-items-center rounded-full bg-yellow-400/10 text-sm font-black text-yellow-400"> <span
class="grid size-9 place-items-center rounded-full bg-yellow-400/10 text-sm font-black text-yellow-400">
{{ displayInitial }} {{ displayInitial }}
</span> </span>
<div class="min-w-0"> <div class="min-w-0">
+16 -2
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { Circle, Heart, Play } from 'lucide-vue-next' import { ChevronDown, ChevronUp, Circle, Heart, Play } from 'lucide-vue-next'
const props = defineProps<{ const props = defineProps<{
movie: { movie: {
@@ -21,6 +21,7 @@ const props = defineProps<{
}>() }>()
const isPreviewVisible = ref(false) const isPreviewVisible = ref(false)
const isDescriptionExpanded = ref(false)
const previewStyle = ref<Record<string, string>>({}) const previewStyle = ref<Record<string, string>>({})
let hideTimer: ReturnType<typeof setTimeout> | undefined let hideTimer: ReturnType<typeof setTimeout> | undefined
@@ -62,6 +63,7 @@ function scheduleHide() {
if (hideTimer) clearTimeout(hideTimer) if (hideTimer) clearTimeout(hideTimer)
hideTimer = setTimeout(() => { hideTimer = setTimeout(() => {
isPreviewVisible.value = false isPreviewVisible.value = false
isDescriptionExpanded.value = false
}, 90) }, 90)
} }
@@ -137,13 +139,25 @@ onBeforeUnmount(() => {
movie.episode }}</span> movie.episode }}</span>
</div> </div>
<p class="mt-3 line-clamp-2 text-xs font-bold leading-6 text-slate-200"> <div class="mt-3">
<p class="text-xs font-bold leading-6 text-slate-200" :class="isDescriptionExpanded ? '' : 'line-clamp-1'">
<template v-if="movie.countries?.length">{{ movie.countries.slice(0, 2).join(' ') }}</template> <template v-if="movie.countries?.length">{{ movie.countries.slice(0, 2).join(' ') }}</template>
<template v-if="movie.countries?.length && movie.categories?.length"> </template> <template v-if="movie.countries?.length && movie.categories?.length"> </template>
<template v-if="movie.categories?.length">{{ movie.categories.slice(0, 3).join(' ') }}</template> <template v-if="movie.categories?.length">{{ movie.categories.slice(0, 3).join(' ') }}</template>
<template v-if="!movie.countries?.length && !movie.categories?.length">Phim Hàn Quốc Vietsub mới cập <template v-if="!movie.countries?.length && !movie.categories?.length">Phim Hàn Quốc Vietsub mới cập
nhật</template> nhật</template>
</p> </p>
<button type="button"
class="mt-1 inline-flex items-center gap-0.5 text-[10px] font-semibold text-yellow-300 transition hover:text-yellow-200"
@click.stop="isDescriptionExpanded = !isDescriptionExpanded">
<template v-if="isDescriptionExpanded">
Thu gọn <ChevronUp class="size-3" />
</template>
<template v-else>
Xem thêm <ChevronDown class="size-3" />
</template>
</button>
</div>
</div> </div>
</NuxtLink> </NuxtLink>
</Transition> </Transition>
+5 -1
View File
@@ -134,6 +134,7 @@ const totalPages = computed(() => data.value?.totalPages || 1)
<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</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>
<th class="px-4 py-3 text-center">Chỉnh sửa</th> <th class="px-4 py-3 text-center">Chỉnh sửa</th>
<th class="px-4 py-3 text-center">Thao tác</th> <th class="px-4 py-3 text-center">Thao tác</th>
@@ -155,6 +156,9 @@ const totalPages = computed(() => data.value?.totalPages || 1)
{{ movie.source?.toUpperCase() }} {{ movie.source?.toUpperCase() }}
</span> </span>
</td> </td>
<td class="px-4 py-3 text-center text-sm text-slate-300">
{{ (movie.views || 0).toLocaleString() }}
</td>
<td class="px-4 py-3 text-center"> <td class="px-4 py-3 text-center">
<span class="rounded-full px-2.5 py-1 text-xs font-semibold" <span class="rounded-full px-2.5 py-1 text-xs font-semibold"
:class="movie.active ? 'bg-green-400/10 text-green-400' : 'bg-slate-400/10 text-slate-400'"> :class="movie.active ? 'bg-green-400/10 text-green-400' : 'bg-slate-400/10 text-slate-400'">
@@ -188,7 +192,7 @@ const totalPages = computed(() => data.value?.totalPages || 1)
</td> </td>
</tr> </tr>
<tr v-if="!movies.length"> <tr v-if="!movies.length">
<td colspan="6" class="px-4 py-12 text-center text-sm text-slate-400"> <td colspan="7" 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.' }} {{ 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> </td>
</tr> </tr>
+3 -2
View File
@@ -96,8 +96,9 @@ function movieUpdateTime(movie: any) {
} }
const topTrending = computed(() => [...homeCatalog.value] const topTrending = computed(() => [...homeCatalog.value]
.sort((a: any, b: any) => (Number(b.rating) || 0) - (Number(a.rating) || 0) .filter((movie: any) => (movie.views || 0) > 0)
|| movieUpdateTime(b) - movieUpdateTime(a)) .sort((a: any, b: any) => (Number(b.views) || 0) - (Number(a.views) || 0)
|| (Number(b.rating) || 0) - (Number(a.rating) || 0))
.slice(0, 10)) .slice(0, 10))
const latestMovies = computed(() => withoutMovies(homeCatalog.value, topTrending.value) const latestMovies = computed(() => withoutMovies(homeCatalog.value, topTrending.value)
+23 -6
View File
@@ -6,6 +6,7 @@ const requestedSource = computed(() => String(route.query.source || 'nguonc'))
const requestedSources = computed(() => typeof route.query.srcs === 'string' ? route.query.srcs : '') const requestedSources = computed(() => typeof route.query.srcs === 'string' ? route.query.srcs : '')
const selectedServer = ref(0) const selectedServer = ref(0)
const movieInfoOpen = ref(false) const movieInfoOpen = ref(false)
const contentExpanded = ref(false)
const activeTab = ref<'episodes' | 'actors'>('episodes') const activeTab = ref<'episodes' | 'actors'>('episodes')
const isFavoriteMovie = ref(false) const isFavoriteMovie = ref(false)
const actionMessage = ref('') const actionMessage = ref('')
@@ -164,6 +165,10 @@ async function shareMovie() {
onMounted(async () => { onMounted(async () => {
await refreshFavoriteState() await refreshFavoriteState()
await $fetch(`/api/movies/${route.params.slug}/view`, {
method: 'POST',
query: { source: route.query.source },
}).catch(() => {})
}) })
watch(requestedSource, () => { watch(requestedSource, () => {
@@ -253,10 +258,16 @@ useHead(() => ({
</div> </div>
<div class="mt-4 hidden space-y-3 text-sm leading-6 text-slate-300 sm:block lg:mt-5"> <div class="mt-4 hidden space-y-3 text-sm leading-6 text-slate-300 sm:block lg:mt-5">
<p v-if="movie.content"> <div v-if="movie.content">
<span class="block font-black text-white">Giới thiệu:</span> <span class="block font-black text-white">Giới thiệu:</span>
{{ movie.content }} <p class="mt-1" :class="contentExpanded ? '' : 'line-clamp-3'">{{ movie.content }}</p>
</p> <button type="button"
class="mt-1 inline-flex items-center gap-1 text-xs font-semibold text-yellow-300 transition hover:text-yellow-200"
@click="contentExpanded = !contentExpanded">
{{ contentExpanded ? 'Thu gọn' : 'Xem thêm' }}
<ChevronDown class="size-3 transition" :class="contentExpanded ? 'rotate-180' : ''" />
</button>
</div>
<p><span class="font-black text-white">Số tập:</span> {{ <p><span class="font-black text-white">Số tập:</span> {{
episodeCount || movie.episode || 'Đang cập nhật' episodeCount || movie.episode || 'Đang cập nhật'
}}</p> }}</p>
@@ -278,10 +289,16 @@ useHead(() => ({
<div v-if="movieInfoOpen" <div v-if="movieInfoOpen"
class="mb-4 space-y-3 border-b border-white/10 pb-4 text-sm leading-6 text-slate-300 sm:hidden"> class="mb-4 space-y-3 border-b border-white/10 pb-4 text-sm leading-6 text-slate-300 sm:hidden">
<p v-if="movie.content"> <div v-if="movie.content">
<span class="block font-black text-white">Giới thiệu:</span> <span class="block font-black text-white">Giới thiệu:</span>
{{ movie.content }} <p class="mt-1" :class="contentExpanded ? '' : 'line-clamp-3'">{{ movie.content }}</p>
</p> <button type="button"
class="mt-1 inline-flex items-center gap-1 text-xs font-semibold text-yellow-300 transition hover:text-yellow-200"
@click="contentExpanded = !contentExpanded">
{{ contentExpanded ? 'Thu gọn' : 'Xem thêm' }}
<ChevronDown class="size-3 transition" :class="contentExpanded ? 'rotate-180' : ''" />
</button>
</div>
<p><span class="font-black text-white">Số tập:</span> {{ <p><span class="font-black text-white">Số tập:</span> {{
episodeCount || movie.episode || 'Đang cập nhật' episodeCount || movie.episode || 'Đang cập nhật'
}}</p> }}</p>
+64 -10
View File
@@ -97,6 +97,12 @@ const canUseHls = computed(() => Boolean(hlsPlayerUrl.value))
const actorSummary = computed(() => (movie.value?.actors ?? []).map((actor: any) => actor.name).filter(Boolean).slice(0, 6).join(', ')) const actorSummary = computed(() => (movie.value?.actors ?? []).map((actor: any) => actor.name).filter(Boolean).slice(0, 6).join(', '))
const durationSeconds = computed(() => Math.floor(videoDuration.value || parseDurationSeconds(movie.value?.time || ''))) const durationSeconds = computed(() => Math.floor(videoDuration.value || parseDurationSeconds(movie.value?.time || '')))
const hasNextEpisode = computed(() => Boolean(activeServer.value?.episodes?.[selectedEpisode.value + 1])) const hasNextEpisode = computed(() => Boolean(activeServer.value?.episodes?.[selectedEpisode.value + 1]))
function getProxyUrl(url: string): string {
const encoded = btoa(url)
return `/api/proxy-m3u8/${encoded}`
}
const progressPercent = computed(() => { const progressPercent = computed(() => {
if (!durationSeconds.value || !progressSeconds.value) return 0 if (!durationSeconds.value || !progressSeconds.value) return 0
return Math.min(Math.max((progressSeconds.value / durationSeconds.value) * 100, 0), 100) return Math.min(Math.max((progressSeconds.value / durationSeconds.value) * 100, 0), 100)
@@ -365,8 +371,13 @@ function formatPlayerTime(seconds = 0) {
return `${minutes}:${String(remainingSeconds).padStart(2, '0')}` return `${minutes}:${String(remainingSeconds).padStart(2, '0')}`
} }
async function setupHlsPlayer(autoplay = false) { async function setupHlsPlayer(autoplay = false, tryProxy = true) {
if (!import.meta.client || !videoRef.value || !hlsPlayerUrl.value) return console.log('[HLS] Setup called', { tryProxy, hasVideoRef: !!videoRef.value, hlsUrl: hlsPlayerUrl.value })
if (!import.meta.client || !videoRef.value || !hlsPlayerUrl.value) {
console.log('[HLS] Early return - missing requirements')
return
}
destroyHlsPlayer() destroyHlsPlayer()
hlsFatalRetryCount = 0 hlsFatalRetryCount = 0
@@ -378,27 +389,47 @@ async function setupHlsPlayer(autoplay = false) {
video.muted = isVideoMuted.value video.muted = isVideoMuted.value
video.playbackRate = playbackRate.value video.playbackRate = playbackRate.value
if (video.canPlayType('application/vnd.apple.mpegurl')) { // Use proxy if tryProxy is true, otherwise use direct URL
video.src = hlsPlayerUrl.value let sourceUrl = tryProxy ? getProxyUrl(hlsPlayerUrl.value) : hlsPlayerUrl.value
console.log('[HLS] Source URL:', sourceUrl)
// Force use hls.js when using proxy, as native HLS doesn't handle proxy URLs well
if (video.canPlayType('application/vnd.apple.mpegurl') && !tryProxy) {
console.log('[HLS] Using native HLS (Safari) - direct URL only')
// Native HLS (Safari) - only when using direct URL
video.src = sourceUrl
scheduleHlsFallback() scheduleHlsFallback()
// Listen for errors on native HLS
const handleError = () => {
console.log('[HLS] Native HLS error, falling back to hls.js')
video.removeEventListener('error', handleError)
// Fall back to hls.js instead of embed
tryProxy = false
setupHlsPlayer(autoplay, false)
}
video.addEventListener('error', handleError)
} else { } else {
console.log('[HLS] Using hls.js library')
try {
const { default: HlsPlayer } = await import('hls.js') const { default: HlsPlayer } = await import('hls.js')
if (!HlsPlayer.isSupported()) { if (!HlsPlayer.isSupported()) {
console.log('[HLS] hls.js not supported')
fallbackToEmbed('Trình duyệt chưa hỗ trợ HLS, đã chuyển sang chế độ Nhúng.') fallbackToEmbed('Trình duyệt chưa hỗ trợ HLS, đã chuyển sang chế độ Nhúng.')
return return
} }
if (!HlsPlayer.isSupported()) {
hlsErrorMessage.value = 'Trình duyệt chưa hỗ trợ HLS, bạn dùng chế độ Nhúng nhé.'
return
}
hlsPlayer = new HlsPlayer({ hlsPlayer = new HlsPlayer({
enableWorker: true, enableWorker: true,
lowLatencyMode: true, lowLatencyMode: true,
xhrSetup: (xhr, url) => {
xhr.withCredentials = false
},
}) })
hlsPlayer.on(HlsPlayer.Events.MANIFEST_PARSED, () => { hlsPlayer.on(HlsPlayer.Events.MANIFEST_PARSED, () => {
console.log('[HLS] Manifest parsed successfully')
clearHlsFallbackTimer() clearHlsFallbackTimer()
qualityLevels.value = hlsPlayer?.levels qualityLevels.value = hlsPlayer?.levels
.map((level, index) => ({ .map((level, index) => ({
@@ -409,11 +440,27 @@ async function setupHlsPlayer(autoplay = false) {
selectedQuality.value = -1 selectedQuality.value = -1
applyResumeProgress() applyResumeProgress()
}) })
hlsPlayer.on(HlsPlayer.Events.ERROR, (_event, data) => { hlsPlayer.on(HlsPlayer.Events.ERROR, (_event, data) => {
console.log('[HLS] Error:', data.type, data.details, data.fatal)
if (!data.fatal) return if (!data.fatal) return
// If using proxy and it fails, try direct URL
if (tryProxy) {
console.log('[HLS] Proxy failed, trying direct URL')
hlsErrorMessage.value = 'Proxy lỗi, đang thử trực tiếp...'
hlsPlayer?.destroy()
// Retry with direct URL
setTimeout(() => {
setupHlsPlayer(autoplay, false)
}, 100)
return
}
hlsFatalRetryCount += 1 hlsFatalRetryCount += 1
if (hlsFatalRetryCount >= 2) { if (hlsFatalRetryCount >= 2) {
console.log('[HLS] Too many errors, falling back to embed')
fallbackToEmbed('HLS tải không ổn định, đã chuyển sang chế độ Nhúng.') fallbackToEmbed('HLS tải không ổn định, đã chuyển sang chế độ Nhúng.')
return return
} }
@@ -428,9 +475,16 @@ async function setupHlsPlayer(autoplay = false) {
hlsErrorMessage.value = 'Link HLS đang lỗi, bạn chuyển sang chế độ Nhúng nhé.' hlsErrorMessage.value = 'Link HLS đang lỗi, bạn chuyển sang chế độ Nhúng nhé.'
} }
}) })
hlsPlayer.loadSource(hlsPlayerUrl.value)
console.log('[HLS] Loading source and attaching media')
hlsPlayer.loadSource(sourceUrl)
hlsPlayer.attachMedia(video) hlsPlayer.attachMedia(video)
scheduleHlsFallback() scheduleHlsFallback()
} catch (error) {
console.error('[HLS] Setup error:', error)
fallbackToEmbed('Lỗi khởi tạo HLS player, đã chuyển sang chế độ Nhúng.')
return
}
} }
if (autoplay) { if (autoplay) {
+1
View File
@@ -76,6 +76,7 @@ function mapMovieToResponse(movie: any) {
lang: movie.lang || undefined, lang: movie.lang || undefined,
type: movie.type || undefined, type: movie.type || undefined,
rating: movie.rating || undefined, rating: movie.rating || undefined,
views: movie.views || 0,
categories: movie.categories || [], categories: movie.categories || [],
countries: movie.countries || [], countries: movie.countries || [],
sources: movie.sources || [], sources: movie.sources || [],
+25
View File
@@ -0,0 +1,25 @@
import { movies } from '../../../database/schema'
import { eq, and, sql } from 'drizzle-orm'
export default defineEventHandler(async (event) => {
const slug = getRouterParam(event, 'slug')
if (!slug) {
throw createError({ statusCode: 400, message: 'Thiếu slug phim' })
}
const query = getQuery(event)
const source = typeof query.source === 'string' ? query.source : ''
const db = useDb()
const whereClause = source
? and(eq(movies.slug, slug), eq(movies.source, source), eq(movies.active, true))
: and(eq(movies.slug, slug), eq(movies.active, true))
await db
.update(movies)
.set({ views: sql`${movies.views} + 1` })
.where(whereClause)
return { success: true }
})
+83
View File
@@ -0,0 +1,83 @@
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const url = body.url as string
if (!url) {
throw createError({
statusCode: 400,
message: 'Missing url parameter',
})
}
// Validate URL to prevent SSRF
if (!url.startsWith('http://') && !url.startsWith('https://')) {
throw createError({
statusCode: 400,
message: 'Invalid URL protocol',
})
}
try {
const response = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept': '*/*',
'Referer': new URL(url).origin,
'Origin': new URL(url).origin,
},
})
if (!response.ok) {
throw createError({
statusCode: response.status,
message: `Upstream error: ${response.statusText}`,
})
}
const contentType = response.headers.get('content-type') || 'application/octet-stream'
const isM3u8 = url.includes('.m3u8') || contentType.includes('mpegurl')
setHeader(event, 'access-control-allow-origin', '*')
setHeader(event, 'access-control-allow-methods', 'POST, OPTIONS')
setHeader(event, 'cache-control', 'public, max-age=3600')
// For m3u8 files, rewrite relative URLs to use proxy
if (isM3u8) {
let body = await response.text()
const baseUrl = url.substring(0, url.lastIndexOf('/') + 1)
// Rewrite absolute URLs
body = body.replace(/^(https?:\/\/[^\s]+)$/gm, (match) => {
const encoded = Buffer.from(match).toString('base64')
return `/api/proxy-m3u8/${encoded}`
})
// Rewrite relative URLs (not starting with http/https or /api/proxy-m3u8/)
body = body.replace(/^([^#\s][^\n]*)$/gm, (match) => {
if (match.startsWith('http://') || match.startsWith('https://') || match.startsWith('/api/proxy-m3u8/')) {
return match
}
const absoluteUrl = new URL(match, baseUrl).href
const encoded = Buffer.from(absoluteUrl).toString('base64')
return `/api/proxy-m3u8/${encoded}`
})
setHeader(event, 'content-type', 'application/vnd.apple.mpegurl')
return body
} else {
// For .ts files and other binary data, stream directly
setHeader(event, 'content-type', contentType)
const contentLength = response.headers.get('content-length')
if (contentLength) {
setHeader(event, 'content-length', contentLength)
}
return response.body
}
} catch (error: any) {
if (error.statusCode) throw error
throw createError({
statusCode: 500,
message: `Proxy error: ${error.message}`,
})
}
})
+77
View File
@@ -0,0 +1,77 @@
export default defineEventHandler(async (event) => {
// Get base64 encoded URL from path after /api/proxy-m3u8/
const path = event.path || event.node?.req?.url || ''
const base64Url = path.replace('/api/proxy-m3u8/', '')
if (!base64Url) {
throw createError({ statusCode: 400, message: 'Missing url parameter' })
}
// Decode URL from base64
let url: string
try {
url = Buffer.from(base64Url, 'base64').toString('utf-8')
} catch {
throw createError({ statusCode: 400, message: 'Invalid URL encoding' })
}
if (!url.startsWith('http://') && !url.startsWith('https://')) {
throw createError({ statusCode: 400, message: 'Invalid URL protocol' })
}
try {
const response = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept': '*/*',
'Referer': new URL(url).origin,
'Origin': new URL(url).origin,
},
})
if (!response.ok) {
throw createError({ statusCode: response.status, message: `Upstream error: ${response.statusText}` })
}
const contentType = response.headers.get('content-type') || 'application/octet-stream'
const isM3u8 = url.includes('.m3u8') || contentType.includes('mpegurl')
setHeader(event, 'access-control-allow-origin', '*')
setHeader(event, 'access-control-allow-methods', 'GET, OPTIONS')
setHeader(event, 'cache-control', 'public, max-age=3600')
if (isM3u8) {
let body = await response.text()
const baseUrl = url.substring(0, url.lastIndexOf('/') + 1)
// Rewrite absolute URLs
body = body.replace(/^(https?:\/\/[^\s]+)$/gm, (match) => {
const encoded = Buffer.from(match).toString('base64')
return `/api/proxy-m3u8/${encoded}`
})
// Rewrite relative URLs
body = body.replace(/^([^#\s][^\n]*)$/gm, (match) => {
if (match.startsWith('http://') || match.startsWith('https://') || match.startsWith('/api/proxy-m3u8/')) {
return match
}
const absoluteUrl = new URL(match, baseUrl).href
const encoded = Buffer.from(absoluteUrl).toString('base64')
return `/api/proxy-m3u8/${encoded}`
})
setHeader(event, 'content-type', 'application/vnd.apple.mpegurl')
return body
} else {
setHeader(event, 'content-type', contentType)
const contentLength = response.headers.get('content-length')
if (contentLength) {
setHeader(event, 'content-length', contentLength)
}
return response.body
}
} catch (error: any) {
if (error.statusCode) throw error
throw createError({ statusCode: 500, message: `Proxy error: ${error.message}` })
}
})
@@ -0,0 +1 @@
ALTER TABLE `movies` ADD `views` int DEFAULT 0 NOT NULL;
@@ -0,0 +1,329 @@
{
"version": "5",
"dialect": "mysql",
"id": "12998654-722b-4627-9970-1f407a7d7edd",
"prevId": "39aa52ed-af36-43ed-ab3e-2222785d1c00",
"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
},
"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": {}
}
}
@@ -36,6 +36,13 @@
"when": 1784707891329, "when": 1784707891329,
"tag": "0004_opposite_sasquatch", "tag": "0004_opposite_sasquatch",
"breakpoints": true "breakpoints": true
},
{
"idx": 5,
"version": "5",
"when": 1784709136940,
"tag": "0005_illegal_purple_man",
"breakpoints": true
} }
] ]
} }
+1
View File
@@ -27,6 +27,7 @@ export const movies = mysqlTable('movies', {
lang: varchar('lang', { length: 50 }), lang: varchar('lang', { length: 50 }),
type: varchar('type', { length: 50 }), type: varchar('type', { length: 50 }),
rating: int('rating'), rating: int('rating'),
views: int('views').notNull().default(0),
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[]>(),
+80
View File
@@ -0,0 +1,80 @@
export default defineEventHandler(async (event) => {
const path = event.path || event.node?.req?.url || ''
// Only handle /api/proxy-m3u8/ paths
if (!path.startsWith('/api/proxy-m3u8/')) {
return
}
const base64Url = path.replace('/api/proxy-m3u8/', '')
if (!base64Url) {
throw createError({ statusCode: 400, message: 'Missing url parameter' })
}
// Decode URL from base64
let url: string
try {
url = Buffer.from(base64Url, 'base64').toString('utf-8')
} catch {
throw createError({ statusCode: 400, message: 'Invalid URL encoding' })
}
if (!url.startsWith('http://') && !url.startsWith('https://')) {
throw createError({ statusCode: 400, message: 'Invalid URL protocol' })
}
try {
const response = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept': '*/*',
'Referer': new URL(url).origin,
'Origin': new URL(url).origin,
},
})
if (!response.ok) {
throw createError({ statusCode: response.status, message: `Upstream error: ${response.statusText}` })
}
const contentType = response.headers.get('content-type') || 'application/octet-stream'
const isM3u8 = url.includes('.m3u8') || contentType.includes('mpegurl')
setHeader(event, 'access-control-allow-origin', '*')
setHeader(event, 'access-control-allow-methods', 'GET, OPTIONS')
setHeader(event, 'cache-control', 'public, max-age=3600')
if (isM3u8) {
let body = await response.text()
const baseUrl = url.substring(0, url.lastIndexOf('/') + 1)
body = body.replace(/^(https?:\/\/[^\s]+)$/gm, (match) => {
const encoded = Buffer.from(match).toString('base64')
return `/api/proxy-m3u8/${encoded}`
})
body = body.replace(/^([^#\s][^\n]*)$/gm, (match) => {
if (match.startsWith('http://') || match.startsWith('https://') || match.startsWith('/api/proxy-m3u8/')) {
return match
}
const absoluteUrl = new URL(match, baseUrl).href
const encoded = Buffer.from(absoluteUrl).toString('base64')
return `/api/proxy-m3u8/${encoded}`
})
setHeader(event, 'content-type', 'application/vnd.apple.mpegurl')
return body
} else {
setHeader(event, 'content-type', contentType)
const contentLength = response.headers.get('content-length')
if (contentLength) {
setHeader(event, 'content-length', contentLength)
}
return response.body
}
} catch (error: any) {
if (error.statusCode) throw error
throw createError({ statusCode: 500, message: `Proxy error: ${error.message}` })
}
})