feat(music): 完善歌词设置与专辑整理

This commit is contained in:
jxxghp
2026-08-25 21:02:36 +08:00
parent dfa15ab489
commit bb25732cfc
8 changed files with 175 additions and 11 deletions
+9 -6
View File
@@ -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)
+17
View File
@@ -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',
+15
View File
@@ -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编号',
+16
View File
@@ -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編號',
+80 -2
View File
@@ -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',
}),
)
})