first commit

This commit is contained in:
gotolombok
2026-01-31 17:21:32 +08:00
commit f839fbd63a
109 changed files with 19204 additions and 0 deletions

248
lib/api.ts Normal file
View File

@@ -0,0 +1,248 @@
export interface MovieImage {
url: string;
width: number;
height: number;
}
export interface Subject {
subjectId: string;
subjectType: number; // 1 for Movie, 2 for Series
title: string;
description: string;
releaseDate: string;
genre: string;
cover: MovieImage;
image?: MovieImage;
countryName: string;
imdbRatingValue: string;
detailPath: string;
duration?: number;
isDracin?: boolean;
}
export interface BannerItem {
id: string;
title: string;
image: MovieImage;
subjectId: string;
subjectType: number;
subject?: Subject;
description?: string;
}
export interface HomeSection {
type: string;
title: string;
subjects: Subject[];
banner?: {
items: BannerItem[];
};
customData?: {
items: any[];
};
}
export interface HomePageData {
homeList: HomeSection[];
operatingList: HomeSection[];
platformList?: any[];
}
export interface Season {
se: number;
maxEp: number;
}
export interface Resource {
seasons: Season[];
}
export interface MovieDetail {
subject: Subject;
resource?: Resource;
stars?: any[];
}
export interface SourceData {
id: string;
url: string;
resolution: number;
size: string;
}
export interface SourcesResponse {
downloads: SourceData[];
captions?: Caption[];
processedSources?: { quality: number, directUrl: string }[];
}
const BASE_URL = process.env.NEXT_PUBLIC_MOVIE_API_URL || "https://mapi.geofani.online/api";
const HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"x-api-key": process.env.API_KEY || "masrockey"
};
export async function getHomepageData(): Promise<HomePageData> {
const res = await fetch(`${BASE_URL}/home`, {
next: { revalidate: 3600 },
headers: HEADERS
});
if (!res.ok) {
throw new Error("Failed to fetch homepage data");
}
const json = await res.json();
return json;
}
export interface Caption {
id: string;
lan: string;
lanName: string;
url: string;
size: string;
delay: number;
}
export interface PlayerData {
sources: SourceData[];
captions: Caption[];
}
export async function getSources(subjectId: string, detailPath: string, season: number = 0, episode: number = 0): Promise<PlayerData> {
const params = new URLSearchParams({
subjectId,
detailPath,
season: season.toString(),
episode: episode.toString()
});
const res = await fetch(`${BASE_URL}/download?${params.toString()}`, {
next: { revalidate: 300 }, // 5 mins cache for download links
headers: HEADERS
});
if (!res.ok) {
const text = await res.text().catch(() => '');
console.error(`getSources failed: ${res.status} ${text}`);
throw new Error(`Failed to fetch sources: ${res.status}`);
}
const json = await res.json();
const data = json;
const sources: SourceData[] = [];
// Map downloads to sources
if (data.downloads && Array.isArray(data.downloads)) {
sources.push(...data.downloads);
}
return {
sources,
captions: data.captions || []
};
}
export async function getMovieDetail(subjectId: string): Promise<MovieDetail> {
const res = await fetch(`${BASE_URL}/detail?subjectId=${subjectId}`, {
next: { revalidate: 3600 },
headers: HEADERS
});
if (!res.ok) {
// console.error(`Failed to fetch detail for subjectId: ${subjectId}`);
throw new Error(`Failed to fetch detail for ${subjectId}`);
}
const json = await res.json();
return json; // The endpoint returns the detail object directly, not wrapped in data
}
export interface SearchResponse {
items: Subject[];
pager: Pager;
}
export async function getSearch(query: string, page: number = 1, signal?: AbortSignal): Promise<SearchResponse> {
const url = `${BASE_URL}/search?keyword=${encodeURIComponent(query)}&page=${page}`;
try {
const res = await fetch(url, {
next: { revalidate: 3600 },
headers: HEADERS,
signal
});
if (!res.ok) throw new Error("Search failed");
const json = await res.json();
return json;
} catch (error: any) {
if (error.name === 'AbortError') throw error;
console.error(`[DEBUG] Search fetch failed for ${url}:`, error);
throw error;
}
}
export interface Pager {
hasMore: boolean;
nextPage: string;
page: string;
perPage: number;
totalCount: number;
}
export interface TrendingResponse {
subjectList: Subject[];
pager: Pager;
}
export async function getTrending(page: number = 1): Promise<TrendingResponse> {
const res = await fetch(`${BASE_URL}/trending?page=${page}`, { next: { revalidate: 3600 }, headers: HEADERS });
if (!res.ok) throw new Error("Failed to fetch trending");
const json = await res.json();
return {
subjectList: json.subjectList || [],
pager: json.pager || { hasMore: false, nextPage: "1", page: "0", perPage: 18, totalCount: 0 }
};
}
export async function getRank(): Promise<{ movie: Subject[], tv: Subject[] }> {
const res = await fetch(`${BASE_URL}/rank`, { next: { revalidate: 3600 }, headers: HEADERS });
if (!res.ok) throw new Error("Failed to fetch rank");
const json = await res.json();
return {
movie: json.movie || [],
tv: json.tv || []
};
}
export async function getRecommendations(subjectId: string): Promise<Subject[]> {
const res = await fetch(`${BASE_URL}/recommend?subjectId=${subjectId}`, { next: { revalidate: 3600 }, headers: HEADERS });
if (!res.ok) {
// Optional: Do not throw error here to avoid breaking the page if recommendations fail
console.error("Failed to fetch recommendations");
return [];
}
const json = await res.json();
return json.items || [];
}
export async function generateStreamLink(url: string) {
const res = await fetch(`${BASE_URL}/generate-stream-link?url=${encodeURIComponent(url)}`, {
cache: 'no-store', // Do not cache generated stream links
headers: HEADERS
});
if (!res.ok) {
// If it fails, fallback to the original URL
console.warn("generateStreamLink failed, using original url");
return url;
}
const json = await res.json();
if (json.streamUrl) {
// Append API Key to the stream URL for player access
const streamUrl = new URL(json.streamUrl);
streamUrl.searchParams.append("apikey", process.env.API_KEY || "masrockey");
return streamUrl.toString();
}
return url;
}

371
lib/dramabox.ts Normal file
View File

@@ -0,0 +1,371 @@
import { cookies } from 'next/headers';
const BASE_URL = process.env.DRAMABOX_API_URL || "http://localhost:7000";
const HEADERS = {
"x-api-key": process.env.API_KEY || "masrockey"
};
async function getLang() {
try {
const cookieStore = await cookies();
return cookieStore.get('dracin_lang')?.value || 'in';
} catch (e) {
return 'in';
}
}
export interface DramaboxItem {
id: number;
name: string;
cover: string;
chapterCount: number;
introduction: string;
tags: string[];
playCount: number;
}
export interface DramaboxTag {
tagId: number;
tagName: string;
tagEnName: string;
}
export interface DramaboxDetail {
id: number;
name: string;
cover: string;
introduction: string;
tags: DramaboxTag[];
chapterCount: number;
playCount: number;
releaseTime?: string;
actors?: string[];
}
// Update interface to match reality (string IDs)
export interface DramaboxChapter {
chapterId: string; // Changed from number to string as per chapters API
chapterIndex: number;
chapterName: string;
videoPath: string; // m3u8 often in 'videoPath' or handled by stream endpoint
}
export async function getDracinHome(page: number = 1, size: number = 20): Promise<DramaboxItem[]> {
try {
const lang = await getLang();
const res = await fetch(`${BASE_URL}/api/home?page=${page}&size=${size}&lang=${lang}`, {
next: { revalidate: 3600 },
headers: HEADERS
});
if (!res.ok) return [];
const json = await res.json();
return json.data || [];
} catch (error) {
console.error("getDracinHome error:", error);
return [];
}
}
export async function getDracinDetail(bookId: string): Promise<{ drama: DramaboxDetail | null, chapters: DramaboxChapter[] }> {
try {
const lang = await getLang();
const [detailRes, chaptersData] = await Promise.all([
fetch(`${BASE_URL}/api/detail/${bookId}/v2?lang=en`, { next: { revalidate: 3600 }, headers: HEADERS }), // Keep detail in EN if preferred or change to lang? Usually details like title might be better in local language if available, but let's stick to what works or use lang for consistency. Let's use 'en' for structure keys if they vary, but content might be better in 'lang'. Let's try to pass 'lang' but mapped to API codes. The user said ID/EN. API expects 'in' or 'en'.
getDracinChapters(bookId)
]);
if (!detailRes.ok) {
// console.warn(`[getDracinDetail] Failed to fetch. Status: ${detailRes.status} for ID: ${bookId}`);
// Return empty structure instead of throwing to prevent crashing Promise.all or clogging logs with stack traces
return { drama: null, chapters: [] };
}
const json = await detailRes.json();
let drama = null;
if (json.success && json.data) {
drama = json.data.drama;
if (drama && !drama.cover) {
// Fallback to constructed URL pattern based on ID
drama.cover = `https://hwztchapter.dramaboxdb.com/data/cppartner/4x2/42x0/420x0/${drama.id}/${drama.id}.jpg`;
}
}
// Use chapters from the specialized endpoint if available, otherwise fallback to detail's chapters
const chapters = (chaptersData.length > 0) ? chaptersData : (json.data?.chapters || []);
// Map to ensure type compatibility if needed (mostly id types)
const formattedChapters: DramaboxChapter[] = chapters.map((c: any) => ({
chapterId: String(c.chapterId),
chapterIndex: c.chapterIndex,
chapterName: c.chapterName,
videoPath: c.videoPath
}));
return {
drama: drama,
chapters: formattedChapters
};
} catch (error) {
console.error("getDracinDetail error:", error);
return { drama: null, chapters: [] };
}
}
export interface DracinStreamResponse {
success: boolean;
data?: {
bookId: string;
allEps: number;
chapter: {
video: {
mp4?: string;
m3u8?: string;
};
};
};
}
// Interface for the detailed chapter response from /api/chapters
export interface DracinChapterDetail {
chapterId: string;
chapterIndex: number;
chapterName: string;
videoPath: string;
cdnList: Array<{
cdnDomain: string;
videoPathList: Array<{
quality: number;
videoPath: string;
}>;
}>;
}
export async function getDracinChapters(bookId: string): Promise<DracinChapterDetail[]> {
try {
const lang = await getLang();
// Request a large size to get all chapters (default might be small)
const res = await fetch(`${BASE_URL}/api/chapters/${bookId}?lang=${lang}&size=300`, {
next: { revalidate: 300 }, // 5 mins cache
headers: HEADERS
});
if (!res.ok) return [];
const json = await res.json();
return json.data || [];
} catch (error) {
console.error("getDracinChapters error:", error);
return [];
}
}
export async function getDracinStream(bookId: string, episode: number): Promise<string | null> {
// 1. Primary: Video path from Chapter List (Requested by user)
try {
const chapters = await getDracinChapters(bookId);
// Find matching chapter.
// Note: 'episode' param passed from URL is typically the chapterIndex.
const chapter = chapters.find(c => c.chapterIndex === episode);
if (chapter && chapter.videoPath) {
// Some videoPaths might be signed URLs that expire, so we don't cache this function result (already set in fetch options)
return chapter.videoPath;
}
} catch (error) {
console.error("getDracinStream (chapters) error:", error);
}
// 2. Fallback: Direct stream endpoint (Secondary/Backup)
try {
const lang = await getLang();
const res = await fetch(`${BASE_URL}/api/stream?bookId=${bookId}&episode=${episode}&lang=${lang}`, {
cache: 'no-store',
headers: HEADERS
});
if (res.ok) {
const json: DracinStreamResponse = await res.json();
if (json.success && json.data && json.data.chapter && json.data.chapter.video) {
return json.data.chapter.video.mp4 || json.data.chapter.video.m3u8 || null;
}
}
} catch (error) {
console.warn("getDracinStream /api/stream fallback failed", error);
}
return null;
}
const mapBookItem = (b: any): DramaboxItem => {
let cover = b.coverWap || b.cover || "";
if (cover && cover.startsWith('/')) {
cover = `https://hwztchapter.dramaboxdb.com${cover}`;
}
const id = b.bookId || b.id;
return {
id: id,
name: b.bookName || b.name,
cover: cover,
chapterCount: b.chapterCount,
introduction: b.introduction,
tags: b.tags || [],
playCount: b.playCount || 0
};
};
// Helper to extract books from nested column structure or parse flat list
function extractBooksFromColumns(data: any): DramaboxItem[] {
if (!data) return [];
// Check for double nested data
const rootData = data.data || data;
// Case 1: Array of Columns (e.g. VIP)
// Or Case 2: Array of Books (e.g. Recommend, sometimes)
// But API structure varies.
// If it's an array directly
if (Array.isArray(rootData)) {
if (rootData.length === 0) return [];
// Check first item to determine type
const first = rootData[0];
if (first.bookList && Array.isArray(first.bookList)) {
// It's a list of Columns
let allBooks: DramaboxItem[] = [];
rootData.forEach((col: any) => {
if (col.bookList && Array.isArray(col.bookList)) {
allBooks = [...allBooks, ...col.bookList.map(mapBookItem)];
}
});
return allBooks;
} else {
// It's likely a list of Books directly
return rootData.map(mapBookItem);
}
}
// Case 3: Object with columnVoList
const columns = rootData.columnVoList || data.columnVoList;
if (columns && Array.isArray(columns)) {
let allBooks: DramaboxItem[] = [];
columns.forEach((col: any) => {
if (col.bookList && Array.isArray(col.bookList)) {
allBooks = [...allBooks, ...col.bookList.map(mapBookItem)];
}
});
return allBooks;
}
return [];
}
export async function getDracinRecommend(page: number = 1, size: number = 20): Promise<DramaboxItem[]> {
try {
const lang = await getLang();
const res = await fetch(`${BASE_URL}/api/recommend?page=${page}&size=${size}&lang=${lang}`, {
next: { revalidate: 3600 },
headers: HEADERS
});
if (!res.ok) return [];
const json = await res.json();
return extractBooksFromColumns(json.data);
} catch (error) {
console.error("getDracinRecommend error:", error);
return [];
}
}
export async function getDracinVip(page: number = 1, size: number = 20): Promise<DramaboxItem[]> {
try {
const lang = await getLang();
const res = await fetch(`${BASE_URL}/api/vip?page=${page}&size=${size}&lang=${lang}`, {
next: { revalidate: 3600 },
headers: HEADERS
});
if (!res.ok) return [];
const json = await res.json();
return extractBooksFromColumns(json.data);
} catch (error) {
console.error("getDracinVip error:", error);
return [];
}
}
export async function searchDracin(keyword: string, page: number = 1, size: number = 20, signal?: AbortSignal): Promise<DramaboxItem[]> {
try {
const lang = await getLang();
const url = `${BASE_URL}/api/search?keyword=${encodeURIComponent(keyword)}&page=${page}&size=${size}&lang=${lang}`;
const res = await fetch(url, {
next: { revalidate: 0 },
headers: HEADERS,
signal
});
if (!res.ok) return [];
const json = await res.json();
// Search usually returns a specific list in data.list or just data
const list = json.data?.list || (Array.isArray(json.data) ? json.data : []);
// If it looks like a list of books (has bookId/id), map it
if (list.length > 0 && (list[0].bookId || list[0].id)) {
return list.map(mapBookItem);
}
return extractBooksFromColumns(json.data);
} catch (error) {
console.error("searchDracin error:", error);
return [];
}
}
export async function getDracinCategories() {
try {
const lang = await getLang();
const res = await fetch(`${BASE_URL}/api/categories?lang=${lang}`, { headers: HEADERS });
if (!res.ok) {
throw new Error(`Failed to fetch categories: ${res.status}`);
}
const json = await res.json();
// Check if data is array
if (json.success && Array.isArray(json.data)) {
return json.data as { id: number; name: string; replaceName: string; checked: boolean }[];
}
return [];
} catch (error) {
console.error("Error fetching dracin categories:", error);
return [];
}
}
export async function getDracinCategoryDetail(id: string, page: number = 1, size: number = 20): Promise<{ books: DramaboxItem[], categoryName: string, totalPages: number }> {
try {
const lang = await getLang();
const res = await fetch(`${BASE_URL}/api/category/${id}?page=${page}&size=${size}&lang=${lang}`, {
next: { revalidate: 3600 },
headers: HEADERS
});
if (!res.ok) return { books: [], categoryName: "", totalPages: 0 };
const json = await res.json();
if (json.success && json.data) {
const books = json.data.bookList ? json.data.bookList.map(mapBookItem) : [];
// Find current category name from types list if available, or just use ID/Placeholder
let categoryName = "";
if (json.data.types && Array.isArray(json.data.types)) {
const currentType = json.data.types.find((t: any) => t.id == id);
if (currentType) categoryName = currentType.name;
}
return {
books,
categoryName,
totalPages: json.data.pages || 0
};
}
return { books: [], categoryName: "", totalPages: 0 };
} catch (error) {
console.error("getDracinCategoryDetail error:", error);
return { books: [], categoryName: "", totalPages: 0 };
}
}

48
lib/history-service.ts Normal file
View File

@@ -0,0 +1,48 @@
import { createClient } from "@/lib/supabase/client";
export const savePlaybackProgress = async ({
subjectId,
type,
title,
poster,
season,
episode,
lastPosition,
duration
}: {
subjectId: string;
type: 'movie' | 'series' | 'dracin';
title: string;
poster: string;
season?: number;
episode?: number;
lastPosition: number;
duration: number;
}) => {
const supabase = createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return;
try {
const { error } = await supabase
.from('history')
.upsert({
user_id: user.id,
subject_id: subjectId,
type,
title,
poster,
season: season || null,
episode: episode || null,
last_position: Math.floor(lastPosition),
duration: Math.floor(duration),
updated_at: new Date().toISOString()
}, {
onConflict: 'user_id,subject_id,type'
});
if (error) console.error("Error saving history:", error);
} catch (e) {
console.error("Save progress error:", e);
}
};

15
lib/supabase/admin.ts Normal file
View File

@@ -0,0 +1,15 @@
import { createClient } from '@supabase/supabase-js'
export function createAdminClient() {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{
auth: {
autoRefreshToken: false,
persistSession: false
}
}
)
}

9
lib/supabase/client.ts Normal file
View File

@@ -0,0 +1,9 @@
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
}

View File

@@ -0,0 +1,58 @@
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({
request,
})
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) =>
request.cookies.set(name, value)
)
supabaseResponse = NextResponse.next({
request,
})
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
)
},
},
}
)
const {
data: { user },
} = await supabase.auth.getUser()
if (request.nextUrl.pathname.startsWith('/admin')) {
if (!user) {
const url = request.nextUrl.clone()
url.pathname = '/login'
return NextResponse.redirect(url)
}
if (user.app_metadata?.role !== 'admin') {
const url = request.nextUrl.clone()
url.pathname = '/'
return NextResponse.redirect(url)
}
}
if (request.nextUrl.pathname.startsWith('/login') && user) {
const url = request.nextUrl.clone()
url.pathname = '/admin'
return NextResponse.redirect(url)
}
return supabaseResponse
}

30
lib/supabase/server.ts Normal file
View File

@@ -0,0 +1,30 @@
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createClient() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// The `setAll` method was called from a Server Component.
// This can be ignored if you have middleware refreshing
// user sessions.
}
},
},
}
)
}

6
lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}