feat(media-source): 支持插件注册的媒体数据源,各来源选择器与全局搜索适配扩展来源

This commit is contained in:
jxxghp
2026-08-18 15:19:56 +08:00
parent b8b8fabe15
commit 89da6be8bb
12 changed files with 190 additions and 11 deletions
+2
View File
@@ -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)
}
+7
View File
@@ -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。 */
+7
View File
@@ -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()
+20 -4
View File
@@ -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],
}
+46
View File
@@ -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,
}
}
+32 -1
View File
@@ -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
View File
@@ -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,其余来源只要求非空。 */
+8 -2
View File
@@ -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[]>([])
+6
View File
@@ -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。