emby分类获取

This commit is contained in:
mtvpls
2026-01-03 18:49:40 +08:00
parent a10bf361a8
commit 0928756d8c
5 changed files with 349 additions and 12 deletions
+4 -2
View File
@@ -12,10 +12,11 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') || '1');
const pageSize = parseInt(searchParams.get('pageSize') || '20');
const parentId = searchParams.get('parentId') || undefined;
try {
// 检查缓存
const cached = getCachedEmbyList(page, pageSize);
const cached = getCachedEmbyList(page, pageSize, parentId);
if (cached) {
return NextResponse.json(cached);
}
@@ -65,6 +66,7 @@ export async function GET(request: NextRequest) {
// 获取媒体列表
const result = await client.getItems({
ParentId: parentId,
IncludeItemTypes: 'Movie,Series',
Recursive: true,
Fields: 'Overview,ProductionYear',
@@ -94,7 +96,7 @@ export async function GET(request: NextRequest) {
};
// 缓存结果
setCachedEmbyList(page, pageSize, response);
setCachedEmbyList(page, pageSize, response, parentId);
return NextResponse.json(response);
} catch (error) {
+81
View File
@@ -0,0 +1,81 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { NextResponse } from 'next/server';
import { getConfig } from '@/lib/config';
import { EmbyClient } from '@/lib/emby.client';
import { getCachedEmbyViews, setCachedEmbyViews } from '@/lib/emby-cache';
export const runtime = 'nodejs';
export async function GET() {
try {
// 检查缓存
const cached = getCachedEmbyViews();
if (cached) {
return NextResponse.json(cached);
}
const config = await getConfig();
const embyConfig = config.EmbyConfig;
if (!embyConfig?.Enabled || !embyConfig.ServerURL) {
return NextResponse.json({
error: 'Emby 未配置或未启用',
views: [],
});
}
// 创建 Emby 客户端
const client = new EmbyClient(embyConfig);
// 如果使用用户名密码且没有 UserId,需要先认证
if (!embyConfig.ApiKey && !embyConfig.UserId && embyConfig.Username && embyConfig.Password) {
try {
const authResult = await client.authenticate(embyConfig.Username, embyConfig.Password);
embyConfig.UserId = authResult.User.Id;
} catch (error) {
return NextResponse.json({
error: 'Emby 认证失败: ' + (error as Error).message,
views: [],
});
}
}
// 验证认证信息:必须有 ApiKey 或 UserId
if (!embyConfig.ApiKey && !embyConfig.UserId) {
return NextResponse.json({
error: 'Emby 认证失败,请检查配置',
views: [],
});
}
// 获取媒体库列表
const views = await client.getUserViews();
// 过滤出电影和电视剧媒体库
const filteredViews = views.filter(
(view) => view.CollectionType === 'movies' || view.CollectionType === 'tvshows'
);
const response = {
success: true,
views: filteredViews.map((view) => ({
id: view.Id,
name: view.Name,
type: view.CollectionType,
})),
};
// 缓存结果
setCachedEmbyViews(response);
return NextResponse.json(response);
} catch (error) {
console.error('获取 Emby 媒体库列表失败:', error);
return NextResponse.json({
error: '获取 Emby 媒体库列表失败: ' + (error as Error).message,
views: [],
});
}
}
+178 -4
View File
@@ -2,7 +2,7 @@
'use client';
import { useRouter } from 'next/navigation';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState, useRef } from 'react';
import CapsuleSwitch from '@/components/CapsuleSwitch';
@@ -25,8 +25,15 @@ interface Video {
mediaType: 'movie' | 'tv';
}
interface EmbyView {
id: string;
name: string;
type: string;
}
export default function PrivateLibraryPage() {
const router = useRouter();
const searchParams = useSearchParams();
const [source, setSource] = useState<LibrarySource>('openlist');
const [videos, setVideos] = useState<Video[]>([]);
const [loading, setLoading] = useState(true);
@@ -34,19 +41,138 @@ export default function PrivateLibraryPage() {
const [error, setError] = useState('');
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [embyViews, setEmbyViews] = useState<EmbyView[]>([]);
const [selectedView, setSelectedView] = useState<string>('all');
const [loadingViews, setLoadingViews] = useState(false);
const pageSize = 20;
const observerTarget = useRef<HTMLDivElement>(null);
const isFetchingRef = useRef(false);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const isDraggingRef = useRef(false);
const startXRef = useRef(0);
const scrollLeftRef = useRef(0);
const isInitializedRef = useRef(false);
// 切换源时重置所有状态
// 从URL初始化状态
useEffect(() => {
const urlSource = searchParams.get('source') as LibrarySource;
const urlView = searchParams.get('view');
if (urlSource && (urlSource === 'openlist' || urlSource === 'emby')) {
setSource(urlSource);
}
if (urlView) {
setSelectedView(urlView);
}
isInitializedRef.current = true;
}, [searchParams]);
// 更新URL参数
useEffect(() => {
if (!isInitializedRef.current) return;
const params = new URLSearchParams();
params.set('source', source);
if (source === 'emby' && selectedView !== 'all') {
params.set('view', selectedView);
}
router.replace(`/private-library?${params.toString()}`, { scroll: false });
}, [source, selectedView, router]);
// 切换源时重置所有状态(但不在初始化时执行)
useEffect(() => {
if (!isInitializedRef.current) return;
setPage(1);
setVideos([]);
setHasMore(true);
setError('');
setSelectedView('all');
isFetchingRef.current = false;
}, [source]);
// 切换分类时重置状态(但不在初始化时执行)
useEffect(() => {
if (!isInitializedRef.current) return;
setPage(1);
setVideos([]);
setHasMore(true);
setError('');
isFetchingRef.current = false;
}, [selectedView]);
// 获取 Emby 媒体库列表
useEffect(() => {
if (source !== 'emby') return;
const fetchEmbyViews = async () => {
setLoadingViews(true);
try {
const response = await fetch('/api/emby/views');
const data = await response.json();
if (data.error) {
console.error('获取 Emby 媒体库列表失败:', data.error);
setEmbyViews([]);
} else {
setEmbyViews(data.views || []);
// 分类加载完成后,检查URL中是否有view参数
const urlView = searchParams.get('view');
if (urlView && data.views && data.views.length > 0) {
// 检查该view是否存在于分类列表中
const viewExists = data.views.some((v: EmbyView) => v.id === urlView);
if (viewExists) {
setSelectedView(urlView);
}
}
}
} catch (err) {
console.error('获取 Emby 媒体库列表失败:', err);
setEmbyViews([]);
} finally {
setLoadingViews(false);
}
};
fetchEmbyViews();
}, [source]);
// 鼠标拖动滚动
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
if (!scrollContainerRef.current) return;
isDraggingRef.current = true;
startXRef.current = e.pageX - scrollContainerRef.current.offsetLeft;
scrollLeftRef.current = scrollContainerRef.current.scrollLeft;
scrollContainerRef.current.style.cursor = 'grabbing';
scrollContainerRef.current.style.userSelect = 'none';
};
const handleMouseLeave = () => {
if (!scrollContainerRef.current) return;
isDraggingRef.current = false;
scrollContainerRef.current.style.cursor = 'grab';
scrollContainerRef.current.style.userSelect = 'auto';
};
const handleMouseUp = () => {
if (!scrollContainerRef.current) return;
isDraggingRef.current = false;
scrollContainerRef.current.style.cursor = 'grab';
scrollContainerRef.current.style.userSelect = 'auto';
};
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
if (!isDraggingRef.current || !scrollContainerRef.current) return;
e.preventDefault();
const x = e.pageX - scrollContainerRef.current.offsetLeft;
const walk = (x - startXRef.current) * 2; // 滚动速度倍数
scrollContainerRef.current.scrollLeft = scrollLeftRef.current - walk;
};
// 加载数据的函数
useEffect(() => {
const fetchVideos = async () => {
@@ -69,7 +195,7 @@ export default function PrivateLibraryPage() {
const endpoint = source === 'openlist'
? `/api/openlist/list?page=${page}&pageSize=${pageSize}`
: `/api/emby/list?page=${page}&pageSize=${pageSize}`;
: `/api/emby/list?page=${page}&pageSize=${pageSize}${selectedView !== 'all' ? `&parentId=${selectedView}` : ''}`;
const response = await fetch(endpoint);
@@ -116,7 +242,7 @@ export default function PrivateLibraryPage() {
};
fetchVideos();
}, [source, page]);
}, [source, page, selectedView]);
const handleVideoClick = (video: Video) => {
// 跳转到播放页面
@@ -175,6 +301,54 @@ export default function PrivateLibraryPage() {
/>
</div>
{/* Emby 分类选择器 */}
{source === 'emby' && (
<div className='mb-6'>
{loadingViews ? (
<div className='flex justify-center'>
<div className='w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full animate-spin' />
</div>
) : embyViews.length > 0 ? (
<div className='relative'>
<div
ref={scrollContainerRef}
className='overflow-x-auto scrollbar-hide cursor-grab active:cursor-grabbing'
onMouseDown={handleMouseDown}
onMouseLeave={handleMouseLeave}
onMouseUp={handleMouseUp}
onMouseMove={handleMouseMove}
>
<div className='flex gap-2 px-4 min-w-min'>
<button
onClick={() => setSelectedView('all')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap flex-shrink-0 ${
selectedView === 'all'
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
</button>
{embyViews.map((view) => (
<button
key={view.id}
onClick={() => setSelectedView(view.id)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap flex-shrink-0 ${
selectedView === view.id
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
{view.name}
</button>
))}
</div>
</div>
</div>
) : null}
</div>
)}
{error && (
<div className='bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-6'>
<p className='text-red-800 dark:text-red-200'>{error}</p>