mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-13 01:25:01 +08:00
feat(plugin): expose native subscribe action (#502)
This commit is contained in:
@@ -135,12 +135,16 @@ export default defineConfig({
|
||||
// 自定义事件,用于通知主应用刷新数据
|
||||
const emit = defineEmits(['action', 'switch', 'close'])
|
||||
|
||||
// 接收API对象
|
||||
// 接收主应用能力
|
||||
const props = defineProps({
|
||||
api: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
nativeSubscribe: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
// 页面逻辑代码...
|
||||
@@ -175,7 +179,7 @@ function notifyClose() {
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// 接收初始配置和API对象
|
||||
// 接收初始配置和主应用能力
|
||||
const props = defineProps({
|
||||
initialConfig: {
|
||||
type: Object,
|
||||
@@ -185,6 +189,10 @@ const props = defineProps({
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
nativeSubscribe: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
// 配置数据
|
||||
@@ -230,7 +238,7 @@ function notifyClose() {
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// 接收配置和刷新控制
|
||||
// 接收配置、刷新控制和主应用能力
|
||||
const props = defineProps({
|
||||
config: {
|
||||
type: Object,
|
||||
@@ -240,6 +248,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
nativeSubscribe: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
// 仪表板逻辑...
|
||||
@@ -272,16 +284,18 @@ const props = defineProps({
|
||||
|
||||
主应用传入的 props:
|
||||
|
||||
| 属性 | 说明 |
|
||||
| ---------- | ----------------------------------------------------- |
|
||||
| `api` | 与 `Page` 相同,用于 `bear` 认证的插件 HTTP 调用 |
|
||||
| `navKey` | 与侧栏声明的 `nav_key` 一致,同一插件多入口时用于区分 |
|
||||
| `pluginId` | 当前插件 ID |
|
||||
| 属性 | 说明 |
|
||||
| ----------------- | ----------------------------------------------------- |
|
||||
| `api` | 与 `Page` 相同,用于 `bear` 认证的插件 HTTP 调用 |
|
||||
| `nativeSubscribe` | 打开主应用原生订阅交互 |
|
||||
| `navKey` | 与侧栏声明的 `nav_key` 一致,同一插件多入口时用于区分 |
|
||||
| `pluginId` | 当前插件 ID |
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
api: { type: Object, default: () => ({}) },
|
||||
nativeSubscribe: { type: Function, default: null },
|
||||
navKey: { type: String, default: 'main' },
|
||||
pluginId: { type: String, default: '' },
|
||||
})
|
||||
@@ -296,7 +310,54 @@ const emit = defineEmits(['action'])
|
||||
</template>
|
||||
```
|
||||
|
||||
### 5.5 调用主应用 Toast
|
||||
### 5.5 主应用宿主能力
|
||||
|
||||
登录后的联邦组件宿主会向插件开放以下能力:
|
||||
|
||||
| 能力 | Page | Config | Dashboard | AppPage | 调用方式 |
|
||||
| ---------------- | ---- | ------ | --------- | ------- | ---------------------------------------------------------------- |
|
||||
| 认证 API | ✓ | ✓ | ✓ | ✓ | `api` prop |
|
||||
| 原生订阅交互 | ✓ | ✓ | ✓ | ✓ | `nativeSubscribe` prop 或 `inject('moviepilot:nativeSubscribe')` |
|
||||
| 主应用统一 Toast | ✓ | ✓ | ✓ | ✓ | `inject('moviepilot:toast')` |
|
||||
|
||||
`nativeSubscribe` 和 Toast 都由主应用宿主提供。插件不应复制主程序订阅弹窗,也不应自行创建另一套 Toast 容器。插件在旧版主程序或能力不存在的环境中运行时,应保留空值判断和必要的页面内 fallback。
|
||||
|
||||
### 5.6 调用主应用原生订阅
|
||||
|
||||
`Page`、`Config`、`Dashboard` 与 `AppPage` 都会收到 `nativeSubscribe(mediaInfo)` prop。插件传入媒体信息后,电视剧会打开主应用的选季抽屉,电影会进入现有电影订阅流程。宿主也会用 `moviepilot:nativeSubscribe` 键提供同一个方法,深层子组件可以使用 `inject`,无需逐层传递 prop。
|
||||
|
||||
媒体信息必须包含:
|
||||
|
||||
- `type`:`电影` / `电视剧`,也兼容 `movie` / `tv`;
|
||||
- `title`;
|
||||
- 至少一个有效媒体标识:`tmdb_id` / `tmdbid`、`douban_id` / `doubanid`、`bangumi_id` / `bangumiid`、`anilist_id` / `anilistid`,或者 `media_id` 与 `mediaid_prefix` / `source` / `media_source` 的组合。
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue'
|
||||
|
||||
type NativeSubscribeResult =
|
||||
{ success: true } | { success: false; code: 'INVALID_MEDIA' | 'PERMISSION_DENIED'; message: string }
|
||||
|
||||
const props = defineProps<{
|
||||
nativeSubscribe?: (mediaInfo: Record<string, unknown>) => Promise<NativeSubscribeResult>
|
||||
}>()
|
||||
|
||||
const nativeSubscribe = inject('moviepilot:nativeSubscribe', props.nativeSubscribe)
|
||||
|
||||
/** 使用主应用订阅交互,宿主不接受时保留插件自己的 fallback。 */
|
||||
async function subscribeMedia(mediaInfo: Record<string, unknown>) {
|
||||
const result = await nativeSubscribe?.(mediaInfo)
|
||||
if (!result?.success) {
|
||||
// 插件可在这里执行自己的 fallback;宿主已同时显示明确错误提示。
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
`success: true` 表示主应用已接受调用并启动原生交互,不表示用户已经完成订阅。字段无效或当前用户没有订阅权限时返回 `success: false`,插件可以依据 `code` 执行 fallback。
|
||||
|
||||
### 5.7 调用主应用 Toast
|
||||
|
||||
`Page`、`Config`、`Dashboard` 与 `AppPage` 的宿主容器会通过固定键提供主应用 Toast。远程组件应复用该实例,不要自行渲染 `VSnackbar` 或创建另一套 Toast 容器:
|
||||
|
||||
@@ -304,7 +365,14 @@ const emit = defineEmits(['action'])
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue'
|
||||
|
||||
const toast = inject<any>('moviepilot:toast', null)
|
||||
interface HostToast {
|
||||
error(message: string): unknown
|
||||
info(message: string): unknown
|
||||
success(message: string): unknown
|
||||
warning(message: string): unknown
|
||||
}
|
||||
|
||||
const toast = inject<HostToast | null>('moviepilot:toast', null)
|
||||
|
||||
// 保存完成后调用主应用的统一通知。
|
||||
function saveComplete() {
|
||||
|
||||
@@ -8,6 +8,7 @@ import FormRender from '../render/FormRender.vue'
|
||||
import ProgressDialog from '../dialog/ProgressDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { loadRemoteComponent } from '@/utils/federationLoader'
|
||||
import { usePluginNativeSubscribe } from '@/composables/usePluginNativeSubscribe'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -43,6 +44,10 @@ const $toast = useToast()
|
||||
// 向联邦插件提供主应用 Toast,避免远程组件自行创建通知容器。
|
||||
provide('moviepilot:toast', $toast)
|
||||
|
||||
// 配置联邦组件沿用与其它插件宿主一致的原生订阅能力。
|
||||
const nativeSubscribe = usePluginNativeSubscribe()
|
||||
provide('moviepilot:nativeSubscribe', nativeSubscribe)
|
||||
|
||||
// 是否刷新
|
||||
const isRefreshed = ref(false)
|
||||
|
||||
@@ -209,6 +214,7 @@ onBeforeMount(async () => {
|
||||
:is="dynamicComponent"
|
||||
:initial-config="pluginConfigForm"
|
||||
:api="api"
|
||||
:native-subscribe="nativeSubscribe"
|
||||
@save="handleVueComponentSave"
|
||||
@layout="handleVueComponentLayout"
|
||||
@switch="emit('switch')"
|
||||
|
||||
@@ -6,6 +6,7 @@ import api from '@/api'
|
||||
import { loadRemoteComponent } from '@/utils/federationLoader'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { usePluginNativeSubscribe } from '@/composables/usePluginNativeSubscribe'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -31,6 +32,10 @@ const { appMode } = usePWA()
|
||||
const $toast = useToast()
|
||||
provide('moviepilot:toast', $toast)
|
||||
|
||||
// 向联邦插件同时提供 prop 与 inject 形式的主程序原生订阅入口。
|
||||
const nativeSubscribe = usePluginNativeSubscribe()
|
||||
provide('moviepilot:nativeSubscribe', nativeSubscribe)
|
||||
|
||||
// 是否刷新
|
||||
const isRefreshed = ref(false)
|
||||
// 组件是否已加载成功
|
||||
@@ -158,6 +163,7 @@ onMounted(() => {
|
||||
<component
|
||||
:is="dynamicComponent"
|
||||
:api="api"
|
||||
:native-subscribe="nativeSubscribe"
|
||||
:show_switch="show_switch"
|
||||
@action="handleAction"
|
||||
@switch="emit('switch')"
|
||||
|
||||
@@ -6,6 +6,7 @@ import DashboardRender from '@/components/render/DashboardRender.vue'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { loadRemoteComponent } from '@/utils/federationLoader'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { usePluginNativeSubscribe } from '@/composables/usePluginNativeSubscribe'
|
||||
|
||||
type DashboardComponentLoader = () => Promise<any>
|
||||
|
||||
@@ -13,7 +14,12 @@ type DashboardComponentLoader = () => Promise<any>
|
||||
const $toast = useToast()
|
||||
provide('moviepilot:toast', $toast)
|
||||
|
||||
// 向仪表板联邦组件导出主程序原生订阅入口。
|
||||
const nativeSubscribe = usePluginNativeSubscribe()
|
||||
provide('moviepilot:nativeSubscribe', nativeSubscribe)
|
||||
|
||||
const DashboardSkeleton = {
|
||||
// 创建无需模板编译的仪表板加载骨架。
|
||||
setup() {
|
||||
const SkeletonLoader = resolveComponent('VSkeletonLoader')
|
||||
|
||||
@@ -226,7 +232,13 @@ onUnmounted(() => {
|
||||
<template v-else-if="!isNullOrEmptyObject(props.config)">
|
||||
<!-- Vue 渲染模式 -->
|
||||
<div v-if="pluginRenderMode === 'vue'" class="dashboard-plugin-vue-renderer">
|
||||
<component :is="dynamicPluginComponent" :config="props.config" :allow-refresh="props.allowRefresh" :api="api" />
|
||||
<component
|
||||
:is="dynamicPluginComponent"
|
||||
:config="props.config"
|
||||
:allow-refresh="props.allowRefresh"
|
||||
:api="api"
|
||||
:native-subscribe="nativeSubscribe"
|
||||
/>
|
||||
</div>
|
||||
<!-- Vuetify 渲染模式 -->
|
||||
<template v-else-if="pluginRenderMode === 'vuetify'">
|
||||
|
||||
195
src/composables/__tests__/usePluginNativeSubscribe.spec.ts
Normal file
195
src/composables/__tests__/usePluginNativeSubscribe.spec.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import {
|
||||
normalizeNativeSubscribeMedia,
|
||||
type NativeSubscribe,
|
||||
usePluginNativeSubscribe,
|
||||
} from '@/composables/usePluginNativeSubscribe'
|
||||
import type { SeasonSubscribeModes } from '@/composables/useMediaSubscribe'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { defineComponent, type Ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
interface CapturedSubscribeOptions {
|
||||
isExists: () => boolean
|
||||
isSubscribed: Ref<boolean>
|
||||
subscribedSeasonModes: Ref<SeasonSubscribeModes>
|
||||
subscribedSeasons: Ref<number[]>
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
apiGet: vi.fn(),
|
||||
checkSubscribe: vi.fn(),
|
||||
handleSubscribe: vi.fn(),
|
||||
subscribeOptions: undefined as CapturedSubscribeOptions | undefined,
|
||||
toastError: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => mocks.apiGet(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('vue-toastification', () => ({
|
||||
useToast: () => ({ error: mocks.toastError }),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useMediaSubscribe', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useMediaSubscribe')>()
|
||||
return {
|
||||
...actual,
|
||||
useMediaSubscribe: (options: Record<string, unknown>) => {
|
||||
mocks.subscribeOptions = options as unknown as CapturedSubscribeOptions
|
||||
return {
|
||||
checkSubscribe: mocks.checkSubscribe,
|
||||
handleSubscribe: mocks.handleSubscribe,
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
/** 在完整应用插件环境中创建原生订阅回调,便于验证权限与响应式状态。 */
|
||||
async function renderNativeSubscribeHarness(subscribePermission = true) {
|
||||
let nativeSubscribe: NativeSubscribe | undefined
|
||||
const Harness = defineComponent({
|
||||
name: 'PluginNativeSubscribeHarness',
|
||||
/** 在组件上下文中创建待测的原生订阅方法。 */
|
||||
setup() {
|
||||
nativeSubscribe = usePluginNativeSubscribe()
|
||||
return () => null
|
||||
},
|
||||
})
|
||||
|
||||
await renderWithProviders(Harness, {
|
||||
initialState: {
|
||||
user: {
|
||||
permissions: {
|
||||
discovery: true,
|
||||
manage: false,
|
||||
search: true,
|
||||
subscribe: subscribePermission,
|
||||
},
|
||||
superUser: false,
|
||||
userName: 'tester',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return nativeSubscribe as NativeSubscribe
|
||||
}
|
||||
|
||||
describe('native subscribe media normalization', () => {
|
||||
it('normalizes legacy provider aliases and English media types', () => {
|
||||
const result = normalizeNativeSubscribeMedia({
|
||||
anilistid: '154587',
|
||||
bangumiid: 4011,
|
||||
doubanid: 3601,
|
||||
title: '测试剧集',
|
||||
tmdbid: '2501',
|
||||
type: 'tv',
|
||||
year: 2026,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
media: expect.objectContaining({
|
||||
anilist_id: 154587,
|
||||
bangumi_id: '4011',
|
||||
douban_id: '3601',
|
||||
title: '测试剧集',
|
||||
tmdb_id: 2501,
|
||||
type: '电视剧',
|
||||
year: '2026',
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts generic source identifiers', () => {
|
||||
const result = normalizeNativeSubscribeMedia({
|
||||
media_id: 'subject-42',
|
||||
media_source: 'custom-source',
|
||||
title: '自定义媒体',
|
||||
type: 'movie',
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
media: expect.objectContaining({
|
||||
media_id: 'subject-42',
|
||||
source: 'custom-source',
|
||||
type: '电影',
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
[null, 'invalidMedia'],
|
||||
[{ title: '缺少类型', tmdb_id: 1 }, 'unsupportedType'],
|
||||
[{ title: '', tmdb_id: 1, type: '电影' }, 'missingTitle'],
|
||||
[{ title: '缺少ID', type: '电视剧' }, 'missingId'],
|
||||
])('rejects invalid input %#', (input, reason) => {
|
||||
expect(normalizeNativeSubscribeMedia(input)).toEqual({ success: false, reason })
|
||||
})
|
||||
})
|
||||
|
||||
describe('plugin native subscribe flow', () => {
|
||||
beforeEach(() => {
|
||||
mocks.checkSubscribe.mockResolvedValue(false)
|
||||
mocks.handleSubscribe.mockResolvedValue(undefined)
|
||||
mocks.apiGet.mockResolvedValue([])
|
||||
mocks.subscribeOptions = undefined
|
||||
})
|
||||
|
||||
it('restores subscribed TV seasons before opening the native season dialog', async () => {
|
||||
mocks.apiGet.mockResolvedValue([
|
||||
{ best_version: 0, media_source: 'themoviedb', media_id: '500', season: 1, type: '电视剧' },
|
||||
{ best_version: 1, media_source: 'themoviedb', media_id: '500', season: 3, type: '电视剧' },
|
||||
{ best_version: 0, media_source: 'themoviedb', media_id: '999', season: 2, type: '电视剧' },
|
||||
])
|
||||
const nativeSubscribe = await renderNativeSubscribeHarness()
|
||||
|
||||
await expect(nativeSubscribe({ title: '原生选季', tmdbid: 500, type: 'tv' })).resolves.toEqual({ success: true })
|
||||
|
||||
expect(mocks.apiGet).toHaveBeenCalledWith('subscribe/')
|
||||
expect(mocks.subscribeOptions?.subscribedSeasons.value).toEqual([1, 3])
|
||||
expect(mocks.subscribeOptions?.subscribedSeasonModes.value).toEqual({ 1: 'normal', 3: 'best_version' })
|
||||
expect(mocks.handleSubscribe).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('restores movie subscribe and exists state before using the native movie flow', async () => {
|
||||
mocks.checkSubscribe.mockResolvedValue(true)
|
||||
mocks.apiGet.mockResolvedValue({ success: true })
|
||||
const nativeSubscribe = await renderNativeSubscribeHarness()
|
||||
|
||||
await expect(nativeSubscribe({ title: '原生电影', tmdb_id: 600, type: '电影' })).resolves.toEqual({ success: true })
|
||||
|
||||
expect(mocks.checkSubscribe).toHaveBeenCalledWith(null)
|
||||
expect(mocks.apiGet).toHaveBeenCalledWith('mediaserver/exists', {
|
||||
params: expect.objectContaining({ mtype: '电影', title: '原生电影', tmdbid: 600 }),
|
||||
})
|
||||
expect(mocks.subscribeOptions?.isSubscribed.value).toBe(true)
|
||||
expect(mocks.subscribeOptions?.isExists()).toBe(true)
|
||||
expect(mocks.handleSubscribe).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('returns a structured fallback result for invalid media', async () => {
|
||||
const nativeSubscribe = await renderNativeSubscribeHarness()
|
||||
const result = await nativeSubscribe({ title: '没有ID', type: '电视剧' })
|
||||
|
||||
expect(result).toMatchObject({ code: 'INVALID_MEDIA', success: false })
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('无法打开原生订阅:请提供有效的媒体数据源 ID。')
|
||||
expect(mocks.handleSubscribe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns a structured fallback result when the user lacks subscribe permission', async () => {
|
||||
const nativeSubscribe = await renderNativeSubscribeHarness(false)
|
||||
const result = await nativeSubscribe({ title: '无权限', tmdb_id: 700, type: '电影' })
|
||||
|
||||
expect(result).toEqual({
|
||||
code: 'PERMISSION_DENIED',
|
||||
message: '当前用户没有订阅权限。',
|
||||
success: false,
|
||||
})
|
||||
expect(mocks.apiGet).not.toHaveBeenCalled()
|
||||
expect(mocks.handleSubscribe).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
215
src/composables/usePluginNativeSubscribe.ts
Normal file
215
src/composables/usePluginNativeSubscribe.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import api from '@/api'
|
||||
import type { MediaInfo, Subscribe } from '@/api/types'
|
||||
import {
|
||||
getMediaSubscribeId,
|
||||
getSubscribeMode,
|
||||
type SeasonSubscribeModes,
|
||||
useMediaSubscribe,
|
||||
} from '@/composables/useMediaSubscribe'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useToast } from 'vue-toastification'
|
||||
|
||||
export interface NativeSubscribeMediaInfo extends Partial<MediaInfo> {
|
||||
anilistid?: number | string
|
||||
bangumiid?: number | string
|
||||
doubanid?: number | string
|
||||
media_source?: string
|
||||
tmdbid?: number | string
|
||||
}
|
||||
|
||||
export type NativeSubscribeResult =
|
||||
| { success: true }
|
||||
| {
|
||||
success: false
|
||||
code: 'INVALID_MEDIA' | 'PERMISSION_DENIED'
|
||||
message: string
|
||||
}
|
||||
|
||||
export type NativeSubscribe = (media: NativeSubscribeMediaInfo) => Promise<NativeSubscribeResult>
|
||||
|
||||
type MediaNormalizationResult =
|
||||
| { success: true; media: MediaInfo }
|
||||
| {
|
||||
success: false
|
||||
reason: 'invalidMedia' | 'missingId' | 'missingTitle' | 'unsupportedType'
|
||||
}
|
||||
|
||||
/** 将插件常用的中英文媒体类型转换为主程序订阅流程使用的类型。 */
|
||||
function normalizeMediaType(value: unknown) {
|
||||
if (typeof value !== 'string') return undefined
|
||||
|
||||
const normalized = value.trim().toLowerCase()
|
||||
if (normalized === '电影' || normalized === 'movie') return '电影'
|
||||
if (normalized === '电视剧' || normalized === 'tv' || normalized === 'television') return '电视剧'
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** 将数字或数字字符串转换为有效的正整数媒体 ID。 */
|
||||
function normalizeNumericId(value: unknown) {
|
||||
if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) return value
|
||||
if (typeof value !== 'string' || !/^\d+$/.test(value.trim())) return undefined
|
||||
|
||||
const id = Number(value)
|
||||
return Number.isSafeInteger(id) && id > 0 ? id : undefined
|
||||
}
|
||||
|
||||
/** 将字符串或数字 ID 转换为非空字符串。 */
|
||||
function normalizeStringId(value: unknown) {
|
||||
if (typeof value !== 'string' && typeof value !== 'number') return undefined
|
||||
|
||||
const id = String(value).trim()
|
||||
return id && id !== '0' ? id : undefined
|
||||
}
|
||||
|
||||
/** 规范插件媒体信息并兼容后端字段别名,校验结果可供宿主明确拒绝无效调用。 */
|
||||
export function normalizeNativeSubscribeMedia(input: unknown): MediaNormalizationResult {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
||||
return { success: false, reason: 'invalidMedia' }
|
||||
}
|
||||
|
||||
const raw = input as Record<string, unknown>
|
||||
const type = normalizeMediaType(raw.type)
|
||||
if (!type) return { success: false, reason: 'unsupportedType' }
|
||||
|
||||
const title = typeof raw.title === 'string' ? raw.title.trim() : ''
|
||||
if (!title) return { success: false, reason: 'missingTitle' }
|
||||
|
||||
const source = normalizeStringId(raw.source ?? raw.media_source)
|
||||
const mediaidPrefix = normalizeStringId(raw.mediaid_prefix)
|
||||
const normalizedMedia = {
|
||||
...raw,
|
||||
anilist_id: normalizeNumericId(raw.anilist_id ?? raw.anilistid),
|
||||
bangumi_id: normalizeStringId(raw.bangumi_id ?? raw.bangumiid),
|
||||
douban_id: normalizeStringId(raw.douban_id ?? raw.doubanid),
|
||||
media_id: normalizeStringId(raw.media_id),
|
||||
mediaid_prefix: mediaidPrefix,
|
||||
source,
|
||||
title,
|
||||
tmdb_id: normalizeNumericId(raw.tmdb_id ?? raw.tmdbid),
|
||||
type,
|
||||
year: normalizeStringId(raw.year),
|
||||
} as MediaInfo
|
||||
|
||||
if (!getMediaSubscribeId(normalizedMedia)) return { success: false, reason: 'missingId' }
|
||||
|
||||
return { success: true, media: normalizedMedia }
|
||||
}
|
||||
|
||||
/** 生成订阅记录的统一媒体标识,用于恢复电视剧已订阅季状态。 */
|
||||
function getSubscribeRecordMediaId(subscribe: Subscribe) {
|
||||
if (subscribe.media_source && subscribe.media_id) {
|
||||
const source = subscribe.media_source === 'themoviedb' ? 'tmdb' : subscribe.media_source
|
||||
return `${source}:${subscribe.media_id}`
|
||||
}
|
||||
if (subscribe.mediaid) return subscribe.mediaid
|
||||
if (subscribe.tmdbid) return `tmdb:${subscribe.tmdbid}`
|
||||
if (subscribe.doubanid) return `douban:${subscribe.doubanid}`
|
||||
if (subscribe.bangumiid) return `bangumi:${subscribe.bangumiid}`
|
||||
if (subscribe.anilistid) return `anilist:${subscribe.anilistid}`
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 为插件联邦组件创建主程序原生订阅入口。 */
|
||||
export function usePluginNativeSubscribe(): NativeSubscribe {
|
||||
const { t } = useI18n()
|
||||
const $toast = useToast()
|
||||
const userStore = useUserStore()
|
||||
const media = shallowRef<MediaInfo>()
|
||||
const isSubscribed = ref(false)
|
||||
const isExists = ref(false)
|
||||
const subscribedSeasons = ref<number[]>([])
|
||||
const subscribedSeasonModes = ref<SeasonSubscribeModes>({})
|
||||
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
|
||||
const canSubscribe = computed(() => hasPermission(userPermissions.value, 'subscribe'))
|
||||
|
||||
const subscribeActions = useMediaSubscribe({
|
||||
media: () => media.value,
|
||||
canSubscribe: () => canSubscribe.value,
|
||||
isSubscribed,
|
||||
isExists: () => isExists.value,
|
||||
subscribedSeasons,
|
||||
subscribedSeasonModes,
|
||||
primarySeason: () => media.value?.season ?? null,
|
||||
})
|
||||
|
||||
/** 清空上一次原生订阅调用的临时状态,避免不同媒体互相污染。 */
|
||||
function resetSubscribeState() {
|
||||
isSubscribed.value = false
|
||||
isExists.value = false
|
||||
subscribedSeasons.value = []
|
||||
subscribedSeasonModes.value = {}
|
||||
}
|
||||
|
||||
/** 查询电影订阅和入库状态,使插件入口与原生媒体卡片保持相同行为。 */
|
||||
async function loadMovieState(currentMedia: MediaInfo) {
|
||||
const [subscribeResult, existsResult] = await Promise.allSettled([
|
||||
subscribeActions.checkSubscribe(null),
|
||||
api.get('mediaserver/exists', {
|
||||
params: {
|
||||
mtype: currentMedia.type,
|
||||
season: currentMedia.season,
|
||||
title: currentMedia.title,
|
||||
tmdbid: currentMedia.tmdb_id,
|
||||
year: currentMedia.year,
|
||||
},
|
||||
}) as Promise<{ success?: boolean }>,
|
||||
])
|
||||
|
||||
if (subscribeResult.status === 'fulfilled') isSubscribed.value = subscribeResult.value
|
||||
else console.error(subscribeResult.reason)
|
||||
|
||||
if (existsResult.status === 'fulfilled') isExists.value = Boolean(existsResult.value?.success)
|
||||
else console.error(existsResult.reason)
|
||||
}
|
||||
|
||||
/** 查询电视剧全部订阅记录,让选季弹窗正确展示已订阅季和订阅模式。 */
|
||||
async function loadTvState(currentMedia: MediaInfo) {
|
||||
try {
|
||||
const subscribes: Subscribe[] = await api.get('subscribe/')
|
||||
const mediaId = getMediaSubscribeId(currentMedia)
|
||||
const mediaSubscribes = subscribes.filter(
|
||||
item => item.type === '电视剧' && item.season !== undefined && getSubscribeRecordMediaId(item) === mediaId,
|
||||
)
|
||||
|
||||
subscribedSeasons.value = [...new Set(mediaSubscribes.map(item => item.season as number))].sort((a, b) => a - b)
|
||||
subscribedSeasonModes.value = mediaSubscribes.reduce<SeasonSubscribeModes>((modes, item) => {
|
||||
if (item.season !== undefined) modes[item.season] = getSubscribeMode(item)
|
||||
return modes
|
||||
}, {})
|
||||
isSubscribed.value = subscribedSeasons.value.length > 0
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
/** 显示宿主拒绝原因,并返回插件可用于 fallback 的结构化结果。 */
|
||||
function rejectNativeSubscribe(code: 'INVALID_MEDIA' | 'PERMISSION_DENIED', message: string): NativeSubscribeResult {
|
||||
$toast.error(message)
|
||||
return { success: false, code, message }
|
||||
}
|
||||
|
||||
/** 校验插件媒体信息并启动电影或电视剧的主程序原生订阅交互。 */
|
||||
async function nativeSubscribe(input: NativeSubscribeMediaInfo): Promise<NativeSubscribeResult> {
|
||||
const normalized = normalizeNativeSubscribeMedia(input)
|
||||
if (!normalized.success) {
|
||||
return rejectNativeSubscribe('INVALID_MEDIA', t(`subscribe.native.${normalized.reason}`))
|
||||
}
|
||||
if (!canSubscribe.value) {
|
||||
return rejectNativeSubscribe('PERMISSION_DENIED', t('subscribe.native.permissionDenied'))
|
||||
}
|
||||
|
||||
media.value = normalized.media
|
||||
resetSubscribeState()
|
||||
|
||||
if (normalized.media.type === '电视剧') await loadTvState(normalized.media)
|
||||
else await loadMovieState(normalized.media)
|
||||
|
||||
await subscribeActions.handleSubscribe()
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
return nativeSubscribe
|
||||
}
|
||||
@@ -1137,6 +1137,13 @@ export default {
|
||||
cancelFailed: 'Failed to cancel subscription: {message}!',
|
||||
notFound: 'Subscription not found!',
|
||||
requestFailed: 'Request failed. Please try again later.',
|
||||
native: {
|
||||
invalidMedia: 'Unable to open native subscription: invalid media information.',
|
||||
missingId: 'Unable to open native subscription: provide a valid media source ID.',
|
||||
missingTitle: 'Unable to open native subscription: provide a media title.',
|
||||
unsupportedType: 'Unable to open native subscription: media type must be movie or TV.',
|
||||
permissionDenied: 'The current user does not have subscription permission.',
|
||||
},
|
||||
filterSubscriptions: 'Filter Subscriptions',
|
||||
name: 'Name',
|
||||
searchShares: 'Search Subscription Shares',
|
||||
|
||||
@@ -1131,6 +1131,13 @@ export default {
|
||||
cancelFailed: '取消订阅失败:{message}!',
|
||||
notFound: '订阅不存在!',
|
||||
requestFailed: '请求失败,请稍后重试',
|
||||
native: {
|
||||
invalidMedia: '无法打开原生订阅:媒体信息格式无效。',
|
||||
missingId: '无法打开原生订阅:请提供有效的媒体数据源 ID。',
|
||||
missingTitle: '无法打开原生订阅:请提供媒体标题。',
|
||||
unsupportedType: '无法打开原生订阅:媒体类型必须是电影或电视剧。',
|
||||
permissionDenied: '当前用户没有订阅权限。',
|
||||
},
|
||||
filterSubscriptions: '筛选订阅',
|
||||
name: '名称',
|
||||
searchShares: '搜索订阅分享',
|
||||
|
||||
@@ -1131,6 +1131,13 @@ export default {
|
||||
cancelFailed: '取消訂閱失敗:{message}!',
|
||||
notFound: '訂閱不存在!',
|
||||
requestFailed: '請求失敗,請稍後重試',
|
||||
native: {
|
||||
invalidMedia: '無法開啟原生訂閱:媒體資訊格式無效。',
|
||||
missingId: '無法開啟原生訂閱:請提供有效的媒體資料來源 ID。',
|
||||
missingTitle: '無法開啟原生訂閱:請提供媒體標題。',
|
||||
unsupportedType: '無法開啟原生訂閱:媒體類型必須是電影或電視劇。',
|
||||
permissionDenied: '目前使用者沒有訂閱權限。',
|
||||
},
|
||||
filterSubscriptions: '篩選訂閱',
|
||||
name: '名稱',
|
||||
searchShares: '搜索訂閱分享',
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Component } from 'vue'
|
||||
import api from '@/api'
|
||||
import { loadRemoteAppPageComponent } from '@/utils/federationLoader'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { usePluginNativeSubscribe } from '@/composables/usePluginNativeSubscribe'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
@@ -16,6 +17,10 @@ const loadError = ref(false)
|
||||
const $toast = useToast()
|
||||
provide('moviepilot:toast', $toast)
|
||||
|
||||
// 向侧栏全页联邦组件导出主程序原生订阅入口。
|
||||
const nativeSubscribe = usePluginNativeSubscribe()
|
||||
provide('moviepilot:nativeSubscribe', nativeSubscribe)
|
||||
|
||||
watch(
|
||||
[pluginId, navKey],
|
||||
async ([pid, nk]) => {
|
||||
@@ -47,6 +52,7 @@ watch(
|
||||
:is="RemoteView"
|
||||
:key="`${pluginId}-${navKey}`"
|
||||
:api="api"
|
||||
:native-subscribe="nativeSubscribe"
|
||||
:nav-key="navKey"
|
||||
:plugin-id="pluginId"
|
||||
@action="() => {}"
|
||||
|
||||
Reference in New Issue
Block a user