From 40a4e29c7e7bdce161da44350bf91f1bfa1e054d Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 12 Jul 2025 14:57:03 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E6=B7=B1=E5=BA=A6=E9=93=BE?= =?UTF-8?q?=E6=8E=A5=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/types.ts | 4 + src/components/cards/BackdropCard.vue | 4 +- src/components/cards/LibraryCard.vue | 12 +- src/components/cards/PosterCard.vue | 4 +- ...{mediaServerDeepLink.ts => appDeepLink.ts} | 393 ++++++++++++++---- src/views/discover/MediaDetailView.vue | 25 +- 6 files changed, 337 insertions(+), 105 deletions(-) rename src/utils/{mediaServerDeepLink.ts => appDeepLink.ts} (50%) diff --git a/src/api/types.ts b/src/api/types.ts index a3125334..becad18a 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -986,6 +986,8 @@ export interface MediaServerPlayItem { link?: string // 播放百分比 percent?: number + // 媒体服务器类型 + server_type?: string } // 媒体服务器媒体库 @@ -1006,6 +1008,8 @@ export interface MediaServerLibrary { image_list?: string[] // 链接 link?: string + // 媒体服务器类型 + server_type?: string } // 消息通知 diff --git a/src/components/cards/BackdropCard.vue b/src/components/cards/BackdropCard.vue index 822831ee..de9851de 100644 --- a/src/components/cards/BackdropCard.vue +++ b/src/components/cards/BackdropCard.vue @@ -1,6 +1,6 @@ diff --git a/src/utils/mediaServerDeepLink.ts b/src/utils/appDeepLink.ts similarity index 50% rename from src/utils/mediaServerDeepLink.ts rename to src/utils/appDeepLink.ts index cbc05a5c..7751ba4a 100644 --- a/src/utils/mediaServerDeepLink.ts +++ b/src/utils/appDeepLink.ts @@ -1,17 +1,18 @@ /** - * 媒体服务器深度链接工具 - * 支持Plex、Jellyfin、Emby的APP跳转和网页跳转 + * 通用APP深度链接工具类 + * 支持媒体服务器(Plex、Jellyfin、Emby)和豆瓣的APP跳转和网页跳转 * * 深度链接格式参考: * - Plex: https://forums.plex.tv/t/plex-mobile-app-deep-linking/123456 * - Emby: https://emby.media/support/articles/Deep-Linking.html * - Jellyfin: https://jellyfin.org/docs/general/administration/deep-linking + * - 豆瓣: 官方搜索格式 */ import { isMobileDevice, isIOSDevice, isAndroidDevice } from '@/@core/utils' -// 媒体服务器类型 -export type MediaServerType = 'plex' | 'jellyfin' | 'emby' +// APP类型 +export type AppType = 'plex' | 'jellyfin' | 'emby' | 'trimemedia' | 'douban' // 深度链接配置 interface DeepLinkConfig { @@ -20,8 +21,8 @@ interface DeepLinkConfig { timeout: number } -// 各媒体服务器的深度链接配置 -const DEEP_LINK_CONFIGS: Record = { +// 各APP的深度链接配置 +const DEEP_LINK_CONFIGS: Record = { plex: { appScheme: 'plex://', webUrl: 'https://app.plex.tv', @@ -37,37 +38,53 @@ const DEEP_LINK_CONFIGS: Record = { webUrl: 'https://emby.media', timeout: 2000, }, + trimemedia: { + appScheme: 'trimemedia://', + webUrl: 'https://trimemedia.com', + timeout: 2000, + }, + douban: { + appScheme: 'douban://', + webUrl: 'https://movie.douban.com', + timeout: 2000, + }, +} + +// 豆瓣APP跳转参数 +interface DoubanAppParams { + doubanId: string + mediaType?: string + title?: string + year?: string + fallbackUrl?: string } /** * 尝试跳转到APP,如果失败则跳转到网页 - * @param serverType 媒体服务器类型 - * @param playUrl 播放链接 - * @param fallbackUrl 备用网页链接 + * @param appType APP类型 + * @param params 跳转参数 */ -export async function openMediaServerApp( - serverType: MediaServerType, - playUrl: string, - fallbackUrl?: string, -): Promise { +export async function openApp(appType: AppType, params: string | DoubanAppParams, fallbackUrl?: string): Promise { // 如果不是移动设备,直接使用网页链接 if (!isMobileDevice()) { - window.open(fallbackUrl || playUrl, '_blank') + const webUrl = getWebUrl(appType, params, fallbackUrl) + window.open(webUrl, '_blank') return } - const config = DEEP_LINK_CONFIGS[serverType] + const config = DEEP_LINK_CONFIGS[appType] if (!config) { - console.warn(`不支持的媒体服务器类型: ${serverType}`) - window.open(fallbackUrl || playUrl, '_blank') + console.warn(`不支持的APP类型: ${appType}`) + const webUrl = getWebUrl(appType, params, fallbackUrl) + window.open(webUrl, '_blank') return } // 构建APP深度链接 - const appUrl = buildDeepLinkUrl(serverType, playUrl) + const appUrl = buildDeepLinkUrl(appType, params) - console.log(`构建${serverType}深度链接:`, { - originalUrl: playUrl, + console.log(`构建${appType}深度链接:`, { + params, deepLinkUrl: appUrl, }) @@ -75,106 +92,176 @@ export async function openMediaServerApp( try { await attemptAppLaunch(appUrl, config.timeout) } catch (error) { - console.log(`APP跳转失败,使用网页链接: ${error}`) + console.log(`${appType} APP跳转失败,使用网页链接: ${error}`) // APP跳转失败,使用网页链接 - window.open(fallbackUrl || playUrl, '_blank') + const webUrl = getWebUrl(appType, params, fallbackUrl) + window.open(webUrl, '_blank') + } +} + +/** + * 获取网页链接 + * @param appType APP类型 + * @param params 参数 + * @param fallbackUrl 备用链接 + */ +function getWebUrl(appType: AppType, params: string | DoubanAppParams, fallbackUrl?: string): string { + if (fallbackUrl) return fallbackUrl + + const config = DEEP_LINK_CONFIGS[appType] + + switch (appType) { + case 'douban': + const doubanParams = params as DoubanAppParams + return `${config.webUrl}/subject/${doubanParams.doubanId}` + default: + return typeof params === 'string' ? params : config.webUrl } } /** * 构建深度链接URL - * @param serverType 媒体服务器类型 - * @param playUrl 播放链接 + * @param appType APP类型 + * @param params 参数 */ -function buildDeepLinkUrl(serverType: MediaServerType, playUrl: string): string { - switch (serverType) { +function buildDeepLinkUrl(appType: AppType, params: string | DoubanAppParams): string { + switch (appType) { case 'plex': - return buildPlexDeepLink(playUrl) + return buildPlexDeepLink(params as string) case 'jellyfin': - return buildJellyfinDeepLink(playUrl) + return buildJellyfinDeepLink(params as string) case 'emby': - return buildEmbyDeepLink(playUrl) + return buildEmbyDeepLink(params as string) + + case 'trimemedia': + return buildTrimemediaDeepLink(params as string) + + case 'douban': + return buildDoubanDeepLink(params as DoubanAppParams) default: - return playUrl + return typeof params === 'string' ? params : '' } } /** * 构建Plex深度链接 * 参考: https://forums.plex.tv/t/plex-mobile-app-deep-linking/123456 + * + * 后台API返回格式: + * - 媒体库: web/index.html#!/media/{machineIdentifier}/com.plexapp.plugins.library?source={library.key}&X-Plex-Token={token} + * - 媒体项: web/index.html#!/server/{machineIdentifier}/details?key={item_id}&X-Plex-Token={token} + * + * Plex官方APP URL格式: + * - 媒体库: http://[PMS_IP_Address]:32400/library/sections/29/all?X-Plex-Token=YourTokenGoesHere + * - 媒体项: http://[PMS_IP_ADDRESS]:32400/library/metadata/1668?X-Plex-Token=YourTokenGoesHere + * * @param playUrl 播放链接 */ function buildPlexDeepLink(playUrl: string): string { try { const url = new URL(playUrl) - // 提取媒体ID和机器标识符 + // 提取媒体ID、机器标识符、库ID等 let mediaId: string | null = null let machineIdentifier: string | null = null let libraryKey: string | null = null + let librarySectionId: string | null = null + let plexToken: string | null = null - // 格式1: web/index.html#!/media/{machineIdentifier}/com.plexapp.plugins.library?source={library.key} + // 提取X-Plex-Token + const tokenMatch = playUrl.match(/X-Plex-Token=([^&]+)/) + if (tokenMatch) { + plexToken = tokenMatch[1] + console.log('提取Plex Token:', { plexToken }) + } + + // 格式1: 后台API返回的媒体库格式 + // web/index.html#!/media/{machineIdentifier}/com.plexapp.plugins.library?source={library.key}&X-Plex-Token={token} const mediaLibraryMatch = playUrl.match(/\/media\/([^\/]+)\/com\.plexapp\.plugins\.library\?source=([^&]+)/) if (mediaLibraryMatch) { machineIdentifier = mediaLibraryMatch[1] libraryKey = mediaLibraryMatch[2] - // 对于库链接,我们使用库的key作为媒体ID - mediaId = libraryKey + console.log('Plex后台API媒体库格式匹配:', { machineIdentifier, libraryKey }) + + // 从library.key中提取section ID + // library.key格式通常是: library://video-section/1 或类似格式 + const sectionMatch = libraryKey.match(/section\/(\d+)/) + if (sectionMatch) { + librarySectionId = sectionMatch[1] + console.log('从library.key提取section ID:', { librarySectionId }) + } } - // 格式2: web/index.html#!/server/{machineIdentifier}/details?key={item_id} + // 格式2: 后台API返回的媒体项格式 + // web/index.html#!/server/{machineIdentifier}/details?key={item_id}&X-Plex-Token={token} const serverDetailsMatch = playUrl.match(/\/server\/([^\/]+)\/details\?key=([^&]+)/) if (serverDetailsMatch) { machineIdentifier = serverDetailsMatch[1] - mediaId = serverDetailsMatch[2] - } + const keyValue = serverDetailsMatch[2] + console.log('Plex后台API媒体项格式匹配:', { machineIdentifier, keyValue }) - // 格式3: plex.tv/tv/xxx 或 plex.tv/movie/xxx (传统格式) - if (!mediaId) { - const plexMatch = playUrl.match(/plex\.tv\/(tv|movie)\/([^\/\?]+)/) - if (plexMatch) { - mediaId = plexMatch[2] - } - } - - // 格式4: /media/plex.tv/tv/xxx (传统格式) - if (!mediaId) { - const mediaMatch = playUrl.match(/\/media\/plex\.tv\/(tv|movie)\/([^\/\?]+)/) - if (mediaMatch) { - mediaId = mediaMatch[2] - } - } - - // 如果还没有提取到机器标识符,尝试从其他路径中提取 - if (!machineIdentifier) { - const mediaPathMatch = playUrl.match(/\/media\/([^\/]+)/) - if (mediaPathMatch) { - machineIdentifier = mediaPathMatch[1] + // 从key中提取媒体ID + // key格式可能是: /library/metadata/1668 或直接是 1668 + const metadataMatch = keyValue.match(/\/library\/metadata\/(\d+)/) + if (metadataMatch) { + mediaId = metadataMatch[1] + console.log('从key提取媒体ID:', { mediaId }) + } else if (/^\d+$/.test(keyValue)) { + // 如果key本身就是数字,直接使用 + mediaId = keyValue + console.log('key本身就是媒体ID:', { mediaId }) } } + // 构建深度链接 if (mediaId) { - // Plex深度链接格式: plex://{媒体ID} - const deepLink = `plex://${mediaId}` + // http://[PMS_IP_ADDRESS]:32400/library/metadata/1668?X-Plex-Token=YourTokenGoesHere + let deepLink = `plex://library/metadata/${mediaId}` + if (plexToken) { + deepLink += `?X-Plex-Token=${plexToken}` + } console.log('Plex深度链接构建成功:', { originalUrl: playUrl, machineIdentifier, libraryKey, + librarySectionId, mediaId, + plexToken, deepLink, }) return deepLink } + // 如果有库ID,尝试使用库ID + if (librarySectionId) { + // http://[PMS_IP_Address]:32400/library/sections/29/all?X-Plex-Token=YourTokenGoesHere + let libraryLink = `plex://library/sections/${librarySectionId}/all` + if (plexToken) { + libraryLink += `?X-Plex-Token=${plexToken}` + } + console.log('Plex库深度链接构建成功:', { + originalUrl: playUrl, + librarySectionId, + plexToken, + libraryLink, + }) + return libraryLink + } + // 如果无法提取媒体ID,尝试使用机器标识符 if (machineIdentifier) { - const fallbackLink = `plex://${machineIdentifier}` + // http://[PMS_IP_Address]:32400/library/sections?X-Plex-Token=YourTokenGoesHere + let fallbackLink = `plex://library/sections` + if (plexToken) { + fallbackLink += `?X-Plex-Token=${plexToken}` + } console.log('Plex深度链接构建失败,使用机器标识符:', { originalUrl: playUrl, machineIdentifier, + plexToken, fallbackLink, }) return fallbackLink @@ -288,6 +375,8 @@ function buildJellyfinDeepLink(playUrl: string): string { /** * 构建Emby深度链接 * 参考: https://emby.media/support/articles/Deep-Linking.html + * iOS格式: emby://items?serverId={SERVER_ID}&itemId={ITEM_ID} + * Android格式: emby://{服务器地址}/item/{媒体ID} * @param playUrl 播放链接 */ function buildEmbyDeepLink(playUrl: string): string { @@ -351,11 +440,23 @@ function buildEmbyDeepLink(playUrl: string): string { } if (mediaId) { - // Emby深度链接格式: emby://{服务器地址}/item/{媒体ID} - // 如果有serverId,也包含进去 - let deepLink = `emby://${serverAddress}/item/${mediaId}` - if (serverId) { - deepLink += `?serverId=${serverId}` + let deepLink: string + + // 根据设备类型使用不同的深度链接格式 + if (isIOSDevice()) { + // iOS格式: emby://items?serverId={SERVER_ID}&itemId={ITEM_ID} + if (serverId) { + deepLink = `emby://items?serverId=${serverId}&itemId=${mediaId}` + } else { + // 如果没有serverId,尝试使用服务器地址作为serverId + deepLink = `emby://items?serverId=${serverAddress}&itemId=${mediaId}` + } + } else { + // Android格式: emby://{服务器地址}/item/{媒体ID} + deepLink = `emby://${serverAddress}/item/${mediaId}` + if (serverId) { + deepLink += `?serverId=${serverId}` + } } console.log('Emby深度链接构建成功:', { @@ -363,6 +464,7 @@ function buildEmbyDeepLink(playUrl: string): string { serverAddress, mediaId, serverId, + deviceType: isIOSDevice() ? 'iOS' : 'Android', deepLink, }) return deepLink @@ -383,6 +485,80 @@ function buildEmbyDeepLink(playUrl: string): string { } } +/** + * 构建Trimemedia深度链接 + * @param playUrl 播放链接 + */ +function buildTrimemediaDeepLink(playUrl: string): string { + try { + const url = new URL(playUrl) + const serverAddress = url.hostname + (url.port ? `:${url.port}` : '') + + // 提取媒体ID + let mediaId: string | null = null + + // 尝试从URL路径中提取媒体ID + const pathMatch = playUrl.match(/\/item\/([^\/\?]+)/) + if (pathMatch) { + mediaId = pathMatch[1] + } + + // 尝试从查询参数中提取媒体ID + if (!mediaId) { + const idMatch = playUrl.match(/[?&]id=([^&]+)/) + if (idMatch) { + mediaId = idMatch[1] + } + } + + // 构建深度链接 + if (mediaId) { + const deepLink = `trimemedia://${serverAddress}/item/${mediaId}` + console.log('Trimemedia深度链接构建成功:', { + originalUrl: playUrl, + serverAddress, + mediaId, + deepLink, + }) + return deepLink + } + + // 如果无法提取媒体ID,尝试直接使用服务器地址 + const fallbackLink = `trimemedia://${serverAddress}` + console.log('Trimemedia深度链接构建失败,使用服务器地址:', { + originalUrl: playUrl, + serverAddress, + fallbackLink, + }) + return fallbackLink + } catch (error) { + console.warn('构建Trimemedia深度链接失败:', error) + return playUrl + } +} + +/** + * 构建豆瓣深度链接 + * 使用豆瓣App官方支持的搜索格式 + * @param params 豆瓣参数 + */ +function buildDoubanDeepLink(params: DoubanAppParams): string { + const { title, year } = params + + // 使用豆瓣App官方支持的搜索格式 + // 格式:douban:///search?q={query} + const searchQuery = `${title || ''} ${year || ''}`.trim() + const deepLink = `douban:///search?q=${encodeURIComponent(searchQuery)}` + + console.log('豆瓣深度链接构建成功:', { + params, + searchQuery, + deepLink, + }) + + return deepLink +} + /** * 尝试启动APP * @param appUrl APP深度链接 @@ -431,23 +607,38 @@ async function attemptAppLaunch(appUrl: string, timeout: number): Promise * 根据播放链接自动检测媒体服务器类型并跳转 * @param playUrl 播放链接 * @param fallbackUrl 备用网页链接 + * @param serverType 媒体服务器类型(可选,优先使用此参数) */ -export async function openMediaServerWithAutoDetect(playUrl: string, fallbackUrl?: string): Promise { - // 从URL中检测媒体服务器类型 - const url = playUrl.toLowerCase() +export async function openMediaServerWithAutoDetect( + playUrl: string, + fallbackUrl?: string, + serverType?: string, +): Promise { + let detectedServerType: AppType | null = null - let serverType: MediaServerType | null = null - - if (url.includes('plex') || url.includes('plex.tv')) { - serverType = 'plex' - } else if (url.includes('jellyfin')) { - serverType = 'jellyfin' - } else if (url.includes('emby')) { - serverType = 'emby' + // 优先使用传入的 serverType 参数 + if (serverType) { + const type = serverType.toLowerCase() + if (type === 'plex' || type === 'jellyfin' || type === 'emby' || type === 'trimemedia') { + detectedServerType = type as AppType + } } - if (serverType) { - await openMediaServerApp(serverType, playUrl, fallbackUrl) + // 如果没有传入 serverType 或类型不支持,则从URL中检测 + if (!detectedServerType) { + const url = playUrl.toLowerCase() + + if (url.includes('plex') || url.includes('plex.tv')) { + detectedServerType = 'plex' + } else if (url.includes('jellyfin')) { + detectedServerType = 'jellyfin' + } else if (url.includes('emby')) { + detectedServerType = 'emby' + } + } + + if (detectedServerType) { + await openApp(detectedServerType, playUrl, fallbackUrl) } else { // 无法检测到服务器类型,直接使用网页链接 window.open(fallbackUrl || playUrl, '_blank') @@ -455,28 +646,58 @@ export async function openMediaServerWithAutoDetect(playUrl: string, fallbackUrl } /** - * 获取媒体服务器的APP下载链接 - * @param serverType 媒体服务器类型 + * 打开豆瓣APP + * @param doubanId 豆瓣ID + * @param mediaType 媒体类型(电影/电视剧) + * @param title 媒体标题 + * @param year 媒体年份 + * @param fallbackUrl 备用网页链接 */ -export function getAppDownloadUrl(serverType: MediaServerType): string { - switch (serverType) { +export async function openDoubanApp( + doubanId: string, + mediaType?: string, + title?: string, + year?: string, + fallbackUrl?: string, +): Promise { + const params: DoubanAppParams = { + doubanId, + mediaType, + title, + year, + fallbackUrl, + } + + await openApp('douban', params, fallbackUrl) +} + +/** + * 获取APP的下载链接 + * @param appType APP类型 + */ +export function getAppDownloadUrl(appType: AppType): string { + switch (appType) { case 'plex': return 'https://www.plex.tv/apps/' case 'jellyfin': return 'https://jellyfin.org/downloads/' case 'emby': return 'https://emby.media/download.html' + case 'trimemedia': + return 'https://trimemedia.com/download' + case 'douban': + return 'https://www.douban.com/doubanapp/' default: return '' } } /** - * 检查是否安装了特定的媒体服务器APP + * 检查是否安装了特定的APP * 注意:由于浏览器安全限制,无法直接检测APP是否安装 * 这个方法主要用于提示用户 */ -export function checkAppInstalled(serverType: MediaServerType): boolean { +export function checkAppInstalled(appType: AppType): boolean { // 由于浏览器安全限制,无法直接检测APP是否安装 // 这里可以根据用户代理或其他信息进行推测 // 目前返回false,让系统总是尝试跳转 diff --git a/src/views/discover/MediaDetailView.vue b/src/views/discover/MediaDetailView.vue index 524986db..df524566 100644 --- a/src/views/discover/MediaDetailView.vue +++ b/src/views/discover/MediaDetailView.vue @@ -16,7 +16,7 @@ import { useTheme } from 'vuetify' import { useI18n } from 'vue-i18n' import { hasPermission } from '@/utils/permission' import { useGlobalSettingsStore } from '@/stores' -import { openMediaServerWithAutoDetect } from '@/utils/mediaServerDeepLink' +import { openMediaServerWithAutoDetect, openDoubanApp } from '@/utils/appDeepLink' // 国际化 const { t } = useI18n() @@ -354,6 +354,18 @@ function getDoubanLink() { return `https://movie.douban.com/subject/${mediaDetail.value.douban_id}` } +// 处理豆瓣链接点击 +async function handleDoubanClick() { + if (mediaDetail.value.douban_id) { + await openDoubanApp( + mediaDetail.value.douban_id, + mediaDetail.value.type, + mediaDetail.value.title, + mediaDetail.value.year, + ) + } +} + // 拼装IMDB地址 function getImdbLink() { return `https://www.imdb.com/title/${mediaDetail.value.imdb_id}` @@ -477,7 +489,7 @@ async function handlePlay() { const result: { [key: string]: any } = await api.get(`mediaserver/play/${existsItemId.value}`) if (result?.success) { // 使用深度链接工具,优先跳转到APP,失败后跳转到网页 - await openMediaServerWithAutoDetect(result.data.url) + await openMediaServerWithAutoDetect(result.data.url, undefined, result.data.server_type) } else { $toast.error(`获取播放链接失败:${result.message}!`) } @@ -668,19 +680,14 @@ onBeforeMount(() => { TheMovieDb - +