mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 23:56:42 +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'])
|
const emit = defineEmits(['action', 'switch', 'close'])
|
||||||
|
|
||||||
// 接收API对象
|
// 接收主应用能力
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
api: {
|
api: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => {},
|
default: () => {},
|
||||||
},
|
},
|
||||||
|
nativeSubscribe: {
|
||||||
|
type: Function,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// 页面逻辑代码...
|
// 页面逻辑代码...
|
||||||
@@ -175,7 +179,7 @@ function notifyClose() {
|
|||||||
|
|
||||||
```vue
|
```vue
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// 接收初始配置和API对象
|
// 接收初始配置和主应用能力
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
initialConfig: {
|
initialConfig: {
|
||||||
type: Object,
|
type: Object,
|
||||||
@@ -185,6 +189,10 @@ const props = defineProps({
|
|||||||
type: Object,
|
type: Object,
|
||||||
default: () => {},
|
default: () => {},
|
||||||
},
|
},
|
||||||
|
nativeSubscribe: {
|
||||||
|
type: Function,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// 配置数据
|
// 配置数据
|
||||||
@@ -230,7 +238,7 @@ function notifyClose() {
|
|||||||
|
|
||||||
```vue
|
```vue
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// 接收配置和刷新控制
|
// 接收配置、刷新控制和主应用能力
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
config: {
|
config: {
|
||||||
type: Object,
|
type: Object,
|
||||||
@@ -240,6 +248,10 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
|
nativeSubscribe: {
|
||||||
|
type: Function,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// 仪表板逻辑...
|
// 仪表板逻辑...
|
||||||
@@ -272,16 +284,18 @@ const props = defineProps({
|
|||||||
|
|
||||||
主应用传入的 props:
|
主应用传入的 props:
|
||||||
|
|
||||||
| 属性 | 说明 |
|
| 属性 | 说明 |
|
||||||
| ---------- | ----------------------------------------------------- |
|
| ----------------- | ----------------------------------------------------- |
|
||||||
| `api` | 与 `Page` 相同,用于 `bear` 认证的插件 HTTP 调用 |
|
| `api` | 与 `Page` 相同,用于 `bear` 认证的插件 HTTP 调用 |
|
||||||
| `navKey` | 与侧栏声明的 `nav_key` 一致,同一插件多入口时用于区分 |
|
| `nativeSubscribe` | 打开主应用原生订阅交互 |
|
||||||
| `pluginId` | 当前插件 ID |
|
| `navKey` | 与侧栏声明的 `nav_key` 一致,同一插件多入口时用于区分 |
|
||||||
|
| `pluginId` | 当前插件 ID |
|
||||||
|
|
||||||
```vue
|
```vue
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
api: { type: Object, default: () => ({}) },
|
api: { type: Object, default: () => ({}) },
|
||||||
|
nativeSubscribe: { type: Function, default: null },
|
||||||
navKey: { type: String, default: 'main' },
|
navKey: { type: String, default: 'main' },
|
||||||
pluginId: { type: String, default: '' },
|
pluginId: { type: String, default: '' },
|
||||||
})
|
})
|
||||||
@@ -296,7 +310,54 @@ const emit = defineEmits(['action'])
|
|||||||
</template>
|
</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 容器:
|
`Page`、`Config`、`Dashboard` 与 `AppPage` 的宿主容器会通过固定键提供主应用 Toast。远程组件应复用该实例,不要自行渲染 `VSnackbar` 或创建另一套 Toast 容器:
|
||||||
|
|
||||||
@@ -304,7 +365,14 @@ const emit = defineEmits(['action'])
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { inject } from 'vue'
|
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() {
|
function saveComplete() {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import FormRender from '../render/FormRender.vue'
|
|||||||
import ProgressDialog from '../dialog/ProgressDialog.vue'
|
import ProgressDialog from '../dialog/ProgressDialog.vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { loadRemoteComponent } from '@/utils/federationLoader'
|
import { loadRemoteComponent } from '@/utils/federationLoader'
|
||||||
|
import { usePluginNativeSubscribe } from '@/composables/usePluginNativeSubscribe'
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -43,6 +44,10 @@ const $toast = useToast()
|
|||||||
// 向联邦插件提供主应用 Toast,避免远程组件自行创建通知容器。
|
// 向联邦插件提供主应用 Toast,避免远程组件自行创建通知容器。
|
||||||
provide('moviepilot:toast', $toast)
|
provide('moviepilot:toast', $toast)
|
||||||
|
|
||||||
|
// 配置联邦组件沿用与其它插件宿主一致的原生订阅能力。
|
||||||
|
const nativeSubscribe = usePluginNativeSubscribe()
|
||||||
|
provide('moviepilot:nativeSubscribe', nativeSubscribe)
|
||||||
|
|
||||||
// 是否刷新
|
// 是否刷新
|
||||||
const isRefreshed = ref(false)
|
const isRefreshed = ref(false)
|
||||||
|
|
||||||
@@ -209,6 +214,7 @@ onBeforeMount(async () => {
|
|||||||
:is="dynamicComponent"
|
:is="dynamicComponent"
|
||||||
:initial-config="pluginConfigForm"
|
:initial-config="pluginConfigForm"
|
||||||
:api="api"
|
:api="api"
|
||||||
|
:native-subscribe="nativeSubscribe"
|
||||||
@save="handleVueComponentSave"
|
@save="handleVueComponentSave"
|
||||||
@layout="handleVueComponentLayout"
|
@layout="handleVueComponentLayout"
|
||||||
@switch="emit('switch')"
|
@switch="emit('switch')"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import api from '@/api'
|
|||||||
import { loadRemoteComponent } from '@/utils/federationLoader'
|
import { loadRemoteComponent } from '@/utils/federationLoader'
|
||||||
import { usePWA } from '@/composables/usePWA'
|
import { usePWA } from '@/composables/usePWA'
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
|
import { usePluginNativeSubscribe } from '@/composables/usePluginNativeSubscribe'
|
||||||
|
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -31,6 +32,10 @@ const { appMode } = usePWA()
|
|||||||
const $toast = useToast()
|
const $toast = useToast()
|
||||||
provide('moviepilot:toast', $toast)
|
provide('moviepilot:toast', $toast)
|
||||||
|
|
||||||
|
// 向联邦插件同时提供 prop 与 inject 形式的主程序原生订阅入口。
|
||||||
|
const nativeSubscribe = usePluginNativeSubscribe()
|
||||||
|
provide('moviepilot:nativeSubscribe', nativeSubscribe)
|
||||||
|
|
||||||
// 是否刷新
|
// 是否刷新
|
||||||
const isRefreshed = ref(false)
|
const isRefreshed = ref(false)
|
||||||
// 组件是否已加载成功
|
// 组件是否已加载成功
|
||||||
@@ -158,6 +163,7 @@ onMounted(() => {
|
|||||||
<component
|
<component
|
||||||
:is="dynamicComponent"
|
:is="dynamicComponent"
|
||||||
:api="api"
|
:api="api"
|
||||||
|
:native-subscribe="nativeSubscribe"
|
||||||
:show_switch="show_switch"
|
:show_switch="show_switch"
|
||||||
@action="handleAction"
|
@action="handleAction"
|
||||||
@switch="emit('switch')"
|
@switch="emit('switch')"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import DashboardRender from '@/components/render/DashboardRender.vue'
|
|||||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||||
import { loadRemoteComponent } from '@/utils/federationLoader'
|
import { loadRemoteComponent } from '@/utils/federationLoader'
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
|
import { usePluginNativeSubscribe } from '@/composables/usePluginNativeSubscribe'
|
||||||
|
|
||||||
type DashboardComponentLoader = () => Promise<any>
|
type DashboardComponentLoader = () => Promise<any>
|
||||||
|
|
||||||
@@ -13,7 +14,12 @@ type DashboardComponentLoader = () => Promise<any>
|
|||||||
const $toast = useToast()
|
const $toast = useToast()
|
||||||
provide('moviepilot:toast', $toast)
|
provide('moviepilot:toast', $toast)
|
||||||
|
|
||||||
|
// 向仪表板联邦组件导出主程序原生订阅入口。
|
||||||
|
const nativeSubscribe = usePluginNativeSubscribe()
|
||||||
|
provide('moviepilot:nativeSubscribe', nativeSubscribe)
|
||||||
|
|
||||||
const DashboardSkeleton = {
|
const DashboardSkeleton = {
|
||||||
|
// 创建无需模板编译的仪表板加载骨架。
|
||||||
setup() {
|
setup() {
|
||||||
const SkeletonLoader = resolveComponent('VSkeletonLoader')
|
const SkeletonLoader = resolveComponent('VSkeletonLoader')
|
||||||
|
|
||||||
@@ -226,7 +232,13 @@ onUnmounted(() => {
|
|||||||
<template v-else-if="!isNullOrEmptyObject(props.config)">
|
<template v-else-if="!isNullOrEmptyObject(props.config)">
|
||||||
<!-- Vue 渲染模式 -->
|
<!-- Vue 渲染模式 -->
|
||||||
<div v-if="pluginRenderMode === 'vue'" class="dashboard-plugin-vue-renderer">
|
<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>
|
</div>
|
||||||
<!-- Vuetify 渲染模式 -->
|
<!-- Vuetify 渲染模式 -->
|
||||||
<template v-else-if="pluginRenderMode === 'vuetify'">
|
<template v-else-if="pluginRenderMode === 'vuetify'">
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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}!',
|
cancelFailed: 'Failed to cancel subscription: {message}!',
|
||||||
notFound: 'Subscription not found!',
|
notFound: 'Subscription not found!',
|
||||||
requestFailed: 'Request failed. Please try again later.',
|
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',
|
filterSubscriptions: 'Filter Subscriptions',
|
||||||
name: 'Name',
|
name: 'Name',
|
||||||
searchShares: 'Search Subscription Shares',
|
searchShares: 'Search Subscription Shares',
|
||||||
|
|||||||
@@ -1131,6 +1131,13 @@ export default {
|
|||||||
cancelFailed: '取消订阅失败:{message}!',
|
cancelFailed: '取消订阅失败:{message}!',
|
||||||
notFound: '订阅不存在!',
|
notFound: '订阅不存在!',
|
||||||
requestFailed: '请求失败,请稍后重试',
|
requestFailed: '请求失败,请稍后重试',
|
||||||
|
native: {
|
||||||
|
invalidMedia: '无法打开原生订阅:媒体信息格式无效。',
|
||||||
|
missingId: '无法打开原生订阅:请提供有效的媒体数据源 ID。',
|
||||||
|
missingTitle: '无法打开原生订阅:请提供媒体标题。',
|
||||||
|
unsupportedType: '无法打开原生订阅:媒体类型必须是电影或电视剧。',
|
||||||
|
permissionDenied: '当前用户没有订阅权限。',
|
||||||
|
},
|
||||||
filterSubscriptions: '筛选订阅',
|
filterSubscriptions: '筛选订阅',
|
||||||
name: '名称',
|
name: '名称',
|
||||||
searchShares: '搜索订阅分享',
|
searchShares: '搜索订阅分享',
|
||||||
|
|||||||
@@ -1131,6 +1131,13 @@ export default {
|
|||||||
cancelFailed: '取消訂閱失敗:{message}!',
|
cancelFailed: '取消訂閱失敗:{message}!',
|
||||||
notFound: '訂閱不存在!',
|
notFound: '訂閱不存在!',
|
||||||
requestFailed: '請求失敗,請稍後重試',
|
requestFailed: '請求失敗,請稍後重試',
|
||||||
|
native: {
|
||||||
|
invalidMedia: '無法開啟原生訂閱:媒體資訊格式無效。',
|
||||||
|
missingId: '無法開啟原生訂閱:請提供有效的媒體資料來源 ID。',
|
||||||
|
missingTitle: '無法開啟原生訂閱:請提供媒體標題。',
|
||||||
|
unsupportedType: '無法開啟原生訂閱:媒體類型必須是電影或電視劇。',
|
||||||
|
permissionDenied: '目前使用者沒有訂閱權限。',
|
||||||
|
},
|
||||||
filterSubscriptions: '篩選訂閱',
|
filterSubscriptions: '篩選訂閱',
|
||||||
name: '名稱',
|
name: '名稱',
|
||||||
searchShares: '搜索訂閱分享',
|
searchShares: '搜索訂閱分享',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { Component } from 'vue'
|
|||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { loadRemoteAppPageComponent } from '@/utils/federationLoader'
|
import { loadRemoteAppPageComponent } from '@/utils/federationLoader'
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
|
import { usePluginNativeSubscribe } from '@/composables/usePluginNativeSubscribe'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
@@ -16,6 +17,10 @@ const loadError = ref(false)
|
|||||||
const $toast = useToast()
|
const $toast = useToast()
|
||||||
provide('moviepilot:toast', $toast)
|
provide('moviepilot:toast', $toast)
|
||||||
|
|
||||||
|
// 向侧栏全页联邦组件导出主程序原生订阅入口。
|
||||||
|
const nativeSubscribe = usePluginNativeSubscribe()
|
||||||
|
provide('moviepilot:nativeSubscribe', nativeSubscribe)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
[pluginId, navKey],
|
[pluginId, navKey],
|
||||||
async ([pid, nk]) => {
|
async ([pid, nk]) => {
|
||||||
@@ -47,6 +52,7 @@ watch(
|
|||||||
:is="RemoteView"
|
:is="RemoteView"
|
||||||
:key="`${pluginId}-${navKey}`"
|
:key="`${pluginId}-${navKey}`"
|
||||||
:api="api"
|
:api="api"
|
||||||
|
:native-subscribe="nativeSubscribe"
|
||||||
:nav-key="navKey"
|
:nav-key="navKey"
|
||||||
:plugin-id="pluginId"
|
:plugin-id="pluginId"
|
||||||
@action="() => {}"
|
@action="() => {}"
|
||||||
|
|||||||
Reference in New Issue
Block a user