diff --git a/src/app/api/detail/route.ts b/src/app/api/detail/route.ts index 49ae8d5..bb6b515 100644 --- a/src/app/api/detail/route.ts +++ b/src/app/api/detail/route.ts @@ -1,212 +1,7 @@ import { NextResponse } from 'next/server'; -import { API_CONFIG, ApiSite, getApiSites, getCacheTime } from '@/lib/config'; - -const M3U8_PATTERN = /(https?:\/\/[^"'\s]+?\.m3u8)/g; - -// 清理 HTML 标签的工具函数 -function cleanHtmlTags(text: string): string { - if (!text) return ''; - return text - .replace(/<[^>]+>/g, '\n') // 将 HTML 标签替换为换行 - .replace(/\n+/g, '\n') // 将多个连续换行合并为一个 - .replace(/[ \t]+/g, ' ') // 将多个连续空格和制表符合并为一个空格,但保留换行符 - .replace(/^\n+|\n+$/g, '') // 去掉首尾换行 - .replace(/ /g, ' ') // 将   替换为空格 - .trim(); // 去掉首尾空格 -} - -export interface VideoDetail { - code: number; - episodes: string[]; - detailUrl: string; - videoInfo: { - title: string; - cover?: string; - desc?: string; - type?: string; - year?: string; - area?: string; - director?: string; - actor?: string; - remarks?: string; - source_name: string; - source: string; - id: string; - }; -} - -async function handleSpecialSourceDetail( - id: string, - apiSite: ApiSite -): Promise { - const detailUrl = `${apiSite.detail}/index.php/vod/detail/id/${id}.html`; - - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); - - const response = await fetch(detailUrl, { - headers: API_CONFIG.detail.headers, - signal: controller.signal, - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - throw new Error(`详情页请求失败: ${response.status}`); - } - - const html = await response.text(); - let matches: string[] = []; - - if (apiSite.key === 'ffzy') { - const ffzyPattern = - /\$(https?:\/\/[^"'\s]+?\/\d{8}\/\d+_[a-f0-9]+\/index\.m3u8)/g; - matches = html.match(ffzyPattern) || []; - } - - if (matches.length === 0) { - const generalPattern = /\$(https?:\/\/[^"'\s]+?\.m3u8)/g; - matches = html.match(generalPattern) || []; - } - - matches = Array.from(new Set(matches)); - matches = matches.map((link: string) => { - link = link.substring(1, link.length); - const parenIndex = link.indexOf('('); - return parenIndex > 0 ? link.substring(0, parenIndex) : link; - }); - - const titleMatch = html.match(/]*>([^<]+)<\/h1>/); - const titleText = titleMatch ? titleMatch[1].trim() : ''; - - const descMatch = html.match( - /]*class=["']sketch["'][^>]*>([\s\S]*?)<\/div>/ - ); - const descText = descMatch ? cleanHtmlTags(descMatch[1]) : ''; - - const coverMatch = html.match(/(https?:\/\/[^"'\s]+?\.jpg)/g); - const coverUrl = coverMatch ? coverMatch[0].trim() : ''; - - return { - code: 200, - episodes: matches, - detailUrl: detailUrl, - videoInfo: { - title: titleText, - cover: coverUrl, - desc: descText, - source_name: apiSite.name, - source: apiSite.key, - id: id, - }, - }; -} - -async function getDetailFromApi( - apiSite: ApiSite, - id: string -): Promise { - const detailUrl = `${apiSite.api}${API_CONFIG.detail.path}${id}`; - - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); - - const response = await fetch(detailUrl, { - headers: API_CONFIG.detail.headers, - signal: controller.signal, - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - throw new Error(`详情请求失败: ${response.status}`); - } - - const data = await response.json(); - - if ( - !data || - !data.list || - !Array.isArray(data.list) || - data.list.length === 0 - ) { - throw new Error('获取到的详情内容无效'); - } - - const videoDetail = data.list[0]; - let episodes: string[] = []; - - if (videoDetail.vod_play_url) { - const playSources = videoDetail.vod_play_url.split('$$$'); - - if (playSources.length > 0) { - const mainSource = playSources[0]; - const episodeList = mainSource.split('#'); - - episodes = episodeList - .map((ep: string) => { - const parts = ep.split('$'); - return parts.length > 1 ? parts[1] : ''; - }) - .filter( - (url: string) => - url && (url.startsWith('http://') || url.startsWith('https://')) - ); - } - } - - if (episodes.length === 0 && videoDetail.vod_content) { - const matches = videoDetail.vod_content.match(M3U8_PATTERN) || []; - episodes = matches.map((link: string) => link.replace(/^\$/, '')); - } - - return { - code: 200, - episodes: episodes, - detailUrl: detailUrl, - videoInfo: { - title: videoDetail.vod_name, - cover: videoDetail.vod_pic, - desc: cleanHtmlTags(videoDetail.vod_content), - type: videoDetail.type_name, - year: videoDetail.vod_year, - area: videoDetail.vod_area, - director: videoDetail.vod_director, - actor: videoDetail.vod_actor, - remarks: videoDetail.vod_remarks, - source_name: apiSite.name, - source: apiSite.key, - id: id, - }, - }; -} - -export async function getVideoDetail( - id: string, - sourceCode: string -): Promise { - if (!id) { - throw new Error('缺少视频ID参数'); - } - - if (!/^[\w-]+$/.test(id)) { - throw new Error('无效的视频ID格式'); - } - - const apiSites = getApiSites(); - const apiSite = apiSites.find((site) => site.key === sourceCode); - - if (!apiSite) { - throw new Error('无效的API来源'); - } - - if (apiSite.detail) { - return await handleSpecialSourceDetail(id, apiSite); - } else { - return await getDetailFromApi(apiSite, id); - } -} +import { getCacheTime } from '@/lib/config'; +import { getVideoDetail } from '@/lib/video'; export async function GET(request: Request) { const { searchParams } = new URL(request.url); diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 081f35a..44a429a 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -1,8 +1,7 @@ import { NextResponse } from 'next/server'; import { API_CONFIG, ApiSite, getApiSites, getCacheTime } from '@/lib/config'; - -import { getVideoDetail } from '../detail/route'; +import { getVideoDetail } from '@/lib/video'; export interface SearchResult { id: string; diff --git a/src/app/components/layout.tsx b/src/app/components/layout.tsx deleted file mode 100644 index 11e6ad2..0000000 --- a/src/app/components/layout.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Metadata } from 'next'; -import * as React from 'react'; - -import '@/styles/colors.css'; - -export const metadata: Metadata = { - title: 'Components', - description: 'Pre-built components with awesome default', -}; - -export default function ComponentsLayout({ - children, -}: { - children: React.ReactNode; -}) { - return <>{children}; -} diff --git a/src/app/components/page.tsx b/src/app/components/page.tsx deleted file mode 100644 index 74e8bd8..0000000 --- a/src/app/components/page.tsx +++ /dev/null @@ -1,461 +0,0 @@ -'use client'; - -import clsx from 'clsx'; -import { - ArrowRight, - CreditCard, - Laptop, - Phone, - Plus, - Shield, -} from 'lucide-react'; -import * as React from 'react'; - -import Button from '@/components/buttons/Button'; -import IconButton from '@/components/buttons/IconButton'; -import TextButton from '@/components/buttons/TextButton'; -import ArrowLink from '@/components/links/ArrowLink'; -import ButtonLink from '@/components/links/ButtonLink'; -import PrimaryLink from '@/components/links/PrimaryLink'; -import UnderlineLink from '@/components/links/UnderlineLink'; -import UnstyledLink from '@/components/links/UnstyledLink'; -import NextImage from '@/components/NextImage'; -import Skeleton from '@/components/Skeleton'; - -type Color = (typeof colorList)[number]; - -export default function ComponentPage() { - const [mode, setMode] = React.useState<'dark' | 'light'>('light'); - const [color, setColor] = React.useState('sky'); - function toggleMode() { - return mode === 'dark' ? setMode('light') : setMode('dark'); - } - - const textColor = mode === 'dark' ? 'text-gray-300' : 'text-gray-600'; - - return ( -
-
-
-

Built-in Components

- - Back to Home - - -
- - {/* */} -
- -
    -
  1. -

    Customize Colors

    -

    - You can change primary color to any Tailwind CSS colors. See - globals.css to change your color. -

    -
    - - - Check list of colors - -
    -
    -
    - 50 -
    -
    - 100 -
    -
    - 200 -
    -
    - 300 -
    -
    - 400 -
    -
    - 500 -
    -
    - 600 -
    -
    - 700 -
    -
    - 800 -
    -
    - 900 -
    -
    - 950 -
    -
    -
  2. -
  3. -

    UnstyledLink

    -

    - No style applied, differentiate internal and outside links, give - custom cursor for outside links. -

    -
    - Internal Links - - Outside Links - -
    -
  4. -
  5. -

    PrimaryLink

    -

    - Add styling on top of UnstyledLink, giving a primary color to - the link. -

    -
    - Internal Links - - Outside Links - -
    -
  6. -
  7. -

    UnderlineLink

    -

    - Add styling on top of UnstyledLink, giving a dotted and animated - underline. -

    -
    - Internal Links - - Outside Links - -
    -
  8. -
  9. -

    ArrowLink

    -

    - Useful for indicating navigation, I use this quite a lot, so why - not build a component with some whimsy touch? -

    -
    - - Direction Left - - Direction Right - - Polymorphic - - - Polymorphic - -
    -
  10. -
  11. -

    ButtonLink

    -

    - Button styled link with 3 variants. -

    -
    - - Primary Variant - - - Outline Variant - - - Ghost Variant - - - Dark Variant - - - Light Variant - -
    -
  12. -
  13. -

    Button

    -

    - Ordinary button with style. -

    -
    - - - - - -
    -
    - - - - - -
    -
    - - - - - -
    -
    - - - - - - -
    - -
    - - - - - -
    -
    - - - - - -
    -
  14. -
  15. -

    TextButton

    -

    - Button with a text style -

    -
    - Primary Variant - Basic Variant -
    -
  16. -
  17. -

    IconButton

    -

    - Button with only icon inside -

    -
    - - - - - -
    -
  18. -
  19. -

    Custom 404 Page

    -

    - Styled 404 page with some animation. -

    -
    - Visit the 404 page -
    -
  20. -
  21. -

    Next Image

    -

    - Next Image with default props and skeleton animation -

    - -
  22. -
  23. -

    Skeleton

    -

    - Skeleton with shimmer effect -

    - -
  24. -
-
-
-
- ); -} - -const colorList = [ - 'slate', - 'gray', - 'zinc', - 'neutral', - 'stone', - 'red', - 'orange', - 'amber', - 'yellow', - 'lime', - 'green', - 'emerald', - 'teal', - 'cyan', - 'sky', - 'blue', - 'indigo', - 'violet', - 'purple', - 'fuchsia', - 'pink', - 'rose', -] as const; diff --git a/src/app/detail/page.tsx b/src/app/detail/page.tsx index 5997209..c3dc056 100644 --- a/src/app/detail/page.tsx +++ b/src/app/detail/page.tsx @@ -6,10 +6,9 @@ import { useEffect, useState } from 'react'; import type { PlayRecord } from '@/lib/db.client'; import { generateStorageKey, getAllPlayRecords } from '@/lib/db.client'; +import { VideoDetail } from '@/lib/video'; -import PageLayout from '@/components/layout/PageLayout'; - -import { VideoDetail } from '../api/detail/route'; +import PageLayout from '@/components/PageLayout'; export default function DetailPage() { const searchParams = useSearchParams(); diff --git a/src/app/douban/page.tsx b/src/app/douban/page.tsx index 1f13bd7..c657ee0 100644 --- a/src/app/douban/page.tsx +++ b/src/app/douban/page.tsx @@ -5,7 +5,7 @@ import { useEffect, useRef, useState } from 'react'; import DemoCard from '@/components/DemoCard'; import DoubanCardSkeleton from '@/components/DoubanCardSkeleton'; -import PageLayout from '@/components/layout/PageLayout'; +import PageLayout from '@/components/PageLayout'; // 定义豆瓣数据项类型 interface DoubanItem { diff --git a/src/app/error.tsx b/src/app/error.tsx deleted file mode 100644 index 132041d..0000000 --- a/src/app/error.tsx +++ /dev/null @@ -1,38 +0,0 @@ -'use client'; // Error components must be Client Components - -import * as React from 'react'; -import { RiAlarmWarningFill } from 'react-icons/ri'; - -import TextButton from '@/components/buttons/TextButton'; - -export default function Error({ - error, - reset, -}: { - error: Error & { digest?: string }; - reset: () => void; -}) { - React.useEffect(() => { - // eslint-disable-next-line no-console - console.error(error); - }, [error]); - - return ( -
-
-
- -

- Oops, something went wrong! -

- - Try again - -
-
-
- ); -} diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx deleted file mode 100644 index 9598fe5..0000000 --- a/src/app/not-found.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { Metadata } from 'next'; -import * as React from 'react'; -import { RiAlarmWarningFill } from 'react-icons/ri'; - -export const metadata: Metadata = { - title: 'Not Found', -}; - -export default function NotFound() { - return ( -
-
-
- -

Page Not Found

- Back to home -
-
-
- ); -} diff --git a/src/app/page.tsx b/src/app/page.tsx index b12a6d6..befe122 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -15,7 +15,7 @@ import CapsuleSwitch from '@/components/CapsuleSwitch'; import CollectionCard from '@/components/CollectionCard'; import ContinueWatching from '@/components/ContinueWatching'; import DemoCard from '@/components/DemoCard'; -import PageLayout from '@/components/layout/PageLayout'; +import PageLayout from '@/components/PageLayout'; import ScrollableRow from '@/components/ScrollableRow'; interface DoubanItem { diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index b83a56f..25335a9 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -12,8 +12,7 @@ import { getAllPlayRecords, savePlayRecord, } from '@/lib/db.client'; - -import { VideoDetail } from '../api/detail/route'; +import { VideoDetail } from '@/lib/video'; // 动态导入 Artplayer 和 Hls 以避免 SSR 问题 let Artplayer: any = null; diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index aa58c25..b931968 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -4,7 +4,7 @@ import { Search } from 'lucide-react'; import { useRouter, useSearchParams } from 'next/navigation'; import { useEffect, useRef, useState } from 'react'; -import PageLayout from '@/components/layout/PageLayout'; +import PageLayout from '@/components/PageLayout'; import VideoCard from '@/components/VideoCard'; // 模拟搜索历史数据 diff --git a/src/components/layout/PageLayout.tsx b/src/components/PageLayout.tsx similarity index 100% rename from src/components/layout/PageLayout.tsx rename to src/components/PageLayout.tsx diff --git a/src/components/layout/Sidebar.tsx b/src/components/Sidebar.tsx similarity index 100% rename from src/components/layout/Sidebar.tsx rename to src/components/Sidebar.tsx diff --git a/src/components/buttons/Button.tsx b/src/components/buttons/Button.tsx deleted file mode 100644 index 292359c..0000000 --- a/src/components/buttons/Button.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import { LucideIcon } from 'lucide-react'; -import * as React from 'react'; -import { IconType } from 'react-icons'; -import { ImSpinner2 } from 'react-icons/im'; - -import { cn } from '@/lib/utils'; - -const ButtonVariant = ['primary', 'outline', 'ghost', 'light', 'dark'] as const; -const ButtonSize = ['sm', 'base'] as const; - -type ButtonProps = { - isLoading?: boolean; - isDarkBg?: boolean; - variant?: (typeof ButtonVariant)[number]; - size?: (typeof ButtonSize)[number]; - leftIcon?: IconType | LucideIcon; - rightIcon?: IconType | LucideIcon; - classNames?: { - leftIcon?: string; - rightIcon?: string; - }; -} & React.ComponentPropsWithRef<'button'>; - -const Button = React.forwardRef( - ( - { - children, - className, - disabled: buttonDisabled, - isLoading, - variant = 'primary', - size = 'base', - isDarkBg = false, - leftIcon: LeftIcon, - rightIcon: RightIcon, - classNames, - ...rest - }, - ref - ) => { - const disabled = isLoading || buttonDisabled; - - return ( - - ); - } -); - -export default Button; diff --git a/src/components/buttons/IconButton.tsx b/src/components/buttons/IconButton.tsx deleted file mode 100644 index c7b224e..0000000 --- a/src/components/buttons/IconButton.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { LucideIcon } from 'lucide-react'; -import * as React from 'react'; -import { IconType } from 'react-icons'; -import { ImSpinner2 } from 'react-icons/im'; - -import { cn } from '@/lib/utils'; - -const IconButtonVariant = [ - 'primary', - 'outline', - 'ghost', - 'light', - 'dark', -] as const; - -type IconButtonProps = { - isLoading?: boolean; - isDarkBg?: boolean; - variant?: (typeof IconButtonVariant)[number]; - icon?: IconType | LucideIcon; - classNames?: { - icon?: string; - }; -} & React.ComponentPropsWithRef<'button'>; - -const IconButton = React.forwardRef( - ( - { - className, - disabled: buttonDisabled, - isLoading, - variant = 'primary', - isDarkBg = false, - icon: Icon, - classNames, - ...rest - }, - ref - ) => { - const disabled = isLoading || buttonDisabled; - - return ( - - ); - } -); - -export default IconButton; diff --git a/src/components/buttons/TextButton.tsx b/src/components/buttons/TextButton.tsx deleted file mode 100644 index d20872f..0000000 --- a/src/components/buttons/TextButton.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import * as React from 'react'; - -import { cn } from '@/lib/utils'; - -const TextButtonVariant = ['primary', 'basic'] as const; - -type TextButtonProps = { - variant?: (typeof TextButtonVariant)[number]; -} & React.ComponentPropsWithRef<'button'>; - -const TextButton = React.forwardRef( - ( - { - children, - className, - variant = 'primary', - disabled: buttonDisabled, - ...rest - }, - ref - ) => { - return ( - - ); - } -); - -export default TextButton; diff --git a/src/components/links/ArrowLink.tsx b/src/components/links/ArrowLink.tsx deleted file mode 100644 index 60d70b1..0000000 --- a/src/components/links/ArrowLink.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import * as React from 'react'; - -import { cn } from '@/lib/utils'; - -import UnderlineLink from '@/components/links/UnderlineLink'; -import { UnstyledLinkProps } from '@/components/links/UnstyledLink'; - -type ArrowLinkProps = { - as?: C; - direction?: 'left' | 'right'; -} & UnstyledLinkProps & - React.ComponentProps; - -export default function ArrowLink({ - children, - className, - direction = 'right', - as, - ...rest -}: ArrowLinkProps) { - const Component = as || UnderlineLink; - - return ( - - {children} - - - - - - ); -} diff --git a/src/components/links/ButtonLink.tsx b/src/components/links/ButtonLink.tsx deleted file mode 100644 index 4100fee..0000000 --- a/src/components/links/ButtonLink.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { LucideIcon } from 'lucide-react'; -import * as React from 'react'; -import { IconType } from 'react-icons'; - -import { cn } from '@/lib/utils'; - -import UnstyledLink, { - UnstyledLinkProps, -} from '@/components/links/UnstyledLink'; - -const ButtonLinkVariant = [ - 'primary', - 'outline', - 'ghost', - 'light', - 'dark', -] as const; -const ButtonLinkSize = ['sm', 'base'] as const; - -type ButtonLinkProps = { - isDarkBg?: boolean; - variant?: (typeof ButtonLinkVariant)[number]; - size?: (typeof ButtonLinkSize)[number]; - leftIcon?: IconType | LucideIcon; - rightIcon?: IconType | LucideIcon; - classNames?: { - leftIcon?: string; - rightIcon?: string; - }; -} & UnstyledLinkProps; - -const ButtonLink = React.forwardRef( - ( - { - children, - className, - variant = 'primary', - size = 'base', - isDarkBg = false, - leftIcon: LeftIcon, - rightIcon: RightIcon, - classNames, - ...rest - }, - ref - ) => { - return ( - - {LeftIcon && ( -
- -
- )} - {children} - {RightIcon && ( -
- -
- )} -
- ); - } -); - -export default ButtonLink; diff --git a/src/components/links/IconLink.tsx b/src/components/links/IconLink.tsx deleted file mode 100644 index e031077..0000000 --- a/src/components/links/IconLink.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { LucideIcon } from 'lucide-react'; -import * as React from 'react'; -import { IconType } from 'react-icons'; - -import { cn } from '@/lib/utils'; - -import UnstyledLink, { - UnstyledLinkProps, -} from '@/components/links/UnstyledLink'; - -const IconLinkVariant = [ - 'primary', - 'outline', - 'ghost', - 'light', - 'dark', -] as const; - -type IconLinkProps = { - isDarkBg?: boolean; - variant?: (typeof IconLinkVariant)[number]; - icon?: IconType | LucideIcon; - classNames?: { - icon?: string; - }; -} & Omit; - -const IconLink = React.forwardRef( - ( - { - className, - icon: Icon, - variant = 'outline', - isDarkBg = false, - classNames, - ...rest - }, - ref - ) => { - return ( - - {Icon && } - - ); - } -); - -export default IconLink; diff --git a/src/components/links/PrimaryLink.tsx b/src/components/links/PrimaryLink.tsx deleted file mode 100644 index ba865ca..0000000 --- a/src/components/links/PrimaryLink.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import * as React from 'react'; - -import { cn } from '@/lib/utils'; - -import UnstyledLink, { - UnstyledLinkProps, -} from '@/components/links/UnstyledLink'; - -const PrimaryLinkVariant = ['primary', 'basic'] as const; -type PrimaryLinkProps = { - variant?: (typeof PrimaryLinkVariant)[number]; -} & UnstyledLinkProps; - -const PrimaryLink = React.forwardRef( - ({ className, children, variant = 'primary', ...rest }, ref) => { - return ( - - {children} - - ); - } -); - -export default PrimaryLink; diff --git a/src/components/links/UnderlineLink.tsx b/src/components/links/UnderlineLink.tsx deleted file mode 100644 index bba8890..0000000 --- a/src/components/links/UnderlineLink.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import * as React from 'react'; - -import { cn } from '@/lib/utils'; - -import UnstyledLink, { - UnstyledLinkProps, -} from '@/components/links/UnstyledLink'; - -const UnderlineLink = React.forwardRef( - ({ children, className, ...rest }, ref) => { - return ( - - {children} - - ); - } -); - -export default UnderlineLink; diff --git a/src/components/links/UnstyledLink.tsx b/src/components/links/UnstyledLink.tsx deleted file mode 100644 index 0ccb23f..0000000 --- a/src/components/links/UnstyledLink.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import Link, { LinkProps } from 'next/link'; -import * as React from 'react'; - -import { cn } from '@/lib/utils'; - -export type UnstyledLinkProps = { - href: string; - children: React.ReactNode; - openNewTab?: boolean; - className?: string; - nextLinkProps?: Omit; -} & React.ComponentPropsWithRef<'a'>; - -const UnstyledLink = React.forwardRef( - ({ children, href, openNewTab, className, nextLinkProps, ...rest }, ref) => { - const isNewTab = - openNewTab !== undefined - ? openNewTab - : href && !href.startsWith('/') && !href.startsWith('#'); - - if (!isNewTab) { - return ( - - {children} - - ); - } - - return ( - - {children} - - ); - } -); - -export default UnstyledLink; diff --git a/src/lib/video.ts b/src/lib/video.ts new file mode 100644 index 0000000..4d9193b --- /dev/null +++ b/src/lib/video.ts @@ -0,0 +1,212 @@ +import { API_CONFIG, ApiSite, getApiSites } from '@/lib/config'; + +// 匹配 m3u8 链接的正则 +const M3U8_PATTERN = /(https?:\/\/[^"'\s]+?\.m3u8)/g; + +// 清理 HTML 标签的工具函数 +function cleanHtmlTags(text: string): string { + if (!text) return ''; + return text + .replace(/<[^>]+>/g, '\n') // 将 HTML 标签替换为换行 + .replace(/\n+/g, '\n') // 将多个连续换行合并为一个 + .replace(/[ \t]+/g, ' ') // 将多个连续空格和制表符合并为一个空格,但保留换行符 + .replace(/^\n+|\n+$/g, '') // 去掉首尾换行 + .replace(/ /g, ' ') // 将   替换为空格 + .trim(); // 去掉首尾空格 +} + +export interface VideoDetail { + code: number; + episodes: string[]; + detailUrl: string; + videoInfo: { + title: string; + cover?: string; + desc?: string; + type?: string; + year?: string; + area?: string; + director?: string; + actor?: string; + remarks?: string; + source_name: string; + source: string; + id: string; + }; +} + +async function handleSpecialSourceDetail( + id: string, + apiSite: ApiSite +): Promise { + const detailUrl = `${apiSite.detail}/index.php/vod/detail/id/${id}.html`; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + const response = await fetch(detailUrl, { + headers: API_CONFIG.detail.headers, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`详情页请求失败: ${response.status}`); + } + + const html = await response.text(); + let matches: string[] = []; + + if (apiSite.key === 'ffzy') { + const ffzyPattern = + /\$(https?:\/\/[^"'\s]+?\/\d{8}\/\d+_[a-f0-9]+\/index\.m3u8)/g; + matches = html.match(ffzyPattern) || []; + } + + if (matches.length === 0) { + const generalPattern = /\$(https?:\/\/[^"'\s]+?\.m3u8)/g; + matches = html.match(generalPattern) || []; + } + + // 去重并清理链接前缀 + matches = Array.from(new Set(matches)).map((link: string) => { + link = link.substring(1); // 去掉开头的 $ + const parenIndex = link.indexOf('('); + return parenIndex > 0 ? link.substring(0, parenIndex) : link; + }); + + // 提取标题 + const titleMatch = html.match(/]*>([^<]+)<\/h1>/); + const titleText = titleMatch ? titleMatch[1].trim() : ''; + + // 提取描述 + const descMatch = html.match( + /]*class=["']sketch["'][^>]*>([\s\S]*?)<\/div>/ + ); + const descText = descMatch ? cleanHtmlTags(descMatch[1]) : ''; + + // 提取封面 + const coverMatch = html.match(/(https?:\/\/[^"'\s]+?\.jpg)/g); + const coverUrl = coverMatch ? coverMatch[0].trim() : ''; + + return { + code: 200, + episodes: matches, + detailUrl, + videoInfo: { + title: titleText, + cover: coverUrl, + desc: descText, + source_name: apiSite.name, + source: apiSite.key, + id, + }, + }; +} + +async function getDetailFromApi( + apiSite: ApiSite, + id: string +): Promise { + const detailUrl = `${apiSite.api}${API_CONFIG.detail.path}${id}`; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + const response = await fetch(detailUrl, { + headers: API_CONFIG.detail.headers, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`详情请求失败: ${response.status}`); + } + + const data = await response.json(); + + if ( + !data || + !data.list || + !Array.isArray(data.list) || + data.list.length === 0 + ) { + throw new Error('获取到的详情内容无效'); + } + + const videoDetail = data.list[0]; + let episodes: string[] = []; + + // 处理播放源拆分 + if (videoDetail.vod_play_url) { + const playSources = videoDetail.vod_play_url.split('$$$'); + if (playSources.length > 0) { + const mainSource = playSources[0]; + const episodeList = mainSource.split('#'); + episodes = episodeList + .map((ep: string) => { + const parts = ep.split('$'); + return parts.length > 1 ? parts[1] : ''; + }) + .filter( + (url: string) => + url && (url.startsWith('http://') || url.startsWith('https://')) + ); + } + } + + // 如果播放源为空,则尝试从内容中解析 m3u8 + if (episodes.length === 0 && videoDetail.vod_content) { + const matches = videoDetail.vod_content.match(M3U8_PATTERN) || []; + episodes = matches.map((link: string) => link.replace(/^\$/, '')); + } + + return { + code: 200, + episodes, + detailUrl, + videoInfo: { + title: videoDetail.vod_name, + cover: videoDetail.vod_pic, + desc: cleanHtmlTags(videoDetail.vod_content), + type: videoDetail.type_name, + year: videoDetail.vod_year, + area: videoDetail.vod_area, + director: videoDetail.vod_director, + actor: videoDetail.vod_actor, + remarks: videoDetail.vod_remarks, + source_name: apiSite.name, + source: apiSite.key, + id, + }, + }; +} + +// 对外导出统一的获取详情方法 +export async function getVideoDetail( + id: string, + sourceCode: string +): Promise { + if (!id) { + throw new Error('缺少视频ID参数'); + } + + if (!/^[\w-]+$/.test(id)) { + throw new Error('无效的视频ID格式'); + } + + const apiSites = getApiSites(); + const apiSite = apiSites.find((site) => site.key === sourceCode); + + if (!apiSite) { + throw new Error('无效的API来源'); + } + + if (apiSite.detail) { + return handleSpecialSourceDetail(id, apiSite); + } + + return getDetailFromApi(apiSite, id); +}