重构深度链接功能

This commit is contained in:
jxxghp
2025-07-12 14:57:03 +08:00
parent 60385715e6
commit 40a4e29c7e
6 changed files with 337 additions and 105 deletions

View File

@@ -1,17 +1,18 @@
/**
*
* PlexJellyfinEmbyAPP跳转和网页跳转
* APP深度链接工具类
* PlexJellyfinEmbyAPP跳转和网页跳转
*
*
* - 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<MediaServerType, DeepLinkConfig> = {
// 各APP的深度链接配置
const DEEP_LINK_CONFIGS: Record<AppType, DeepLinkConfig> = {
plex: {
appScheme: 'plex://',
webUrl: 'https://app.plex.tv',
@@ -37,37 +38,53 @@ const DEEP_LINK_CONFIGS: Record<MediaServerType, DeepLinkConfig> = {
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<void> {
export async function openApp(appType: AppType, params: string | DoubanAppParams, fallbackUrl?: string): Promise<void> {
// 如果不是移动设备,直接使用网页链接
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<void>
*
* @param playUrl
* @param fallbackUrl
* @param serverType 使
*/
export async function openMediaServerWithAutoDetect(playUrl: string, fallbackUrl?: string): Promise<void> {
// 从URL中检测媒体服务器类型
const url = playUrl.toLowerCase()
export async function openMediaServerWithAutoDetect(
playUrl: string,
fallbackUrl?: string,
serverType?: string,
): Promise<void> {
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<void> {
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让系统总是尝试跳转