mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-28 11:37:29 +08:00
feat(music): 完善歌词设置与专辑整理
This commit is contained in:
@@ -195,6 +195,11 @@ function getPreviewItemKey(item: ManualTransferPreviewItem) {
|
||||
|
||||
const normalizedItems = computed(() => dedupeFileItems(props.items))
|
||||
|
||||
// 整理目录代表一个集合实体;音频文件仍按单曲处理,避免目录入口误入 Recording 搜索。
|
||||
const defaultMusicEntity = computed<'album' | 'recording'>(() =>
|
||||
normalizedItems.value.length > 0 && normalizedItems.value.every(item => item.type === 'dir') ? 'album' : 'recording',
|
||||
)
|
||||
|
||||
// 分页
|
||||
const previewPage = ref(1)
|
||||
const previewPageSize = ref(20)
|
||||
@@ -499,7 +504,7 @@ watch(
|
||||
if (isMusicType !== sourceIsMusic) {
|
||||
transferForm.media_source = null
|
||||
}
|
||||
transferForm.music_type = isMusicType ? (transferForm.music_type ?? 'recording') : null
|
||||
transferForm.music_type = isMusicType ? (transferForm.music_type ?? defaultMusicEntity.value) : null
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1519,14 +1524,12 @@ onUnmounted(() => {
|
||||
{ title: t('music.entityRecording'), value: 'recording' },
|
||||
{ title: t('music.entityAlbum'), value: 'album' },
|
||||
]"
|
||||
:hint="t('dialog.reorganize.musicEntityHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-music-box-multiple"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
v-if="transferForm.type_name !== ''"
|
||||
cols="12"
|
||||
:md="transferForm.type_name === '音乐' ? 3 : 4"
|
||||
>
|
||||
<VCol v-if="transferForm.type_name !== ''" cols="12" :md="transferForm.type_name === '音乐' ? 3 : 4">
|
||||
<VTextField
|
||||
v-model="transferForm.media_id"
|
||||
:label="mediaIdLabel"
|
||||
|
||||
@@ -796,7 +796,7 @@ describe('ReorganizeDialog payloads and lifecycle', () => {
|
||||
|
||||
await selectOption('类型', 3)
|
||||
await waitFor(() => expect(screen.getByLabelText('MusicBrainz ID')).toBeInTheDocument())
|
||||
await selectOption('音乐实体', 1)
|
||||
expect(screen.getByLabelText<HTMLSelectElement>('音乐实体')).toHaveDisplayValue('专辑')
|
||||
await fireEvent.input(screen.getByLabelText('MusicBrainz ID'), {
|
||||
target: { value: '977e6978-139d-425c-bb98-6b0c62d1e45e' },
|
||||
})
|
||||
@@ -813,6 +813,16 @@ describe('ReorganizeDialog payloads and lifecycle', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults a selected audio file to the recording namespace', async () => {
|
||||
await renderDialog({
|
||||
items: [createFileItem({ name: '晴天.flac', path: '/downloads/晴天.flac' })],
|
||||
})
|
||||
|
||||
await selectOption('类型', 3)
|
||||
|
||||
await waitFor(() => expect(screen.getByLabelText<HTMLSelectElement>('音乐实体')).toHaveDisplayValue('单曲'))
|
||||
})
|
||||
|
||||
it('recommends an episode format and includes it in the next request', async () => {
|
||||
const recommendationBodies: unknown[] = []
|
||||
const transferBodies: unknown[] = []
|
||||
|
||||
@@ -509,7 +509,8 @@ describe('FileList dialogs, download and lifecycle', () => {
|
||||
it('passes single and selected items to scrape and reorganize boundary dialogs', async () => {
|
||||
const first = createItem({ name: 'first.mkv', path: '/media/first.mkv' })
|
||||
const second = createItem({ name: 'second.mkv', path: '/media/second.mkv' })
|
||||
await renderList(() => Promise.resolve([first, second]))
|
||||
const album = createItem({ name: '叶惠美', path: '/media/叶惠美/', type: 'dir' })
|
||||
await renderList(() => Promise.resolve([album, first, second]))
|
||||
await screen.findByText('first.mkv')
|
||||
|
||||
const firstRow = screen.getByText('first.mkv').closest('[role="button"]') as Element
|
||||
@@ -522,6 +523,13 @@ describe('FileList dialogs, download and lifecycle', () => {
|
||||
target_storage: 'local',
|
||||
})
|
||||
|
||||
const albumRow = screen.getByText('叶惠美').closest('[role="button"]') as Element
|
||||
await fireEvent.click(albumRow.querySelector('[data-icon="mdi-folder-arrow-right"]')?.closest('button') as Element)
|
||||
expect(mocks.openSharedDialog.mock.calls.at(-1)?.[1]).toMatchObject({
|
||||
items: [album],
|
||||
target_storage: 'local',
|
||||
})
|
||||
|
||||
await fireEvent.click(getIconButton('mdi-select'))
|
||||
await fireEvent.click(firstRow)
|
||||
await fireEvent.click(screen.getByText('second.mkv').closest('[role="button"]') as Element)
|
||||
|
||||
@@ -2187,6 +2187,21 @@ export default {
|
||||
acoustIdApiKeyPlaceholder: 'Please enter AcoustID API Key',
|
||||
acoustIdApiKeyHint: 'Used to identify MusicBrainz recording IDs from audio fingerprints',
|
||||
acoustIdApiKeyRequired: 'Please enter AcoustID API Key',
|
||||
theAudioDbApiKey: 'TheAudioDB API Key',
|
||||
theAudioDbApiKeyHint: 'Used for music metadata and plain-lyrics fallback; 123 is the public V1 key',
|
||||
lrclibBaseUrl: 'LRCLIB Service URL',
|
||||
lrclibBaseUrlHint: 'Use the official service or a self-hosted LRCLIB-compatible instance',
|
||||
musixmatchApiKey: 'Musixmatch API Key',
|
||||
musixmatchApiKeyHint:
|
||||
'Optional; uses only the licensed official API. Ensure your plan permits storing and displaying lyrics',
|
||||
musixmatchBaseUrl: 'Musixmatch API URL',
|
||||
musixmatchBaseUrlHint: 'Root URL of the official API or an authorized proxy',
|
||||
lyricsBatchTimeout: 'Lyrics Batch Budget',
|
||||
lyricsBatchTimeoutHint:
|
||||
'Maximum time for all online lyrics requests in one music scrape; 0 uses local lyrics only',
|
||||
lyricsRetryMaxWait: 'Lyrics Retry Wait Limit',
|
||||
lyricsRetryMaxWaitHint: 'Cool down a provider when Retry-After exceeds this limit instead of blocking the album',
|
||||
secondUnit: 'sec',
|
||||
musicMetadataToSimplified: 'Convert Music Metadata to Simplified Chinese',
|
||||
musicMetadataToSimplifiedHint:
|
||||
'Force recognized music titles, artists, albums, categories, and other metadata to Simplified Chinese',
|
||||
@@ -2430,6 +2445,7 @@ export default {
|
||||
skipDesc: 'Skip scraping, this file will not be generated',
|
||||
missingOnlyDesc: 'Scrape only if missing, existing file remains unchanged',
|
||||
overwriteDesc: 'Always scrape, existing file will be overwritten',
|
||||
upgradeDesc: 'Replace only with higher-quality lyrics and never downgrade synced lyrics to plain text',
|
||||
},
|
||||
},
|
||||
site: {
|
||||
@@ -3417,6 +3433,7 @@ export default {
|
||||
mediaType: 'Type',
|
||||
mediaTypeHint: 'File media type',
|
||||
musicEntity: 'Music Entity',
|
||||
musicEntityHint: 'Album folders default to Album; individual audio files default to Recording.',
|
||||
mediaSource: 'Data Source',
|
||||
mediaSourceHint: 'Uses the backend recognition setting by default; switch it for this organization and scrape',
|
||||
tmdbId: 'TheMovieDb ID',
|
||||
|
||||
@@ -2161,6 +2161,19 @@ export default {
|
||||
acoustIdApiKeyPlaceholder: '请输入 AcoustID API Key',
|
||||
acoustIdApiKeyHint: '用于通过音频指纹识别 MusicBrainz 录音 ID',
|
||||
acoustIdApiKeyRequired: '请输入 AcoustID API Key',
|
||||
theAudioDbApiKey: 'TheAudioDB API Key',
|
||||
theAudioDbApiKeyHint: '用于音乐元数据和纯文本歌词兜底,默认 123 为官方公开 V1 Key',
|
||||
lrclibBaseUrl: 'LRCLIB 服务地址',
|
||||
lrclibBaseUrlHint: '可填写官方地址或兼容 LRCLIB API 的自建实例',
|
||||
musixmatchApiKey: 'Musixmatch API Key',
|
||||
musixmatchApiKeyHint: '可选;仅使用官方授权 API,请确保账户授权允许保存和展示歌词',
|
||||
musixmatchBaseUrl: 'Musixmatch API 地址',
|
||||
musixmatchBaseUrlHint: '官方或已授权代理的 API 根地址',
|
||||
lyricsBatchTimeout: '歌词批次查询预算',
|
||||
lyricsBatchTimeoutHint: '单次音乐刮削用于所有在线歌词请求的最长时间,0 表示仅使用本地歌词',
|
||||
lyricsRetryMaxWait: '歌词来源最大重试等待',
|
||||
lyricsRetryMaxWaitHint: 'Retry-After 超过该秒数时进入来源冷却,避免阻塞整张专辑',
|
||||
secondUnit: '秒',
|
||||
musicMetadataToSimplified: '音乐媒体信息转简体中文',
|
||||
musicMetadataToSimplifiedHint: '将识别结果中的曲名、艺术家、专辑和分类等音乐信息强制转换为简体中文',
|
||||
tmdbImageDomain: 'TMDB 图片服务地址',
|
||||
@@ -2391,6 +2404,7 @@ export default {
|
||||
skipDesc: '跳过刮削,不生成该文件',
|
||||
missingOnlyDesc: '仅在缺失时刮削,已存在则保持不变',
|
||||
overwriteDesc: '始终刮削,已存在则覆盖',
|
||||
upgradeDesc: '仅在新歌词质量更高时替换,禁止同步歌词降级为纯文本',
|
||||
},
|
||||
},
|
||||
site: {
|
||||
@@ -3360,6 +3374,7 @@ export default {
|
||||
mediaType: '类型',
|
||||
mediaTypeHint: '文件的媒体类型',
|
||||
musicEntity: '音乐实体',
|
||||
musicEntityHint: '选择专辑目录时默认按专辑整理,单个音频文件默认按单曲整理',
|
||||
mediaSource: '数据源',
|
||||
mediaSourceHint: '默认使用后台识别设置,可为本次整理与刮削单独切换',
|
||||
tmdbId: 'TheMovieDb编号',
|
||||
|
||||
@@ -2160,6 +2160,19 @@ export default {
|
||||
acoustIdApiKeyPlaceholder: '請輸入 AcoustID API Key',
|
||||
acoustIdApiKeyHint: '用於通過音訊指紋識別 MusicBrainz 錄音 ID',
|
||||
acoustIdApiKeyRequired: '請輸入 AcoustID API Key',
|
||||
theAudioDbApiKey: 'TheAudioDB API Key',
|
||||
theAudioDbApiKeyHint: '用於音樂媒體資訊和純文字歌詞備援,預設 123 為官方公開 V1 Key',
|
||||
lrclibBaseUrl: 'LRCLIB 服務位址',
|
||||
lrclibBaseUrlHint: '可填寫官方位址或相容 LRCLIB API 的自建執行個體',
|
||||
musixmatchApiKey: 'Musixmatch API Key',
|
||||
musixmatchApiKeyHint: '選用;僅使用官方授權 API,請確認帳戶授權允許儲存和顯示歌詞',
|
||||
musixmatchBaseUrl: 'Musixmatch API 位址',
|
||||
musixmatchBaseUrlHint: '官方或已授權代理的 API 根位址',
|
||||
lyricsBatchTimeout: '歌詞批次查詢預算',
|
||||
lyricsBatchTimeoutHint: '單次音樂刮削用於所有線上歌詞請求的最長時間,0 表示僅使用本機歌詞',
|
||||
lyricsRetryMaxWait: '歌詞來源最大重試等待',
|
||||
lyricsRetryMaxWaitHint: 'Retry-After 超過該秒數時進入來源冷卻,避免阻塞整張專輯',
|
||||
secondUnit: '秒',
|
||||
musicMetadataToSimplified: '音樂媒體資訊轉簡體中文',
|
||||
musicMetadataToSimplifiedHint: '將識別結果中的曲名、藝術家、專輯和分類等音樂資訊強制轉換為簡體中文',
|
||||
tmdbImageDomain: 'TMDB 圖片服務地址',
|
||||
@@ -2390,6 +2403,7 @@ export default {
|
||||
skipDesc: '跳過刮削,不生成該文件',
|
||||
missingOnlyDesc: '僅在缺失時刮削,已存在則保持不變',
|
||||
overwriteDesc: '始終刮削,已存在則覆蓋',
|
||||
upgradeDesc: '僅在新歌詞品質更高時替換,禁止同步歌詞降級為純文字',
|
||||
},
|
||||
},
|
||||
site: {
|
||||
@@ -3358,6 +3372,8 @@ export default {
|
||||
targetPathPlaceholder: '選擇自動或輸入路徑',
|
||||
mediaType: '類型',
|
||||
mediaTypeHint: '文件的媒體類型',
|
||||
musicEntity: '音樂實體',
|
||||
musicEntityHint: '選擇專輯目錄時預設按專輯整理,單個音訊檔案預設按單曲整理',
|
||||
mediaSource: '數據源',
|
||||
mediaSourceHint: '預設使用後台識別設置,可為本次整理與刮削單獨切換',
|
||||
tmdbId: 'TheMovieDb編號',
|
||||
|
||||
@@ -114,6 +114,12 @@ const SystemSettings = ref<any>({
|
||||
TMDB_API_DOMAIN: null,
|
||||
TMDB_API_KEY: null,
|
||||
ACOUSTID_API_KEY: null,
|
||||
THEAUDIODB_API_KEY: '123',
|
||||
LRCLIB_BASE_URL: 'https://lrclib.net',
|
||||
MUSIXMATCH_API_KEY: null,
|
||||
MUSIXMATCH_BASE_URL: 'https://api.musixmatch.com/ws/1.1',
|
||||
LYRICS_BATCH_TIMEOUT: 120,
|
||||
LYRICS_PROVIDER_RETRY_MAX_WAIT: 5,
|
||||
MUSIC_METADATA_TO_SIMPLIFIED: true,
|
||||
TMDB_IMAGE_DOMAIN: null,
|
||||
MUSIC_COVER_PROXY: null,
|
||||
@@ -212,10 +218,14 @@ const scrapingConfig = [
|
||||
]
|
||||
|
||||
// 刮削策略设置
|
||||
type ScrapingPolicy = 'skip' | 'missingOnly' | 'overwrite'
|
||||
type ScrapingPolicy = 'skip' | 'missingOnly' | 'overwrite' | 'upgrade'
|
||||
|
||||
const ScrapingPolicies = ref<Record<string, ScrapingPolicy>>(
|
||||
Object.fromEntries(scrapingConfig.flatMap(section => section.items.map(item => [item.key, 'missingOnly']))),
|
||||
Object.fromEntries(
|
||||
scrapingConfig.flatMap(section =>
|
||||
section.items.map(item => [item.key, item.key === 'music_lyrics' ? 'upgrade' : 'missingOnly']),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
// 是否发送请求的总开关
|
||||
@@ -2132,6 +2142,67 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
||||
prepend-inner-icon="mdi-music-box-multiple-outline"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="SystemSettings.Advanced.THEAUDIODB_API_KEY"
|
||||
:label="t('setting.system.theAudioDbApiKey')"
|
||||
:hint="t('setting.system.theAudioDbApiKeyHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-key-variant"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="SystemSettings.Advanced.LRCLIB_BASE_URL"
|
||||
:label="t('setting.system.lrclibBaseUrl')"
|
||||
:hint="t('setting.system.lrclibBaseUrlHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-music-note-plus"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="SystemSettings.Advanced.MUSIXMATCH_API_KEY"
|
||||
:label="t('setting.system.musixmatchApiKey')"
|
||||
:hint="t('setting.system.musixmatchApiKeyHint')"
|
||||
persistent-hint
|
||||
type="password"
|
||||
prepend-inner-icon="mdi-key-variant"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="SystemSettings.Advanced.MUSIXMATCH_BASE_URL"
|
||||
:label="t('setting.system.musixmatchBaseUrl')"
|
||||
:hint="t('setting.system.musixmatchBaseUrlHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-api"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model.number="SystemSettings.Advanced.LYRICS_BATCH_TIMEOUT"
|
||||
:label="t('setting.system.lyricsBatchTimeout')"
|
||||
:hint="t('setting.system.lyricsBatchTimeoutHint')"
|
||||
persistent-hint
|
||||
min="0"
|
||||
type="number"
|
||||
:suffix="t('setting.system.secondUnit')"
|
||||
prepend-inner-icon="mdi-timer-outline"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model.number="SystemSettings.Advanced.LYRICS_PROVIDER_RETRY_MAX_WAIT"
|
||||
:label="t('setting.system.lyricsRetryMaxWait')"
|
||||
:hint="t('setting.system.lyricsRetryMaxWaitHint')"
|
||||
persistent-hint
|
||||
min="0"
|
||||
type="number"
|
||||
:suffix="t('setting.system.secondUnit')"
|
||||
prepend-inner-icon="mdi-timer-sand"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VCombobox
|
||||
v-model="SystemSettings.Advanced.TMDB_IMAGE_DOMAIN"
|
||||
@@ -2284,6 +2355,10 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
||||
<VIcon icon="mdi-file-replace" color="primary" class="mr-2" />
|
||||
<span>{{ t('setting.system.policy.overwriteDesc') }}</span>
|
||||
</div>
|
||||
<div class="d-flex align-center">
|
||||
<VIcon icon="mdi-arrow-up-bold-hexagon-outline" color="info" class="mr-2" />
|
||||
<span>{{ t('setting.system.policy.upgradeDesc') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</VTooltip>
|
||||
</VExpansionPanelTitle>
|
||||
@@ -2313,6 +2388,9 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
||||
<VBtn value="overwrite" color="primary" size="small">
|
||||
<VIcon icon="mdi-file-replace" />
|
||||
</VBtn>
|
||||
<VBtn v-if="item.key === 'music_lyrics'" value="upgrade" color="info" size="small">
|
||||
<VIcon icon="mdi-arrow-up-bold-hexagon-outline" />
|
||||
</VBtn>
|
||||
</VBtnToggle>
|
||||
<span class="ml-2">{{ t(item.label) }}</span>
|
||||
</div>
|
||||
|
||||
@@ -958,7 +958,12 @@ describe('AccountSettingSystem', () => {
|
||||
}),
|
||||
)
|
||||
expect(findPost('system/setting/ScrapingSwitchs')?.[1]).toEqual(
|
||||
expect.objectContaining({ movie_backdrop: 'overwrite', movie_nfo: 'missingOnly', movie_poster: 'skip' }),
|
||||
expect.objectContaining({
|
||||
movie_backdrop: 'overwrite',
|
||||
movie_nfo: 'missingOnly',
|
||||
movie_poster: 'skip',
|
||||
music_lyrics: 'upgrade',
|
||||
}),
|
||||
)
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('高级设置保存成功')
|
||||
})
|
||||
@@ -1007,6 +1012,12 @@ describe('AccountSettingSystem', () => {
|
||||
await fireEvent.update(dialog.getByLabelText('TMDB API服务地址'), 'api.tmdb.org')
|
||||
await fireEvent.update(dialog.getByLabelText('TMDB API Key'), 'tmdb-key')
|
||||
await fireEvent.update(dialog.getByLabelText('AcoustID API Key'), 'acoustid-key')
|
||||
await fireEvent.update(dialog.getByLabelText('TheAudioDB API Key'), 'audiodb-key')
|
||||
await fireEvent.update(dialog.getByLabelText('LRCLIB 服务地址'), 'https://lyrics.example')
|
||||
await fireEvent.update(dialog.getByLabelText('Musixmatch API Key'), 'musixmatch-key')
|
||||
await fireEvent.update(dialog.getByLabelText('Musixmatch API 地址'), 'https://musixmatch.example/ws/1.1')
|
||||
await fireEvent.update(dialog.getByLabelText('歌词批次查询预算'), '90')
|
||||
await fireEvent.update(dialog.getByLabelText('歌词来源最大重试等待'), '3')
|
||||
await fireEvent.update(dialog.getByLabelText('TMDB 图片服务地址'), 'image.tmdb.org')
|
||||
await fireEvent.update(dialog.getByLabelText('音乐封面代理地址'), 'https://music.example')
|
||||
await selectOption('TMDB 元数据语言', '繁体中文')
|
||||
@@ -1037,8 +1048,13 @@ describe('AccountSettingSystem', () => {
|
||||
FANART_LANG: 'zh,ja',
|
||||
MEDIA_RECOGNIZE_SHARE: false,
|
||||
META_CACHE_EXPIRE: '48',
|
||||
LRCLIB_BASE_URL: 'https://lyrics.example',
|
||||
LYRICS_BATCH_TIMEOUT: 90,
|
||||
LYRICS_PROVIDER_RETRY_MAX_WAIT: 3,
|
||||
MUSIC_COVER_PROXY: 'https://music.example',
|
||||
MUSIC_METADATA_TO_SIMPLIFIED: false,
|
||||
MUSIXMATCH_API_KEY: 'musixmatch-key',
|
||||
MUSIXMATCH_BASE_URL: 'https://musixmatch.example/ws/1.1',
|
||||
RECOGNIZE_PLUGIN_FIRST: true,
|
||||
SCRAP_FOLLOW_TMDB: false,
|
||||
TMDB_API_DOMAIN: 'api.tmdb.org',
|
||||
@@ -1046,6 +1062,7 @@ describe('AccountSettingSystem', () => {
|
||||
TMDB_IMAGE_DOMAIN: 'image.tmdb.org',
|
||||
TMDB_LOCALE: 'zh-TW',
|
||||
TMDB_SCRAP_ORIGINAL_IMAGE: true,
|
||||
THEAUDIODB_API_KEY: 'audiodb-key',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user