feat(plugin): expose native subscribe action (#502)

This commit is contained in:
jxxghp
2026-07-23 17:29:36 +08:00
parent 787076355b
commit 31caf8f5f8
10 changed files with 540 additions and 11 deletions

View File

@@ -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() {

View File

@@ -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')"

View File

@@ -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')"

View File

@@ -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'">

View 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()
})
})

View 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
}

View File

@@ -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',

View File

@@ -1131,6 +1131,13 @@ export default {
cancelFailed: '取消订阅失败:{message}',
notFound: '订阅不存在!',
requestFailed: '请求失败,请稍后重试',
native: {
invalidMedia: '无法打开原生订阅:媒体信息格式无效。',
missingId: '无法打开原生订阅:请提供有效的媒体数据源 ID。',
missingTitle: '无法打开原生订阅:请提供媒体标题。',
unsupportedType: '无法打开原生订阅:媒体类型必须是电影或电视剧。',
permissionDenied: '当前用户没有订阅权限。',
},
filterSubscriptions: '筛选订阅',
name: '名称',
searchShares: '搜索订阅分享',

View File

@@ -1131,6 +1131,13 @@ export default {
cancelFailed: '取消訂閱失敗:{message}',
notFound: '訂閱不存在!',
requestFailed: '請求失敗,請稍後重試',
native: {
invalidMedia: '無法開啟原生訂閱:媒體資訊格式無效。',
missingId: '無法開啟原生訂閱:請提供有效的媒體資料來源 ID。',
missingTitle: '無法開啟原生訂閱:請提供媒體標題。',
unsupportedType: '無法開啟原生訂閱:媒體類型必須是電影或電視劇。',
permissionDenied: '目前使用者沒有訂閱權限。',
},
filterSubscriptions: '篩選訂閱',
name: '名稱',
searchShares: '搜索訂閱分享',

View File

@@ -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="() => {}"