mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-28 19:47:49 +08:00
feat(media-source): 支持插件注册的媒体数据源,各来源选择器与全局搜索适配扩展来源
This commit is contained in:
@@ -32,6 +32,7 @@ import { normalizeThemeMaterialAccent } from '@/utils/glassColor'
|
||||
import { configureApexChartsTheme } from '@/utils/apexCharts'
|
||||
import { useGlobalOfflineStatus, type ConnectionFailureReason } from '@/composables/useOfflineStatus'
|
||||
import { useSystemRestartStatus } from '@/composables/useSystemRestart'
|
||||
import { loadMediaSources } from '@/composables/useMediaSources'
|
||||
import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle'
|
||||
import { useGlassWallpaperTransaction } from '@/composables/useGlassWallpaperTransaction'
|
||||
import {
|
||||
@@ -913,6 +914,7 @@ async function initializeAuthenticatedState() {
|
||||
globalLoadingStateManager.setLoadingState('global-settings', true)
|
||||
await globalSettingsStore.initialize()
|
||||
await globalSettingsStore.loadUserSettings()
|
||||
await loadMediaSources()
|
||||
} finally {
|
||||
globalLoadingStateManager.setLoadingState('global-settings', false)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,13 @@ export enum MediaSource {
|
||||
|
||||
export type MediaDataSource = `${MediaSource}` | (string & {})
|
||||
|
||||
/** 后端返回的媒体来源注册描述,供各类来源选择器共享。 */
|
||||
export interface MediaSourceInfo {
|
||||
name: string
|
||||
media_source: MediaDataSource
|
||||
media_types: string[]
|
||||
}
|
||||
|
||||
// 手动刮削选项
|
||||
export interface ManualScrapeOptions {
|
||||
// 媒体数据源
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import MediaIdSelector from '../misc/MediaIdSelector.vue'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { isMediaDataSource, isValidMediaSourceId } from '@/utils/mediaId'
|
||||
import { useMediaSources } from '@/composables/useMediaSources'
|
||||
|
||||
// 多语言支持
|
||||
const { t } = useI18n()
|
||||
@@ -58,6 +59,8 @@ const normalizedMediaId = computed(() => selectedMediaId.value?.trim() || undefi
|
||||
const hasValidMediaIdentity = computed(
|
||||
() => Boolean(normalizedMediaId.value) && isValidMediaSourceId(normalizedMediaId.value, mediaSource.value),
|
||||
)
|
||||
const { mediaSourceItems: getMediaSourceItems } = useMediaSources()
|
||||
const customMediaSourceItems = getMediaSourceItems('media')
|
||||
|
||||
const mediaSourceItems = computed<{ title: string; value: MediaDataSource }[]>(() => {
|
||||
const labels: Partial<Record<MediaDataSource, string>> = {
|
||||
@@ -75,7 +78,10 @@ const mediaSourceItems = computed<{ title: string; value: MediaDataSource }[]>((
|
||||
migu: 'Migu Video',
|
||||
tencentvideodiscover: 'Tencent Video',
|
||||
}
|
||||
return Object.values(MediaSource).map(value => ({ title: labels[value] ?? value, value }))
|
||||
return [
|
||||
...Object.values(MediaSource).map(value => ({ title: labels[value] ?? value, value })),
|
||||
...customMediaSourceItems.value,
|
||||
]
|
||||
})
|
||||
|
||||
// 当前数据源对应的原生ID标签。
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { MediaDataSource, MusicEntityType } from '@/api/types'
|
||||
import { isMediaDataSource, isMusicMediaSource } from '@/utils/mediaId'
|
||||
import { useMediaSources } from '@/composables/useMediaSources'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -41,6 +42,9 @@ const mediaSource = ref<MediaDataSource>(
|
||||
const mediaId = ref<string>()
|
||||
const musicType = ref<Exclude<MusicEntityType, 'artist'>>(props.musicType)
|
||||
const isMusicSelection = computed(() => isMusicMediaSource(mediaSource.value))
|
||||
const { mediaSourceItems: getMediaSourceItems } = useMediaSources()
|
||||
const customMediaSourceItems = getMediaSourceItems('media')
|
||||
const customMusicSourceItems = getMediaSourceItems('music')
|
||||
const musicEntityItems = computed(() => [
|
||||
{ title: t('setting.cache.musicType.recording'), value: 'recording' },
|
||||
{ title: t('setting.cache.musicType.album'), value: 'album' },
|
||||
@@ -53,6 +57,8 @@ const mediaSourceItems = computed<{ title: string; value: MediaDataSource }[]>((
|
||||
{ title: t('setting.cache.recognitionSource.musicbrainz'), value: 'musicbrainz' },
|
||||
{ title: t('setting.cache.recognitionSource.theaudiodb'), value: 'theaudiodb' },
|
||||
{ title: t('setting.cache.recognitionSource.doubanmusic'), value: 'doubanmusic' },
|
||||
...customMediaSourceItems.value,
|
||||
...customMusicSourceItems.value,
|
||||
])
|
||||
|
||||
const mediaIdLabel = computed(() => {
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { isMusicMediaSource, isValidMediaSourceId } from '@/utils/mediaId'
|
||||
import { useMediaSources } from '@/composables/useMediaSources'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -44,6 +45,10 @@ const props = defineProps({
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
const globalSettings = globalSettingsStore.globalSettings
|
||||
|
||||
const { mediaSourceItems: getMediaSourceItems } = useMediaSources()
|
||||
const customMediaSourceItems = getMediaSourceItems('media')
|
||||
const customMusicSourceItems = getMediaSourceItems('music')
|
||||
|
||||
const mediaSourceItems = computed<{ title: string; value: MediaDataSource | null }[]>(() => [
|
||||
{ title: t('dialog.reorganize.auto'), value: null },
|
||||
{ title: t('setting.cache.recognitionSource.themoviedb'), value: 'themoviedb' },
|
||||
@@ -53,6 +58,8 @@ const mediaSourceItems = computed<{ title: string; value: MediaDataSource | null
|
||||
{ title: t('setting.cache.recognitionSource.musicbrainz'), value: 'musicbrainz' },
|
||||
{ title: t('setting.cache.recognitionSource.theaudiodb'), value: 'theaudiodb' },
|
||||
{ title: t('setting.cache.recognitionSource.doubanmusic'), value: 'doubanmusic' },
|
||||
...customMediaSourceItems.value,
|
||||
...customMusicSourceItems.value,
|
||||
])
|
||||
|
||||
/** 获取后台设置中的默认识别数据源,未知值兼容回退到 TheMovieDb。 */
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useGlobalSettingsStore } from '@/stores'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import MediaIdSelector from '../misc/MediaIdSelector.vue'
|
||||
import { isMusicMediaSource, isValidMediaSourceId } from '@/utils/mediaId'
|
||||
import { useMediaSources } from '@/composables/useMediaSources'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -24,6 +25,10 @@ const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const { mediaSourceItems: getMediaSourceItems } = useMediaSources()
|
||||
const customMediaSourceItems = getMediaSourceItems('media')
|
||||
const customMusicSourceItems = getMediaSourceItems('music')
|
||||
|
||||
const mediaSourceItems = computed<{ title: string; value: MediaDataSource }[]>(() => [
|
||||
{ title: t('setting.cache.recognitionSource.themoviedb'), value: 'themoviedb' },
|
||||
{ title: t('setting.cache.recognitionSource.douban'), value: 'douban' },
|
||||
@@ -32,6 +37,8 @@ const mediaSourceItems = computed<{ title: string; value: MediaDataSource }[]>((
|
||||
{ title: t('setting.cache.recognitionSource.musicbrainz'), value: 'musicbrainz' },
|
||||
{ title: t('setting.cache.recognitionSource.theaudiodb'), value: 'theaudiodb' },
|
||||
{ title: t('setting.cache.recognitionSource.doubanmusic'), value: 'doubanmusic' },
|
||||
...customMediaSourceItems.value,
|
||||
...customMusicSourceItems.value,
|
||||
])
|
||||
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import type { Site, Plugin, Subscribe } from '@/api/types'
|
||||
import type { MediaDataSource, Site, Plugin, Subscribe } from '@/api/types'
|
||||
import { getNavMenus, getSettingTabs } from '@/router/i18n-menu'
|
||||
import { NavMenu } from '@/@layouts/types'
|
||||
import { useUserStore, useGlobalSettingsStore } from '@/stores'
|
||||
@@ -9,6 +9,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { VDialog, VMenu } from 'vuetify/components'
|
||||
import { buildUserPermissionContext, hasPermission, filterMenusByPermission } from '@/utils/permission'
|
||||
import { useMediaSources } from '@/composables/useMediaSources'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
@@ -99,7 +100,7 @@ const searchOverlayProps = computed(() =>
|
||||
// 搜索词
|
||||
const searchWord = ref<string | null>(null)
|
||||
|
||||
type MediaSearchSource = 'themoviedb' | 'douban' | 'bangumi' | 'anilist' | 'musicbrainz' | 'theaudiodb' | 'doubanmusic'
|
||||
type MediaSearchSource = MediaDataSource
|
||||
type MediaSearchType = 'media' | 'music' | 'collection' | 'person'
|
||||
|
||||
interface MediaSearchSourceOption {
|
||||
@@ -123,6 +124,21 @@ const selectedMediaSearchSources = reactive<Record<MediaSearchType, MediaSearchS
|
||||
person: [],
|
||||
})
|
||||
|
||||
const { mediaSourceItems } = useMediaSources()
|
||||
const customMediaSources = mediaSourceItems('media')
|
||||
const customMusicSources = mediaSourceItems('music')
|
||||
/** 将来源目录项转换为搜索来源选项,保持展示名与请求值分离。 */
|
||||
const customSearchSourceOptions = (sources: typeof customMediaSources) =>
|
||||
computed<MediaSearchSourceOption[]>(() =>
|
||||
sources.value.map(source => ({
|
||||
label: source.title,
|
||||
name: source.title,
|
||||
value: source.value,
|
||||
})),
|
||||
)
|
||||
const customMediaSearchSources = customSearchSourceOptions(customMediaSources)
|
||||
const customMusicSearchSources = customSearchSourceOptions(customMusicSources)
|
||||
|
||||
// 全局“媒体搜索数据源”配置(SEARCH_SOURCE),用于搜索框默认勾选,保证基础设置生效。
|
||||
const configuredMediaSearchSources = computed((): MediaSearchSource[] => {
|
||||
const raw = globalSettingsStore.get('SEARCH_SOURCE')
|
||||
@@ -209,8 +225,8 @@ const mediaSearchSourceOptions = computed<Record<MediaSearchType, MediaSearchSou
|
||||
}
|
||||
|
||||
return {
|
||||
media: [themoviedb, douban, bangumi, anilist],
|
||||
music: [musicbrainz, theaudiodb, doubanmusic],
|
||||
media: [themoviedb, douban, bangumi, anilist, ...customMediaSearchSources.value],
|
||||
music: [musicbrainz, theaudiodb, doubanmusic, ...customMusicSearchSources.value],
|
||||
collection: [themoviedb],
|
||||
person: [themoviedb, douban],
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { computed } from 'vue'
|
||||
import api from '@/api'
|
||||
import type { MediaSourceInfo } from '@/api/types'
|
||||
import { MediaSource } from '@/api/types'
|
||||
import { getMediaSourceCatalog, setMediaSourceCatalog, supportsMediaSourceType } from '@/utils/mediaId'
|
||||
|
||||
export type MediaSourceOptionKind = 'media' | 'music'
|
||||
|
||||
let mediaSourceLoadPromise: Promise<void> | null = null
|
||||
|
||||
/** 加载后端注册的媒体来源目录;同一页面会话只发起一次请求。 */
|
||||
export function loadMediaSources(): Promise<void> {
|
||||
if (mediaSourceLoadPromise) return mediaSourceLoadPromise
|
||||
mediaSourceLoadPromise = api
|
||||
.get<MediaSourceInfo[]>('media/source')
|
||||
.then(sources => setMediaSourceCatalog(Array.isArray(sources) ? sources : []))
|
||||
.catch(error => {
|
||||
console.warn('加载媒体数据源失败,继续使用内置来源:', error)
|
||||
})
|
||||
return mediaSourceLoadPromise
|
||||
}
|
||||
|
||||
/** 提供来源选择器所需的插件扩展项,并按影视/音乐能力过滤。 */
|
||||
export function useMediaSources() {
|
||||
const catalog = getMediaSourceCatalog()
|
||||
const customMediaSources = computed(() => {
|
||||
const builtin = new Set<string>(Object.values(MediaSource))
|
||||
return catalog.value.filter(source => !builtin.has(source.media_source))
|
||||
})
|
||||
|
||||
const mediaSourceItems = (kind?: MediaSourceOptionKind) =>
|
||||
computed(() =>
|
||||
customMediaSources.value
|
||||
.filter(source => {
|
||||
return !kind || supportsMediaSourceType(source, kind)
|
||||
})
|
||||
.map(source => ({ title: source.name, value: source.media_source })),
|
||||
)
|
||||
|
||||
return {
|
||||
catalog,
|
||||
customMediaSources,
|
||||
mediaSourceItems,
|
||||
loadMediaSources,
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,10 @@
|
||||
import { isMusicMediaSource, isValidMediaSourceId, parseMediaDataSources } from '@/utils/mediaId'
|
||||
import {
|
||||
isMusicMediaSource,
|
||||
isValidMediaSourceId,
|
||||
parseMediaDataSources,
|
||||
setMediaSourceCatalog,
|
||||
supportsMediaSourceType,
|
||||
} from '@/utils/mediaId'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('media source identity utils', () => {
|
||||
@@ -6,6 +12,31 @@ describe('media source identity utils', () => {
|
||||
expect(isMusicMediaSource(source)).toBe(true)
|
||||
})
|
||||
|
||||
it('recognizes a plugin-registered music source', () => {
|
||||
setMediaSourceCatalog([
|
||||
{
|
||||
name: 'Acme Music',
|
||||
media_source: 'acme.music',
|
||||
media_types: ['音乐'],
|
||||
},
|
||||
])
|
||||
expect(isMusicMediaSource('acme.music')).toBe(true)
|
||||
setMediaSourceCatalog([])
|
||||
})
|
||||
|
||||
it('keeps a source with mixed video and music capabilities in both lists', () => {
|
||||
const source = {
|
||||
media_types: ['电影', '电视剧', '音乐'],
|
||||
}
|
||||
expect(supportsMediaSourceType(source, 'media')).toBe(true)
|
||||
expect(supportsMediaSourceType(source, 'music')).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts API-style media type keys for plugin sources', () => {
|
||||
expect(supportsMediaSourceType({ media_types: ['movie', 'music'] }, 'media')).toBe(true)
|
||||
expect(supportsMediaSourceType({ media_types: ['movie', 'music'] }, 'music')).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['themoviedb', ['themoviedb']],
|
||||
[' musicbrainz, theaudiodb,unknown,musicbrainz ', ['musicbrainz', 'theaudiodb', 'unknown']],
|
||||
|
||||
+42
-3
@@ -1,9 +1,43 @@
|
||||
import type { MediaDataSource } from '@/api/types'
|
||||
import { ref } from 'vue'
|
||||
import type { MediaDataSource, MediaSourceInfo } from '@/api/types'
|
||||
|
||||
const MUSICBRAINZ_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
const IMDB_ID_PATTERN = /^tt\d+$/i
|
||||
export const MUSIC_MEDIA_SOURCES = ['musicbrainz', 'theaudiodb', 'doubanmusic'] as const
|
||||
const MEDIA_SOURCE_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/
|
||||
const mediaSourceCatalog = ref<MediaSourceInfo[]>([])
|
||||
|
||||
const MUSIC_MEDIA_TYPES = new Set(['音乐', 'music'])
|
||||
const VIDEO_MEDIA_TYPES = new Set(['电影', '电视剧', 'movie', 'tv'])
|
||||
|
||||
/** 更新宿主与插件合并后的来源目录,并保留合法来源的稳定顺序。 */
|
||||
export function setMediaSourceCatalog(sources: MediaSourceInfo[]) {
|
||||
const seen = new Set<string>()
|
||||
mediaSourceCatalog.value = sources.reduce<MediaSourceInfo[]>((catalog, source) => {
|
||||
const value = source?.media_source?.toString().trim().toLowerCase()
|
||||
if (!value || !MEDIA_SOURCE_PATTERN.test(value) || seen.has(value)) return catalog
|
||||
seen.add(value)
|
||||
catalog.push({ ...source, media_source: value })
|
||||
return catalog
|
||||
}, [])
|
||||
}
|
||||
|
||||
/** 返回当前已加载的来源目录,供来源选择器构造选项。 */
|
||||
export function getMediaSourceCatalog() {
|
||||
return mediaSourceCatalog
|
||||
}
|
||||
|
||||
/** 判断注册来源是否声明了指定的影视或音乐能力。 */
|
||||
export function supportsMediaSourceType(
|
||||
source: Pick<MediaSourceInfo, 'media_types'>,
|
||||
kind: 'media' | 'music',
|
||||
): boolean {
|
||||
const mediaTypes = (source.media_types ?? [])
|
||||
.filter(type => typeof type === 'string')
|
||||
.map(type => type.trim().toLowerCase())
|
||||
const supportedTypes = kind === 'music' ? MUSIC_MEDIA_TYPES : VIDEO_MEDIA_TYPES
|
||||
return mediaTypes.some(type => supportedTypes.has(type))
|
||||
}
|
||||
|
||||
/** 判断外部输入是否为内置或插件注册的规范媒体来源标识。 */
|
||||
export function isMediaDataSource(value: unknown): value is MediaDataSource {
|
||||
@@ -23,9 +57,14 @@ export function parseMediaDataSources(value: unknown): MediaDataSource[] {
|
||||
]
|
||||
}
|
||||
|
||||
/** 判断当前请求来源是否为内置音乐元数据源。 */
|
||||
/** 判断当前请求来源是否为内置或插件注册的音乐元数据源。 */
|
||||
export function isMusicMediaSource(source?: MediaDataSource): boolean {
|
||||
return MUSIC_MEDIA_SOURCES.includes(source as (typeof MUSIC_MEDIA_SOURCES)[number])
|
||||
const normalized = source?.toString().trim().toLowerCase()
|
||||
if (!normalized) return false
|
||||
return (
|
||||
MUSIC_MEDIA_SOURCES.includes(normalized as (typeof MUSIC_MEDIA_SOURCES)[number]) ||
|
||||
mediaSourceCatalog.value.some(item => item.media_source === normalized && supportsMediaSourceType(item, 'music'))
|
||||
)
|
||||
}
|
||||
|
||||
/** 按媒体数据源校验有固定格式的原生 ID,其余来源只要求非空。 */
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getApiBusinessErrorMessage } from '@/api/client'
|
||||
import type { FilterRuleGroup, Site } from '@/api/types'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useSilentSettingRefresh } from '@/composables/useSilentSettingRefresh'
|
||||
import { useMediaSources } from '@/composables/useMediaSources'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -18,6 +19,9 @@ const props = defineProps({
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
const { mediaSourceItems: getMediaSourceItems } = useMediaSources()
|
||||
const customMediaSourceItems = getMediaSourceItems('media')
|
||||
const customMusicSourceItems = getMediaSourceItems('music')
|
||||
|
||||
// 所有站点
|
||||
const allSites = ref<Site[]>([])
|
||||
@@ -36,7 +40,7 @@ const SystemSettings = ref<any>({
|
||||
})
|
||||
|
||||
// 媒体信息数据源字典
|
||||
const mediaSourcesDict = [
|
||||
const mediaSourcesDict = computed(() => [
|
||||
{
|
||||
title: 'TheMovieDb',
|
||||
value: 'themoviedb',
|
||||
@@ -65,7 +69,9 @@ const mediaSourcesDict = [
|
||||
title: '豆瓣音乐',
|
||||
value: 'doubanmusic',
|
||||
},
|
||||
]
|
||||
...customMediaSourceItems.value,
|
||||
...customMusicSourceItems.value,
|
||||
])
|
||||
|
||||
// 当前选中的媒体信息数据源
|
||||
const selectedMediaSource = ref<string[]>([])
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useGlobalSettingsStore } from '@/stores'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { isMusicMediaSource } from '@/utils/mediaId'
|
||||
import { useMediaSources } from '@/composables/useMediaSources'
|
||||
|
||||
interface PipelineStep {
|
||||
icon: string
|
||||
@@ -70,6 +71,9 @@ const emit = defineEmits<{ close: [] }>()
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
const { mediaSourceItems: getMediaSourceItems } = useMediaSources()
|
||||
const customMediaSourceItems = getMediaSourceItems('media')
|
||||
const customMusicSourceItems = getMediaSourceItems('music')
|
||||
|
||||
const mediaSourceItems = computed<{ title: string; value: MediaDataSource }[]>(() => [
|
||||
{ title: t('setting.cache.recognitionSource.themoviedb'), value: 'themoviedb' },
|
||||
@@ -79,6 +83,8 @@ const mediaSourceItems = computed<{ title: string; value: MediaDataSource }[]>((
|
||||
{ title: t('setting.cache.recognitionSource.musicbrainz'), value: 'musicbrainz' },
|
||||
{ title: t('setting.cache.recognitionSource.theaudiodb'), value: 'theaudiodb' },
|
||||
{ title: t('setting.cache.recognitionSource.doubanmusic'), value: 'doubanmusic' },
|
||||
...customMediaSourceItems.value,
|
||||
...customMusicSourceItems.value,
|
||||
])
|
||||
|
||||
// 获取后台默认识别数据源,未知值兼容回退到TheMovieDb。
|
||||
|
||||
Reference in New Issue
Block a user