From 0928756d8ce74d84f48890afd23fc0f20f362e27 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Sat, 3 Jan 2026 18:47:41 +0800 Subject: [PATCH] =?UTF-8?q?emby=E5=88=86=E7=B1=BB=E8=8E=B7=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/emby/list/route.ts | 6 +- src/app/api/emby/views/route.ts | 81 ++++++++++++++ src/app/private-library/page.tsx | 182 ++++++++++++++++++++++++++++++- src/lib/emby-cache.ts | 43 +++++++- src/lib/emby.client.ts | 49 +++++++++ 5 files changed, 349 insertions(+), 12 deletions(-) create mode 100644 src/app/api/emby/views/route.ts diff --git a/src/app/api/emby/list/route.ts b/src/app/api/emby/list/route.ts index 98cfe20..88fba0a 100644 --- a/src/app/api/emby/list/route.ts +++ b/src/app/api/emby/list/route.ts @@ -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) { diff --git a/src/app/api/emby/views/route.ts b/src/app/api/emby/views/route.ts new file mode 100644 index 0000000..57780e1 --- /dev/null +++ b/src/app/api/emby/views/route.ts @@ -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: [], + }); + } +} diff --git a/src/app/private-library/page.tsx b/src/app/private-library/page.tsx index a89a629..62bac5b 100644 --- a/src/app/private-library/page.tsx +++ b/src/app/private-library/page.tsx @@ -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('openlist'); const [videos, setVideos] = useState([]); 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([]); + const [selectedView, setSelectedView] = useState('all'); + const [loadingViews, setLoadingViews] = useState(false); const pageSize = 20; const observerTarget = useRef(null); const isFetchingRef = useRef(false); + const scrollContainerRef = useRef(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) => { + 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) => { + 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() { /> + {/* Emby 分类选择器 */} + {source === 'emby' && ( +
+ {loadingViews ? ( +
+
+
+ ) : embyViews.length > 0 ? ( +
+
+
+ + {embyViews.map((view) => ( + + ))} +
+
+
+ ) : null} +
+ )} + {error && (

{error}

diff --git a/src/lib/emby-cache.ts b/src/lib/emby-cache.ts index c24f162..559298b 100644 --- a/src/lib/emby-cache.ts +++ b/src/lib/emby-cache.ts @@ -8,13 +8,15 @@ export interface EmbyCachedEntry { // 缓存配置 const EMBY_CACHE_TTL_MS = 6 * 60 * 60 * 1000; // 6小时 +const EMBY_VIEWS_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 1天 const EMBY_CACHE: Map> = new Map(); +const EMBY_VIEWS_CACHE_KEY = 'emby:views'; /** * 生成 Emby 列表缓存键 */ -function makeListCacheKey(page: number, pageSize: number): string { - return `emby:list:${page}:${pageSize}`; +function makeListCacheKey(page: number, pageSize: number, parentId?: string): string { + return parentId ? `emby:list:${page}:${pageSize}:${parentId}` : `emby:list:${page}:${pageSize}`; } /** @@ -22,9 +24,10 @@ function makeListCacheKey(page: number, pageSize: number): string { */ export function getCachedEmbyList( page: number, - pageSize: number + pageSize: number, + parentId?: string ): any | null { - const key = makeListCacheKey(page, pageSize); + const key = makeListCacheKey(page, pageSize, parentId); const entry = EMBY_CACHE.get(key); if (!entry) return null; @@ -43,10 +46,11 @@ export function getCachedEmbyList( export function setCachedEmbyList( page: number, pageSize: number, - data: any + data: any, + parentId?: string ): void { const now = Date.now(); - const key = makeListCacheKey(page, pageSize); + const key = makeListCacheKey(page, pageSize, parentId); EMBY_CACHE.set(key, { expiresAt: now + EMBY_CACHE_TTL_MS, data, @@ -62,6 +66,33 @@ export function clearEmbyCache(): { cleared: number } { return { cleared: size }; } +/** + * 获取缓存的 Emby 媒体库列表 + */ +export function getCachedEmbyViews(): any | null { + const entry = EMBY_CACHE.get(EMBY_VIEWS_CACHE_KEY); + if (!entry) return null; + + // 检查是否过期 + if (entry.expiresAt <= Date.now()) { + EMBY_CACHE.delete(EMBY_VIEWS_CACHE_KEY); + return null; + } + + return entry.data; +} + +/** + * 设置缓存的 Emby 媒体库列表 + */ +export function setCachedEmbyViews(data: any): void { + const now = Date.now(); + EMBY_CACHE.set(EMBY_VIEWS_CACHE_KEY, { + expiresAt: now + EMBY_VIEWS_CACHE_TTL_MS, + data, + }); +} + /** * 获取缓存统计信息 */ diff --git a/src/lib/emby.client.ts b/src/lib/emby.client.ts index 51360fe..61df051 100644 --- a/src/lib/emby.client.ts +++ b/src/lib/emby.client.ts @@ -51,6 +51,12 @@ interface GetItemsParams { searchTerm?: string; } +interface EmbyView { + Id: string; + Name: string; + CollectionType?: string; +} + export class EmbyClient { private serverUrl: string; private apiKey?: string; @@ -165,6 +171,49 @@ export class EmbyClient { } } + async getUserViews(): Promise { + await this.ensureAuthenticated(); + + if (!this.userId) { + throw new Error('未配置 Emby 用户 ID,请在管理面板重新保存 Emby 配置'); + } + + const token = this.apiKey || this.authToken; + const url = `${this.serverUrl}/Users/${this.userId}/Views${token ? `?api_key=${token}` : ''}`; + + console.log('[EmbyClient] getUserViews - URL:', url); + + const response = await fetch(url); + + // 如果是 401 错误且有用户名密码,尝试重新认证 + if (response.status === 401 && this.username && this.password && !this.apiKey) { + console.log('[EmbyClient] Token expired, re-authenticating...'); + const authResult = await this.authenticate(this.username, this.password); + this.authToken = authResult.AccessToken; + this.userId = authResult.User.Id; + + // 重试请求 + const retryUrl = `${this.serverUrl}/Users/${this.userId}/Views?api_key=${this.authToken}`; + const retryResponse = await fetch(retryUrl); + + if (!retryResponse.ok) { + const errorText = await retryResponse.text(); + throw new Error(`获取 Emby 媒体库列表失败 (${retryResponse.status}): ${errorText}`); + } + + const retryData = await retryResponse.json(); + return retryData.Items || []; + } + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`获取 Emby 媒体库列表失败 (${response.status}): ${errorText}`); + } + + const data = await response.json(); + return data.Items || []; + } + async getItems(params: GetItemsParams): Promise { await this.ensureAuthenticated();