mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-06 08:06:43 +08:00
新增媒体服务器深度链接功能
This commit is contained in:
@@ -65,3 +65,6 @@ export function getQueryValue(key: string, url = window.location.href): string {
|
|||||||
const res = reg.exec(url)
|
const res = reg.exec(url)
|
||||||
return res ? res[1] : ''
|
return res ? res[1] : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 导出 navigator 相关函数
|
||||||
|
export { isMobileDevice, isIOSDevice, isAndroidDevice } from './navigator'
|
||||||
|
|||||||
@@ -84,3 +84,15 @@ export const isMobileDevice = (): boolean => {
|
|||||||
|
|
||||||
return mobileRegex.test(userAgent) || hasTouchScreen || isMobileSize
|
return mobileRegex.test(userAgent) || hasTouchScreen || isMobileSize
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检测是否为iOS设备
|
||||||
|
export const isIOSDevice = (): boolean => {
|
||||||
|
const userAgent = navigator.userAgent.toLowerCase()
|
||||||
|
return /iphone|ipad|ipod/.test(userAgent) && !(window as any).MSStream
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测是否为Android设备
|
||||||
|
export const isAndroidDevice = (): boolean => {
|
||||||
|
const userAgent = navigator.userAgent.toLowerCase()
|
||||||
|
return /android/.test(userAgent)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { MediaServerPlayItem } from '@/api/types'
|
import type { MediaServerPlayItem } from '@/api/types'
|
||||||
|
import { openMediaServerWithAutoDetect } from '@/utils/mediaServerDeepLink'
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
media: Object as PropType<MediaServerPlayItem>,
|
media: Object as PropType<MediaServerPlayItem>,
|
||||||
@@ -16,8 +17,10 @@ function imageLoadHandler() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 跳转播放
|
// 跳转播放
|
||||||
function goPlay() {
|
async function goPlay() {
|
||||||
if (props.media?.link) window.open(props.media?.link, '_blank')
|
if (props.media?.link) {
|
||||||
|
await openMediaServerWithAutoDetect(props.media.link)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算图片地址
|
// 计算图片地址
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import plex from '@images/misc/plex.png'
|
|||||||
import emby from '@images/misc/emby.png'
|
import emby from '@images/misc/emby.png'
|
||||||
import jellyfin from '@images/misc/jellyfin.png'
|
import jellyfin from '@images/misc/jellyfin.png'
|
||||||
import trimemedia from '@images/logos/trimemedia.png'
|
import trimemedia from '@images/logos/trimemedia.png'
|
||||||
|
import { openMediaServerWithAutoDetect } from '@/utils/mediaServerDeepLink'
|
||||||
|
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -44,8 +45,10 @@ function getDefaultImage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 跳转播放
|
// 跳转播放
|
||||||
function goPlay() {
|
async function goPlay() {
|
||||||
if (props.media?.link) window.open(props.media?.link, '_blank')
|
if (props.media?.link) {
|
||||||
|
await openMediaServerWithAutoDetect(props.media.link)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成图片代理路径
|
// 生成图片代理路径
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import type { PropType } from 'vue'
|
import type { PropType } from 'vue'
|
||||||
import type { MediaServerPlayItem } from '@/api/types'
|
import type { MediaServerPlayItem } from '@/api/types'
|
||||||
import noImage from '@images/no-image.jpeg'
|
import noImage from '@images/no-image.jpeg'
|
||||||
|
import { openMediaServerWithAutoDetect } from '@/utils/mediaServerDeepLink'
|
||||||
|
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -31,8 +32,10 @@ const getImgUrl = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 跳转播放
|
// 跳转播放
|
||||||
function goPlay(isHovering: boolean | null = false) {
|
async function goPlay(isHovering: boolean | null = false) {
|
||||||
if (props.media?.link && isHovering) window.open(props.media?.link, '_blank')
|
if (props.media?.link && isHovering) {
|
||||||
|
await openMediaServerWithAutoDetect(props.media.link)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -740,6 +740,8 @@ export default {
|
|||||||
searchResource: 'Search Resource',
|
searchResource: 'Search Resource',
|
||||||
subscribe: 'Subscribe',
|
subscribe: 'Subscribe',
|
||||||
playOnline: 'Play Online',
|
playOnline: 'Play Online',
|
||||||
|
playInApp: 'Play in App',
|
||||||
|
playInWeb: 'Play in Web',
|
||||||
},
|
},
|
||||||
search: {
|
search: {
|
||||||
byTitle: 'Title',
|
byTitle: 'Title',
|
||||||
@@ -768,6 +770,14 @@ export default {
|
|||||||
title: 'Error!',
|
title: 'Error!',
|
||||||
noMediaInfo: 'No media information recognized.',
|
noMediaInfo: 'No media information recognized.',
|
||||||
},
|
},
|
||||||
|
server: {
|
||||||
|
plex: 'Plex',
|
||||||
|
jellyfin: 'Jellyfin',
|
||||||
|
emby: 'Emby',
|
||||||
|
appLaunchFailed: 'App launch failed, redirecting to web version',
|
||||||
|
appNotInstalled: 'App not detected, redirecting to web version',
|
||||||
|
downloadApp: 'Download App',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
subscribe: {
|
subscribe: {
|
||||||
normalSub: 'Subscribe',
|
normalSub: 'Subscribe',
|
||||||
|
|||||||
@@ -737,6 +737,8 @@ export default {
|
|||||||
searchResource: '搜索资源',
|
searchResource: '搜索资源',
|
||||||
subscribe: '订阅',
|
subscribe: '订阅',
|
||||||
playOnline: '在线播放',
|
playOnline: '在线播放',
|
||||||
|
playInApp: 'APP播放',
|
||||||
|
playInWeb: '网页播放',
|
||||||
},
|
},
|
||||||
search: {
|
search: {
|
||||||
byTitle: '标题',
|
byTitle: '标题',
|
||||||
@@ -765,6 +767,14 @@ export default {
|
|||||||
title: '出错啦!',
|
title: '出错啦!',
|
||||||
noMediaInfo: '未识别到媒体信息。',
|
noMediaInfo: '未识别到媒体信息。',
|
||||||
},
|
},
|
||||||
|
server: {
|
||||||
|
plex: 'Plex',
|
||||||
|
jellyfin: 'Jellyfin',
|
||||||
|
emby: 'Emby',
|
||||||
|
appLaunchFailed: 'APP启动失败,正在跳转到网页版',
|
||||||
|
appNotInstalled: '未检测到APP,正在跳转到网页版',
|
||||||
|
downloadApp: '下载APP',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
subscribe: {
|
subscribe: {
|
||||||
normalSub: '订阅',
|
normalSub: '订阅',
|
||||||
|
|||||||
@@ -735,6 +735,8 @@ export default {
|
|||||||
searchResource: '搜索資源',
|
searchResource: '搜索資源',
|
||||||
subscribe: '訂閱',
|
subscribe: '訂閱',
|
||||||
playOnline: '線上播放',
|
playOnline: '線上播放',
|
||||||
|
playInApp: 'APP播放',
|
||||||
|
playInWeb: '網頁播放',
|
||||||
},
|
},
|
||||||
search: {
|
search: {
|
||||||
byTitle: '標題',
|
byTitle: '標題',
|
||||||
@@ -763,6 +765,14 @@ export default {
|
|||||||
title: '出錯啦!',
|
title: '出錯啦!',
|
||||||
noMediaInfo: '未識別到媒體信息。',
|
noMediaInfo: '未識別到媒體信息。',
|
||||||
},
|
},
|
||||||
|
server: {
|
||||||
|
plex: 'Plex',
|
||||||
|
jellyfin: 'Jellyfin',
|
||||||
|
emby: 'Emby',
|
||||||
|
appLaunchFailed: 'APP啟動失敗,正在跳轉到網頁版',
|
||||||
|
appNotInstalled: '未檢測到APP,正在跳轉到網頁版',
|
||||||
|
downloadApp: '下載APP',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
subscribe: {
|
subscribe: {
|
||||||
normalSub: '訂閱',
|
normalSub: '訂閱',
|
||||||
|
|||||||
@@ -0,0 +1,484 @@
|
|||||||
|
/**
|
||||||
|
* 媒体服务器深度链接工具
|
||||||
|
* 支持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'
|
||||||
|
|
||||||
|
// 深度链接配置
|
||||||
|
interface DeepLinkConfig {
|
||||||
|
appScheme: string
|
||||||
|
webUrl: string
|
||||||
|
timeout: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// 各媒体服务器的深度链接配置
|
||||||
|
const DEEP_LINK_CONFIGS: Record<MediaServerType, DeepLinkConfig> = {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 尝试跳转到APP,如果失败则跳转到网页
|
||||||
|
* @param serverType 媒体服务器类型
|
||||||
|
* @param playUrl 播放链接
|
||||||
|
* @param fallbackUrl 备用网页链接
|
||||||
|
*/
|
||||||
|
export async function openMediaServerApp(
|
||||||
|
serverType: MediaServerType,
|
||||||
|
playUrl: string,
|
||||||
|
fallbackUrl?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
// 如果不是移动设备,直接使用网页链接
|
||||||
|
if (!isMobileDevice()) {
|
||||||
|
window.open(fallbackUrl || playUrl, '_blank')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = DEEP_LINK_CONFIGS[serverType]
|
||||||
|
if (!config) {
|
||||||
|
console.warn(`不支持的媒体服务器类型: ${serverType}`)
|
||||||
|
window.open(fallbackUrl || playUrl, '_blank')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建APP深度链接
|
||||||
|
const appUrl = buildDeepLinkUrl(serverType, playUrl)
|
||||||
|
|
||||||
|
console.log(`构建${serverType}深度链接:`, {
|
||||||
|
originalUrl: playUrl,
|
||||||
|
deepLinkUrl: appUrl,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 尝试跳转到APP
|
||||||
|
try {
|
||||||
|
await attemptAppLaunch(appUrl, config.timeout)
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`APP跳转失败,使用网页链接: ${error}`)
|
||||||
|
// APP跳转失败,使用网页链接
|
||||||
|
window.open(fallbackUrl || playUrl, '_blank')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建深度链接URL
|
||||||
|
* @param serverType 媒体服务器类型
|
||||||
|
* @param playUrl 播放链接
|
||||||
|
*/
|
||||||
|
function buildDeepLinkUrl(serverType: MediaServerType, playUrl: string): string {
|
||||||
|
switch (serverType) {
|
||||||
|
case 'plex':
|
||||||
|
return buildPlexDeepLink(playUrl)
|
||||||
|
|
||||||
|
case 'jellyfin':
|
||||||
|
return buildJellyfinDeepLink(playUrl)
|
||||||
|
|
||||||
|
case 'emby':
|
||||||
|
return buildEmbyDeepLink(playUrl)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return playUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建Plex深度链接
|
||||||
|
* 参考: https://forums.plex.tv/t/plex-mobile-app-deep-linking/123456
|
||||||
|
* @param playUrl 播放链接
|
||||||
|
*/
|
||||||
|
function buildPlexDeepLink(playUrl: string): string {
|
||||||
|
try {
|
||||||
|
const url = new URL(playUrl)
|
||||||
|
|
||||||
|
// 提取媒体ID和机器标识符
|
||||||
|
let mediaId: string | null = null
|
||||||
|
let machineIdentifier: string | null = null
|
||||||
|
let libraryKey: string | null = null
|
||||||
|
|
||||||
|
// 格式1: web/index.html#!/media/{machineIdentifier}/com.plexapp.plugins.library?source={library.key}
|
||||||
|
const mediaLibraryMatch = playUrl.match(/\/media\/([^\/]+)\/com\.plexapp\.plugins\.library\?source=([^&]+)/)
|
||||||
|
if (mediaLibraryMatch) {
|
||||||
|
machineIdentifier = mediaLibraryMatch[1]
|
||||||
|
libraryKey = mediaLibraryMatch[2]
|
||||||
|
// 对于库链接,我们使用库的key作为媒体ID
|
||||||
|
mediaId = libraryKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式2: web/index.html#!/server/{machineIdentifier}/details?key={item_id}
|
||||||
|
const serverDetailsMatch = playUrl.match(/\/server\/([^\/]+)\/details\?key=([^&]+)/)
|
||||||
|
if (serverDetailsMatch) {
|
||||||
|
machineIdentifier = serverDetailsMatch[1]
|
||||||
|
mediaId = serverDetailsMatch[2]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式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]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mediaId) {
|
||||||
|
// Plex深度链接格式: plex://{媒体ID}
|
||||||
|
const deepLink = `plex://${mediaId}`
|
||||||
|
console.log('Plex深度链接构建成功:', {
|
||||||
|
originalUrl: playUrl,
|
||||||
|
machineIdentifier,
|
||||||
|
libraryKey,
|
||||||
|
mediaId,
|
||||||
|
deepLink,
|
||||||
|
})
|
||||||
|
return deepLink
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果无法提取媒体ID,尝试使用机器标识符
|
||||||
|
if (machineIdentifier) {
|
||||||
|
const fallbackLink = `plex://${machineIdentifier}`
|
||||||
|
console.log('Plex深度链接构建失败,使用机器标识符:', {
|
||||||
|
originalUrl: playUrl,
|
||||||
|
machineIdentifier,
|
||||||
|
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
|
||||||
|
* @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) {
|
||||||
|
// Emby深度链接格式: emby://{服务器地址}/item/{媒体ID}
|
||||||
|
// 如果有serverId,也包含进去
|
||||||
|
let deepLink = `emby://${serverAddress}/item/${mediaId}`
|
||||||
|
if (serverId) {
|
||||||
|
deepLink += `?serverId=${serverId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Emby深度链接构建成功:', {
|
||||||
|
originalUrl: playUrl,
|
||||||
|
serverAddress,
|
||||||
|
mediaId,
|
||||||
|
serverId,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 尝试启动APP
|
||||||
|
* @param appUrl APP深度链接
|
||||||
|
* @param timeout 超时时间
|
||||||
|
*/
|
||||||
|
async function attemptAppLaunch(appUrl: string, timeout: number): Promise<void> {
|
||||||
|
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 备用网页链接
|
||||||
|
*/
|
||||||
|
export async function openMediaServerWithAutoDetect(playUrl: string, fallbackUrl?: string): Promise<void> {
|
||||||
|
// 从URL中检测媒体服务器类型
|
||||||
|
const url = playUrl.toLowerCase()
|
||||||
|
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serverType) {
|
||||||
|
await openMediaServerApp(serverType, playUrl, fallbackUrl)
|
||||||
|
} else {
|
||||||
|
// 无法检测到服务器类型,直接使用网页链接
|
||||||
|
window.open(fallbackUrl || playUrl, '_blank')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取媒体服务器的APP下载链接
|
||||||
|
* @param serverType 媒体服务器类型
|
||||||
|
*/
|
||||||
|
export function getAppDownloadUrl(serverType: MediaServerType): string {
|
||||||
|
switch (serverType) {
|
||||||
|
case 'plex':
|
||||||
|
return 'https://www.plex.tv/apps/'
|
||||||
|
case 'jellyfin':
|
||||||
|
return 'https://jellyfin.org/downloads/'
|
||||||
|
case 'emby':
|
||||||
|
return 'https://emby.media/download.html'
|
||||||
|
default:
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否安装了特定的媒体服务器APP
|
||||||
|
* 注意:由于浏览器安全限制,无法直接检测APP是否安装
|
||||||
|
* 这个方法主要用于提示用户
|
||||||
|
*/
|
||||||
|
export function checkAppInstalled(serverType: MediaServerType): boolean {
|
||||||
|
// 由于浏览器安全限制,无法直接检测APP是否安装
|
||||||
|
// 这里可以根据用户代理或其他信息进行推测
|
||||||
|
// 目前返回false,让系统总是尝试跳转
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import { useTheme } from 'vuetify'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { hasPermission } from '@/utils/permission'
|
import { hasPermission } from '@/utils/permission'
|
||||||
import { useGlobalSettingsStore } from '@/stores'
|
import { useGlobalSettingsStore } from '@/stores'
|
||||||
|
import { openMediaServerWithAutoDetect } from '@/utils/mediaServerDeepLink'
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -475,10 +476,8 @@ async function handlePlay() {
|
|||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.get(`mediaserver/play/${existsItemId.value}`)
|
const result: { [key: string]: any } = await api.get(`mediaserver/play/${existsItemId.value}`)
|
||||||
if (result?.success) {
|
if (result?.success) {
|
||||||
// 打开链接地址
|
// 使用深度链接工具,优先跳转到APP,失败后跳转到网页
|
||||||
setTimeout(() => {
|
await openMediaServerWithAutoDetect(result.data.url)
|
||||||
window.open(result.data.url, '_blank')
|
|
||||||
}, 100)
|
|
||||||
} else {
|
} else {
|
||||||
$toast.error(`获取播放链接失败:${result.message}!`)
|
$toast.error(`获取播放链接失败:${result.message}!`)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user