/** * 通用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' // APP类型 export type AppType = 'plex' | 'jellyfin' | 'emby' | 'trimemedia' | 'douban' // 深度链接配置 interface DeepLinkConfig { appScheme: string webUrl: string timeout: number } // 各APP的深度链接配置 const DEEP_LINK_CONFIGS: Record = { plex: { appScheme: 'plex://', webUrl: 'https://app.plex.tv', timeout: 2000, }, jellyfin: { appScheme: 'jellyfin://', webUrl: 'https://jellyfin.org', timeout: 2000, }, emby: { appScheme: 'emby://', 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 appType APP类型 * @param params 跳转参数 */ export async function openApp(appType: AppType, params: string | DoubanAppParams, fallbackUrl?: string): Promise { // 如果不是移动设备,直接使用网页链接 if (!isMobileDevice()) { const webUrl = getWebUrl(appType, params, fallbackUrl) window.open(webUrl, '_blank') return } const config = DEEP_LINK_CONFIGS[appType] if (!config) { console.warn(`不支持的APP类型: ${appType}`) const webUrl = getWebUrl(appType, params, fallbackUrl) window.open(webUrl, '_blank') return } // 构建APP深度链接 const appUrl = buildDeepLinkUrl(appType, params) console.log(`构建${appType}深度链接:`, { params, deepLinkUrl: appUrl, }) // 尝试跳转到APP try { await attemptAppLaunch(appUrl, config.timeout) } catch (error) { console.log(`${appType} APP跳转失败,使用网页链接: ${error}`) // APP跳转失败,使用网页链接 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 appType APP类型 * @param params 参数 */ function buildDeepLinkUrl(appType: AppType, params: string | DoubanAppParams): string { switch (appType) { case 'plex': return buildPlexDeepLink(params as string) case 'jellyfin': return buildJellyfinDeepLink(params as string) case 'emby': return buildEmbyDeepLink(params as string) case 'trimemedia': return buildTrimemediaDeepLink(params as string) case 'douban': return buildDoubanDeepLink(params as DoubanAppParams) default: 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格式: * plex://play/?metadataKey=/library/metadata/$SOME_ID&server=$SERVER_ID * 例如: plex://play/?metadataKey=/library/metadata/123&server=456 * * @param playUrl 播放链接 */ function buildPlexDeepLink(playUrl: string): string { try { const url = new URL(playUrl) // 提取媒体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 // 提取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] 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: 后台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] const keyValue = serverDetailsMatch[2] console.log('Plex后台API媒体项格式匹配:', { machineIdentifier, keyValue }) // 从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 && machineIdentifier) { // plex://play/?metadataKey=/library/metadata/$SOME_ID&server=$SERVER_ID let deepLink = `plex://play/?metadataKey=/library/metadata/${mediaId}&server=${machineIdentifier}` if (plexToken) { deepLink += `&X-Plex-Token=${plexToken}` } console.log('Plex深度链接构建成功:', { originalUrl: playUrl, machineIdentifier, libraryKey, librarySectionId, mediaId, plexToken, deepLink, }) return deepLink } // 如果有媒体ID但没有机器标识符,尝试使用旧的格式作为降级 if (mediaId) { let deepLink = `plex://library/metadata/${mediaId}` if (plexToken) { deepLink += `?X-Plex-Token=${plexToken}` } console.log('Plex深度链接构建成功(降级格式):', { originalUrl: playUrl, 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) { // 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 } // 最后的降级方案 console.log('Plex深度链接构建失败,使用原始URL:', { originalUrl: playUrl, }) return `plex://${playUrl}` } catch (error) { console.warn('构建Plex深度链接失败:', error) return `plex://${playUrl}` } } /** * 构建Jellyfin深度链接 * 参考: https://jellyfin.org/docs/general/administration/deep-linking * @param playUrl 播放链接 */ function buildJellyfinDeepLink(playUrl: string): string { try { const url = new URL(playUrl) const serverAddress = url.hostname + (url.port ? `:${url.port}` : '') // 提取媒体ID、库ID、serverId let mediaId: string | null = null let libraryId: string | null = null let serverId: string | null = null // 格式1: /details?id={item_id}&serverId={serverid} const detailsMatch = playUrl.match(/\/details\?id=([^&]+)&serverId=([^&]+)/) if (detailsMatch) { mediaId = detailsMatch[1] serverId = detailsMatch[2] } // 格式2: /movies.html?topParentId={libraryId} const moviesMatch = playUrl.match(/\/movies\.html\?topParentId=([^&]+)/) if (moviesMatch) { libraryId = moviesMatch[1] } // 格式3: /tv.html?topParentId={libraryId} const tvMatch = playUrl.match(/\/tv\.html\?topParentId=([^&]+)/) if (tvMatch) { libraryId = tvMatch[1] } // 格式4: /library.html?topParentId={libraryId} const libMatch = playUrl.match(/\/library\.html\?topParentId=([^&]+)/) if (libMatch) { libraryId = libMatch[1] } // 兼容原有格式:?id=xxx if (!mediaId) { const idMatch = playUrl.match(/[?&]id=([^&]+)/) if (idMatch) { mediaId = idMatch[1] } } // 兼容原有格式:/items/xxx if (!mediaId) { const itemsMatch = playUrl.match(/\/items\/([^\/\?]+)/) if (itemsMatch) { mediaId = itemsMatch[1] } } // 构建深度链接 if (mediaId) { let deepLink = `jellyfin://${serverAddress}/item/${mediaId}` if (serverId) { deepLink += `?serverId=${serverId}` } console.log('Jellyfin深度链接构建成功:', { originalUrl: playUrl, serverAddress, mediaId, serverId, deepLink, }) return deepLink } if (libraryId) { const deepLink = `jellyfin://${serverAddress}/library/${libraryId}` console.log('Jellyfin库深度链接构建成功:', { originalUrl: playUrl, serverAddress, libraryId, deepLink, }) return deepLink } // 如果无法提取ID,尝试直接使用服务器地址 const fallbackLink = `jellyfin://${serverAddress}` console.log('Jellyfin深度链接构建失败,使用服务器地址:', { originalUrl: playUrl, serverAddress, fallbackLink, }) return fallbackLink } catch (error) { console.warn('构建Jellyfin深度链接失败:', error) return `jellyfin://${playUrl}` } } /** * 构建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 { try { const url = new URL(playUrl) const serverAddress = url.hostname + (url.port ? `:${url.port}` : '') // 尝试多种格式提取媒体ID let mediaId: string | null = null let serverId: string | null = null // 格式1: /web/index.html#!/item?id=xxx&context=home&serverId=xxx (后台返回的格式) const itemHashMatch = playUrl.match(/\/item\?id=([^&]+)/) if (itemHashMatch) { mediaId = itemHashMatch[1] // 提取serverId const serverIdMatch = playUrl.match(/serverId=([^&]+)/) if (serverIdMatch) { serverId = serverIdMatch[1] } } // 格式2: /web/index.html#!/videos?serverId=xxx&parentId=xxx (后台返回的格式) const videosHashMatch = playUrl.match(/\/videos\?serverId=([^&]+)&parentId=([^&]+)/) if (videosHashMatch) { // 对于videos格式,我们使用parentId作为媒体ID mediaId = videosHashMatch[2] serverId = videosHashMatch[1] } // 格式3: ?id=xxx (通用格式) if (!mediaId) { const idMatch = playUrl.match(/[?&]id=([^&]+)/) if (idMatch) { mediaId = idMatch[1] } } // 格式4: /itemdetails.html?id=xxx if (!mediaId) { const itemMatch = playUrl.match(/\/itemdetails\.html\?id=([^&]+)/) if (itemMatch) { mediaId = itemMatch[1] } } // 格式5: /items/xxx if (!mediaId) { const itemsMatch = playUrl.match(/\/items\/([^\/\?]+)/) if (itemsMatch) { mediaId = itemsMatch[1] } } // 格式6: /item/xxx (路径格式) if (!mediaId) { const itemPathMatch = playUrl.match(/\/item\/([^\/\?]+)/) if (itemPathMatch) { mediaId = itemPathMatch[1] } } if (mediaId) { 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深度链接构建成功:', { originalUrl: playUrl, serverAddress, mediaId, serverId, deviceType: isIOSDevice() ? 'iOS' : 'Android', deepLink, }) return deepLink } // 如果无法提取媒体ID,尝试直接使用服务器地址 // 这会打开Emby APP的主界面 const fallbackLink = `emby://${serverAddress}` console.log('Emby深度链接构建失败,使用服务器地址:', { originalUrl: playUrl, serverAddress, fallbackLink, }) return fallbackLink } catch (error) { console.warn('构建Emby深度链接失败:', error) return playUrl } } /** * 构建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深度链接 * @param timeout 超时时间 */ async function attemptAppLaunch(appUrl: string, timeout: number): Promise { return new Promise((resolve, reject) => { // 创建一个隐藏的iframe来尝试启动APP const iframe = document.createElement('iframe') iframe.style.display = 'none' iframe.src = appUrl // 设置超时 const timeoutId = setTimeout(() => { document.body.removeChild(iframe) reject(new Error('APP启动超时')) }, timeout) // 监听页面可见性变化,如果用户切换到APP,说明启动成功 const handleVisibilityChange = () => { if (document.hidden) { clearTimeout(timeoutId) document.removeEventListener('visibilitychange', handleVisibilityChange) document.body.removeChild(iframe) resolve() } } document.addEventListener('visibilitychange', handleVisibilityChange) // 添加到页面并尝试启动 document.body.appendChild(iframe) // 对于iOS,还需要尝试window.location if (isIOSDevice()) { try { window.location.href = appUrl } catch (error) { console.log('iOS window.location跳转失败:', error) } } }) } /** * 根据播放链接自动检测媒体服务器类型并跳转 * @param playUrl 播放链接 * @param fallbackUrl 备用网页链接 * @param serverType 媒体服务器类型(可选,优先使用此参数) */ export async function openMediaServerWithAutoDetect( playUrl: string, fallbackUrl?: string, serverType?: string, ): Promise { let detectedServerType: AppType | null = null // 优先使用传入的 serverType 参数 if (serverType) { const type = serverType.toLowerCase() if (type === 'plex' || type === 'jellyfin' || type === 'emby' || type === 'trimemedia') { detectedServerType = type as AppType } } // 如果没有传入 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') } } /** * 打开豆瓣APP * @param doubanId 豆瓣ID * @param mediaType 媒体类型(电影/电视剧) * @param title 媒体标题 * @param year 媒体年份 * @param fallbackUrl 备用网页链接 */ 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是否安装 * 这个方法主要用于提示用户 */ export function checkAppInstalled(appType: AppType): boolean { // 由于浏览器安全限制,无法直接检测APP是否安装 // 这里可以根据用户代理或其他信息进行推测 // 目前返回false,让系统总是尝试跳转 return false }