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
-
+