mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-02-22 18:44:44 +08:00
feat: add douban url, optimize source aggregate
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { LinkIcon } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
@@ -15,6 +16,7 @@ function AggregatePageClient() {
|
||||
const query = searchParams.get('q')?.trim() || '';
|
||||
const title = searchParams.get('title')?.trim() || '';
|
||||
const year = searchParams.get('year')?.trim() || '';
|
||||
const type = searchParams.get('type')?.trim() || '';
|
||||
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -46,11 +48,32 @@ function AggregatePageClient() {
|
||||
if (!titleMatch || !yearMatch) {
|
||||
return;
|
||||
}
|
||||
// 如果还传入了 type,则按 type 精确匹配
|
||||
if (type === 'tv' && r.episodes.length === 1) {
|
||||
return;
|
||||
}
|
||||
if (type === 'movie' && r.episodes.length !== 1) {
|
||||
return;
|
||||
}
|
||||
const key = `${r.title}-${r.year}`;
|
||||
const arr = map.get(key) || [];
|
||||
arr.push(r);
|
||||
map.set(key, arr);
|
||||
});
|
||||
if (map.size === 0 && type) {
|
||||
// 无匹配,忽略 type 做重新匹配
|
||||
all.forEach((r) => {
|
||||
const titleMatch = title ? r.title === title : r.title === query;
|
||||
const yearMatch = year ? r.year === year : true;
|
||||
if (!titleMatch || !yearMatch) {
|
||||
return;
|
||||
}
|
||||
const key = `${r.title}-${r.year}`;
|
||||
const arr = map.get(key) || [];
|
||||
arr.push(r);
|
||||
map.set(key, arr);
|
||||
});
|
||||
}
|
||||
if (map.size == 1) {
|
||||
setResults(Array.from(map.values()).flat());
|
||||
} else if (map.size > 1) {
|
||||
@@ -75,6 +98,24 @@ function AggregatePageClient() {
|
||||
return v.length > best.length ? v : best;
|
||||
}, undefined);
|
||||
};
|
||||
// 出现次数最多的非 0 数字
|
||||
const chooseNumber = (vals: (number | undefined)[]): number | undefined => {
|
||||
const countMap = new Map<number, number>();
|
||||
vals.forEach((v) => {
|
||||
if (v !== undefined && v !== 0) {
|
||||
countMap.set(v, (countMap.get(v) || 0) + 1);
|
||||
}
|
||||
});
|
||||
let selected: number | undefined = undefined;
|
||||
let maxCount = 0;
|
||||
countMap.forEach((cnt, num) => {
|
||||
if (cnt > maxCount) {
|
||||
maxCount = cnt;
|
||||
selected = num;
|
||||
}
|
||||
});
|
||||
return selected;
|
||||
};
|
||||
|
||||
const aggregatedInfo = {
|
||||
title: title || query,
|
||||
@@ -83,6 +124,7 @@ function AggregatePageClient() {
|
||||
type: chooseString(results.map((d) => d.type_name)),
|
||||
year: chooseString(results.map((d) => d.year)),
|
||||
remarks: chooseString(results.map((d) => d.class)),
|
||||
douban_id: chooseNumber(results.map((d) => d.douban_id)),
|
||||
};
|
||||
|
||||
const infoReady = Boolean(
|
||||
@@ -166,6 +208,17 @@ function AggregatePageClient() {
|
||||
>
|
||||
<h1 className='text-3xl font-bold mb-2 tracking-wide flex items-center flex-shrink-0 text-center md:text-left w-full'>
|
||||
{aggregatedInfo.title}
|
||||
{aggregatedInfo.douban_id && (
|
||||
<a
|
||||
href={`https://movie.douban.com/subject/${aggregatedInfo.douban_id}/`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className='ml-2'
|
||||
>
|
||||
<LinkIcon className='w-4 h-4' strokeWidth={2} />
|
||||
</a>
|
||||
)}
|
||||
</h1>
|
||||
<div className='flex flex-wrap items-center gap-3 text-base mb-4 opacity-80 flex-shrink-0'>
|
||||
{aggregatedInfo.remarks && (
|
||||
|
||||
@@ -19,6 +19,7 @@ interface ApiSearchItem {
|
||||
vod_class?: string;
|
||||
vod_year?: string;
|
||||
vod_content?: string;
|
||||
vod_douban_id?: number;
|
||||
type_name?: string;
|
||||
}
|
||||
|
||||
@@ -48,7 +49,6 @@ async function searchFromApi(
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (
|
||||
!data ||
|
||||
!data.list ||
|
||||
@@ -92,6 +92,7 @@ async function searchFromApi(
|
||||
year: item.vod_year ? item.vod_year.match(/\d{4}/)?.[0] || '' : '',
|
||||
desc: cleanHtmlTags(item.vod_content || ''),
|
||||
type_name: item.type_name,
|
||||
douban_id: item.vod_douban_id,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -161,6 +162,7 @@ async function searchFromApi(
|
||||
: '',
|
||||
desc: cleanHtmlTags(item.vod_content || ''),
|
||||
type_name: item.type_name,
|
||||
douban_id: item.vod_douban_id,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { Heart } from 'lucide-react';
|
||||
import { Heart, LinkIcon } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
@@ -173,6 +173,17 @@ function DetailPageClient() {
|
||||
>
|
||||
<h1 className='text-3xl font-bold mb-2 tracking-wide flex items-center flex-shrink-0 text-center md:text-left w-full'>
|
||||
{detail.title || fallbackTitle}
|
||||
{detail.douban_id && (
|
||||
<a
|
||||
href={`https://movie.douban.com/subject/${detail.douban_id}/`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className='ml-2'
|
||||
>
|
||||
<LinkIcon className='w-4 h-4' strokeWidth={2} />
|
||||
</a>
|
||||
)}
|
||||
</h1>
|
||||
<div className='flex flex-wrap items-center gap-3 text-base mb-4 opacity-80 flex-shrink-0'>
|
||||
{detail.class && (
|
||||
|
||||
@@ -203,6 +203,7 @@ function DoubanPageClient() {
|
||||
title={item.title}
|
||||
poster={item.poster}
|
||||
rate={item.rate}
|
||||
type={type || 'movie'}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -197,6 +197,7 @@ function HomeClient() {
|
||||
title={movie.title}
|
||||
poster={movie.poster}
|
||||
rate={movie.rate}
|
||||
type='movie'
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -242,6 +243,7 @@ function HomeClient() {
|
||||
title={show.title}
|
||||
poster={show.poster}
|
||||
rate={show.rate}
|
||||
type='tv'
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -494,7 +494,12 @@ function PlayPageClient() {
|
||||
result.title.toLowerCase() === videoTitle.toLowerCase() &&
|
||||
(videoYear
|
||||
? result.year.toLowerCase() === videoYear.toLowerCase()
|
||||
: true)
|
||||
: true) &&
|
||||
detailRef.current?.episodes.length &&
|
||||
((detailRef.current?.episodes.length === 1 &&
|
||||
result.episodes.length === 1) ||
|
||||
(detailRef.current?.episodes.length > 1 &&
|
||||
result.episodes.length > 1))
|
||||
);
|
||||
|
||||
if (exactMatch) {
|
||||
|
||||
@@ -44,7 +44,9 @@ function SearchPageClient() {
|
||||
const map = new Map<string, SearchResult[]>();
|
||||
searchResults.forEach((item) => {
|
||||
// 使用 title + year 作为键,若 year 不存在则使用 'unknown'
|
||||
const key = `${item.title}-${item.year || 'unknown'}`;
|
||||
const key = `${item.title}-${item.year || 'unknown'}-${
|
||||
item.episodes.length === 1 ? 'movie' : 'tv'
|
||||
}`;
|
||||
const arr = map.get(key) || [];
|
||||
arr.push(item);
|
||||
map.set(key, arr);
|
||||
@@ -196,6 +198,7 @@ function SearchPageClient() {
|
||||
episodes={item.episodes.length}
|
||||
source={item.source}
|
||||
source_name={item.source_name}
|
||||
douban_id={item.douban_id}
|
||||
from='search'
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { LinkIcon } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React, { useState } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
// 聚合卡需要的基本字段,与搜索接口保持一致
|
||||
interface SearchResult {
|
||||
@@ -10,6 +11,7 @@ interface SearchResult {
|
||||
poster: string;
|
||||
source: string;
|
||||
source_name: string;
|
||||
douban_id?: number;
|
||||
episodes: string[];
|
||||
}
|
||||
|
||||
@@ -64,13 +66,60 @@ const AggregateCard: React.FC<AggregateCardProps> = ({
|
||||
const [playHover, setPlayHover] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
// 统计 items 中出现次数最多的(非 0) douban_id,用于跳转豆瓣页面
|
||||
const mostFrequentDoubanId = useMemo(() => {
|
||||
const countMap = new Map<number, number>();
|
||||
|
||||
items.forEach((item) => {
|
||||
if (item.douban_id && item.douban_id !== 0) {
|
||||
countMap.set(item.douban_id, (countMap.get(item.douban_id) || 0) + 1);
|
||||
}
|
||||
});
|
||||
|
||||
let selectedId: number | undefined;
|
||||
let maxCount = 0;
|
||||
|
||||
countMap.forEach((cnt, id) => {
|
||||
if (cnt > maxCount) {
|
||||
maxCount = cnt;
|
||||
selectedId = id;
|
||||
}
|
||||
});
|
||||
|
||||
return selectedId;
|
||||
}, [items]);
|
||||
|
||||
// 统计出现次数最多的集数(episodes.length),主要用于显示剧集数徽标
|
||||
const mostFrequentEpisodes = useMemo(() => {
|
||||
const countMap = new Map<number, number>();
|
||||
|
||||
items.forEach((item) => {
|
||||
const len = item.episodes?.length || 0;
|
||||
if (len > 0) {
|
||||
countMap.set(len, (countMap.get(len) || 0) + 1);
|
||||
}
|
||||
});
|
||||
|
||||
let selectedLen = 0;
|
||||
let maxCount = 0;
|
||||
|
||||
countMap.forEach((cnt, len) => {
|
||||
if (cnt > maxCount) {
|
||||
maxCount = cnt;
|
||||
selectedLen = len;
|
||||
}
|
||||
});
|
||||
|
||||
return selectedLen;
|
||||
}, [items]);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/aggregate?q=${encodeURIComponent(
|
||||
query
|
||||
)}&title=${encodeURIComponent(first.title)}${
|
||||
year ? `&year=${encodeURIComponent(year)}` : ''
|
||||
}`}
|
||||
}&type=${mostFrequentEpisodes > 1 ? 'tv' : 'movie'}`}
|
||||
>
|
||||
<div className='group relative w-full rounded-lg bg-transparent shadow-none flex flex-col'>
|
||||
{/* 封面图片 2:3 */}
|
||||
@@ -83,6 +132,15 @@ const AggregateCard: React.FC<AggregateCardProps> = ({
|
||||
unoptimized
|
||||
/>
|
||||
|
||||
{/* 集数指示器 - 绿色小圆球 */}
|
||||
{mostFrequentEpisodes && mostFrequentEpisodes > 1 && (
|
||||
<div className='absolute top-2 right-2 w-4 h-4 sm:w-7 sm:h-7 bg-green-500 rounded-full flex items-center justify-center'>
|
||||
<span className='text-white text-[0.5rem] sm:text-xs font-bold'>
|
||||
{mostFrequentEpisodes}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hover 层 & 播放按钮 */}
|
||||
<div className='absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center pointer-events-none'>
|
||||
<div className='absolute inset-0 flex items-center justify-center pointer-events-auto'>
|
||||
@@ -109,6 +167,20 @@ const AggregateCard: React.FC<AggregateCardProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mostFrequentDoubanId && (
|
||||
<a
|
||||
href={`https://movie.douban.com/subject/${mostFrequentDoubanId}`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className='absolute top-2 left-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200'
|
||||
>
|
||||
<div className='w-4 h-4 sm:w-7 sm:h-7 rounded-full bg-green-500 flex items-center justify-center transition-all duration-200 hover:scale-110'>
|
||||
<LinkIcon className='w-4 h-4 text-white' strokeWidth={2} />
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 标题 */}
|
||||
|
||||
@@ -8,6 +8,7 @@ interface DemoCardProps {
|
||||
title: string;
|
||||
poster: string;
|
||||
rate?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
function SearchCircle({
|
||||
@@ -54,12 +55,12 @@ function SearchCircle({
|
||||
);
|
||||
}
|
||||
|
||||
const DemoCard = ({ id, title, poster, rate }: DemoCardProps) => {
|
||||
const DemoCard = ({ id, title, poster, rate, type }: DemoCardProps) => {
|
||||
const [hover, setHover] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleClick = () => {
|
||||
router.push(`/aggregate?q=${encodeURIComponent(title)}`);
|
||||
router.push(`/aggregate?q=${encodeURIComponent(title)}&type=${type}`);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Heart } from 'lucide-react';
|
||||
import { Heart, LinkIcon } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
@@ -17,6 +17,7 @@ interface VideoCardProps {
|
||||
year?: string;
|
||||
from?: string;
|
||||
currentEpisode?: number;
|
||||
douban_id?: number;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
@@ -83,6 +84,7 @@ export default function VideoCard({
|
||||
year,
|
||||
from,
|
||||
currentEpisode,
|
||||
douban_id,
|
||||
onDelete,
|
||||
}: VideoCardProps) {
|
||||
const [playHover, setPlayHover] = useState(false);
|
||||
@@ -243,6 +245,20 @@ export default function VideoCard({
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{douban_id && from === 'search' && (
|
||||
<a
|
||||
href={`https://movie.douban.com/subject/${douban_id}`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className='absolute top-2 left-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200'
|
||||
>
|
||||
<div className='w-4 h-4 sm:w-7 sm:h-7 rounded-full bg-green-500 flex items-center justify-center transition-all duration-200 hover:scale-110'>
|
||||
<LinkIcon className='w-4 h-4 text-white' strokeWidth={2} />
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 信息层 */}
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface VideoDetail {
|
||||
year: string;
|
||||
desc?: string;
|
||||
type_name?: string;
|
||||
douban_id?: number;
|
||||
}
|
||||
|
||||
interface FetchVideoDetailOptions {
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface SearchResult {
|
||||
year: string;
|
||||
desc?: string;
|
||||
type_name?: string;
|
||||
douban_id?: number;
|
||||
}
|
||||
|
||||
export interface DoubanItem {
|
||||
|
||||
Reference in New Issue
Block a user