diff --git a/src/components/dialog/ReorganizeDialog.vue b/src/components/dialog/ReorganizeDialog.vue index 617d578b..d3f65936 100644 --- a/src/components/dialog/ReorganizeDialog.vue +++ b/src/components/dialog/ReorganizeDialog.vue @@ -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" /> - + { await selectOption('类型', 3) await waitFor(() => expect(screen.getByLabelText('MusicBrainz ID')).toBeInTheDocument()) - await selectOption('音乐实体', 1) + expect(screen.getByLabelText('音乐实体')).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('音乐实体')).toHaveDisplayValue('单曲')) + }) + it('recommends an episode format and includes it in the next request', async () => { const recommendationBodies: unknown[] = [] const transferBodies: unknown[] = [] diff --git a/src/components/filebrowser/__tests__/FileList.spec.ts b/src/components/filebrowser/__tests__/FileList.spec.ts index f77f87e9..5e98aaa2 100644 --- a/src/components/filebrowser/__tests__/FileList.spec.ts +++ b/src/components/filebrowser/__tests__/FileList.spec.ts @@ -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) diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts index 1e0f0530..97793dd2 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -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', diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index a33d02cb..2bcad386 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -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编号', diff --git a/src/locales/zh-TW.ts b/src/locales/zh-TW.ts index ed810c34..043e2f2f 100644 --- a/src/locales/zh-TW.ts +++ b/src/locales/zh-TW.ts @@ -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編號', diff --git a/src/views/setting/AccountSettingSystem.vue b/src/views/setting/AccountSettingSystem.vue index c4bc1232..ebb23c33 100644 --- a/src/views/setting/AccountSettingSystem.vue +++ b/src/views/setting/AccountSettingSystem.vue @@ -114,6 +114,12 @@ const SystemSettings = ref({ 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>( - 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" /> + + + + + + + + + + + + + + + + + + { {{ t('setting.system.policy.overwriteDesc') }} +
+ + {{ t('setting.system.policy.upgradeDesc') }} +
@@ -2313,6 +2388,9 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => { + + + {{ t(item.label) }} diff --git a/src/views/setting/__tests__/AccountSettingSystem.spec.ts b/src/views/setting/__tests__/AccountSettingSystem.spec.ts index f79de309..d0348891 100644 --- a/src/views/setting/__tests__/AccountSettingSystem.spec.ts +++ b/src/views/setting/__tests__/AccountSettingSystem.spec.ts @@ -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', }), ) })