From a62e6d256e5e28cb1e6fa2b452a9bcb579b9be0e Mon Sep 17 00:00:00 2001 From: alger Date: Fri, 6 Mar 2026 19:56:01 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E9=87=8D=E6=9E=84=E9=9F=B3?= =?UTF-8?q?=E4=B9=90=E5=92=8C=E6=AD=8C=E8=AF=8D=E7=BC=93=E5=AD=98=E9=80=BB?= =?UTF-8?q?=E8=BE=91=20=E5=8F=AF=E9=85=8D=E7=BD=AE=E7=BC=93=E5=AD=98?= =?UTF-8?q?=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/i18n/lang/en-US/common.ts | 5 +- src/i18n/lang/en-US/download.ts | 7 +- src/i18n/lang/en-US/favorite.ts | 4 - src/i18n/lang/en-US/history.ts | 1 - src/i18n/lang/en-US/player.ts | 5 +- src/i18n/lang/en-US/podcast.ts | 4 + src/i18n/lang/en-US/settings.ts | 39 +- src/i18n/lang/ja-JP/common.ts | 1 + src/i18n/lang/ja-JP/download.ts | 3 +- src/i18n/lang/ja-JP/podcast.ts | 4 + src/i18n/lang/ja-JP/settings.ts | 39 +- src/i18n/lang/ko-KR/common.ts | 1 + src/i18n/lang/ko-KR/download.ts | 3 +- src/i18n/lang/ko-KR/podcast.ts | 4 + src/i18n/lang/ko-KR/settings.ts | 39 +- src/i18n/lang/zh-CN/common.ts | 1 + src/i18n/lang/zh-CN/download.ts | 3 +- src/i18n/lang/zh-CN/podcast.ts | 4 + src/i18n/lang/zh-CN/settings.ts | 38 +- src/i18n/lang/zh-Hant/common.ts | 1 + src/i18n/lang/zh-Hant/download.ts | 3 +- src/i18n/lang/zh-Hant/podcast.ts | 4 + src/i18n/lang/zh-Hant/settings.ts | 39 +- src/main/modules/cache.ts | 1086 ++++++++++++++++- src/main/modules/config.ts | 17 + src/main/set.json | 6 +- src/renderer/components/common/AlbumItem.vue | 2 +- .../components/common/MobileUpdateModal.vue | 6 +- .../player/MobilePlayerSettings.vue | 6 +- .../settings/MusicSourceSettings.vue | 2 +- src/renderer/hooks/usePlayerHooks.ts | 88 +- .../home/components/HomeDailyRecommend.vue | 2 +- .../views/mobile-search-result/index.vue | 2 +- src/renderer/views/podcast/index.vue | 2 +- src/renderer/views/search/SearchResult.vue | 2 +- src/renderer/views/set/index.vue | 425 ++++++- src/renderer/views/user/followers.vue | 2 +- src/renderer/views/user/follows.vue | 2 +- 38 files changed, 1808 insertions(+), 94 deletions(-) diff --git a/src/i18n/lang/en-US/common.ts b/src/i18n/lang/en-US/common.ts index 8efc5fe..98e9232 100644 --- a/src/i18n/lang/en-US/common.ts +++ b/src/i18n/lang/en-US/common.ts @@ -29,6 +29,7 @@ export default { retry: 'Retry', reset: 'Reset', loadFailed: 'Load Failed', + noData: 'No data', back: 'Back', copySuccess: 'Copied to clipboard', copyFailed: 'Copy failed', @@ -36,9 +37,7 @@ export default { required: 'This field is required', invalidInput: 'Invalid input', selectRequired: 'Please select an option', - numberRange: 'Please enter a number between {min} and {max}', - ipAddress: 'Please enter a valid IP address', - portNumber: 'Please enter a valid port number (1-65535)' + numberRange: 'Please enter a number between {min} and {max}' }, viewMore: 'View More', noMore: 'No more', diff --git a/src/i18n/lang/en-US/download.ts b/src/i18n/lang/en-US/download.ts index 436c360..c133192 100644 --- a/src/i18n/lang/en-US/download.ts +++ b/src/i18n/lang/en-US/download.ts @@ -16,7 +16,6 @@ export default { progress: { total: 'Total Progress: {progress}%' }, - items: 'items', status: { downloading: 'Downloading', completed: 'Completed', @@ -42,12 +41,12 @@ export default { 'Are you sure you want to clear all download records? This will not delete the actual music files, but will clear all records.', confirm: 'Clear', cancel: 'Cancel', - success: 'Download records cleared' + success: 'Download records cleared', + failed: 'Failed to clear download records' }, message: { downloadComplete: '{filename} download completed', - downloadFailed: '{filename} download failed: {error}', - alreadyDownloading: '{filename} is already downloading' + downloadFailed: '{filename} download failed: {error}' }, loading: 'Loading...', playStarted: 'Play started: {name}', diff --git a/src/i18n/lang/en-US/favorite.ts b/src/i18n/lang/en-US/favorite.ts index 8f0d6fe..b186ad6 100644 --- a/src/i18n/lang/en-US/favorite.ts +++ b/src/i18n/lang/en-US/favorite.ts @@ -2,12 +2,8 @@ export default { title: 'Favorites', count: 'Total {count}', batchDownload: 'Batch Download', - selectAll: 'All', download: 'Download ({count})', - cancel: 'Cancel', emptyTip: 'No favorite songs yet', - viewMore: 'View More', - noMore: 'No more', downloadSuccess: 'Download completed', downloadFailed: 'Download failed', downloading: 'Downloading, please wait...', diff --git a/src/i18n/lang/en-US/history.ts b/src/i18n/lang/en-US/history.ts index cbfc230..7cf66db 100644 --- a/src/i18n/lang/en-US/history.ts +++ b/src/i18n/lang/en-US/history.ts @@ -23,7 +23,6 @@ export default { merging: 'Merging records...', noDescription: 'No description', noData: 'No records', - newKey: 'New translation', heatmap: { title: 'Play Heatmap', loading: 'Loading data...', diff --git a/src/i18n/lang/en-US/player.ts b/src/i18n/lang/en-US/player.ts index a7841b6..17894a3 100644 --- a/src/i18n/lang/en-US/player.ts +++ b/src/i18n/lang/en-US/player.ts @@ -131,10 +131,7 @@ export default { timerEnded: 'Sleep timer ended', playbackStopped: 'Music playback stopped', minutesRemaining: '{minutes} min remaining', - songsRemaining: '{count} songs remaining', - activeTime: 'Timer Active', - activeSongs: 'Counting Songs', - activeEnd: 'End After List' + songsRemaining: '{count} songs remaining' }, playList: { clearAll: 'Clear Playlist', diff --git a/src/i18n/lang/en-US/podcast.ts b/src/i18n/lang/en-US/podcast.ts index 5ecdac1..ae01f71 100644 --- a/src/i18n/lang/en-US/podcast.ts +++ b/src/i18n/lang/en-US/podcast.ts @@ -12,6 +12,10 @@ export default { subscribe: 'Subscribe', subscribed: 'Subscribed', unsubscribe: 'Unsubscribe', + unsubscribed: 'Unsubscribed', + subscribeSuccess: 'Subscribed successfully', + unsubscribeFailed: 'Failed to unsubscribe', + subscribeFailed: 'Failed to subscribe', radioDetail: 'Radio Detail', programList: 'Episodes', playProgram: 'Play', diff --git a/src/i18n/lang/en-US/settings.ts b/src/i18n/lang/en-US/settings.ts index 8da679b..cba54f5 100644 --- a/src/i18n/lang/en-US/settings.ts +++ b/src/i18n/lang/en-US/settings.ts @@ -196,6 +196,36 @@ export default { system: { cache: 'Cache Management', cacheDesc: 'Clear cache', + diskCache: 'Disk Cache', + diskCacheDesc: 'Cache played music and lyrics on local disk to speed up repeated playback', + cacheDirectory: 'Cache Directory', + cacheDirectoryDesc: 'Custom directory for music and lyric cache files', + selectDirectory: 'Select Directory', + openDirectory: 'Open Directory', + cacheMaxSize: 'Cache Size Limit', + cacheMaxSizeDesc: 'Older cache items are cleaned automatically when limit is reached', + cleanupPolicy: 'Cleanup Policy', + cleanupPolicyDesc: 'Auto cleanup rule when cache reaches the size limit', + cleanupPolicyOptions: { + lru: 'Least Recently Used', + fifo: 'First In, First Out' + }, + cacheStatus: 'Cache Status', + cacheStatusDesc: 'Used {used} / Limit {limit}', + cacheStatusDetail: 'Music {musicCount}, Lyrics {lyricCount}', + manageDiskCache: 'Manual Disk Cache Cleanup', + manageDiskCacheDesc: 'Clean cache by category', + clearMusicCache: 'Clear Music Cache', + clearLyricCache: 'Clear Lyric Cache', + clearAllCache: 'Clear All Cache', + switchDirectoryMigrateTitle: 'Existing Cache Detected', + switchDirectoryMigrateContent: 'Do you want to migrate old cache files to the new directory?', + switchDirectoryMigrateConfirm: 'Migrate', + switchDirectoryDestroyTitle: 'Destroy Old Cache', + switchDirectoryDestroyContent: + 'If you do not migrate, do you want to destroy old cache files in the previous directory?', + switchDirectoryDestroyConfirm: 'Destroy', + switchDirectoryKeepOld: 'Keep Old Cache', cacheClearTitle: 'Select cache types to clear:', cacheTypes: { history: { @@ -230,7 +260,14 @@ export default { restart: 'Restart', restartDesc: 'Restart application', messages: { - clearSuccess: 'Cache cleared successfully, some settings will take effect after restart' + clearSuccess: 'Cache cleared successfully, some settings will take effect after restart', + diskCacheClearSuccess: 'Disk cache cleaned', + diskCacheClearFailed: 'Failed to clean disk cache', + diskCacheStatsLoadFailed: 'Failed to load cache status', + switchDirectorySuccess: 'Cache directory switched, old cache is kept', + switchDirectoryFailed: 'Failed to switch cache directory', + switchDirectoryMigrated: 'Cache directory switched, migrated {count} cache files', + switchDirectoryDestroyed: 'Cache directory switched, destroyed {count} old cache files' } }, about: { diff --git a/src/i18n/lang/ja-JP/common.ts b/src/i18n/lang/ja-JP/common.ts index 0b920a7..83ec5b5 100644 --- a/src/i18n/lang/ja-JP/common.ts +++ b/src/i18n/lang/ja-JP/common.ts @@ -29,6 +29,7 @@ export default { retry: '再試行', reset: 'リセット', loadFailed: '読み込みに失敗しました', + noData: 'データがありません', back: '戻る', copySuccess: 'クリップボードにコピーしました', copyFailed: 'コピーに失敗しました', diff --git a/src/i18n/lang/ja-JP/download.ts b/src/i18n/lang/ja-JP/download.ts index d64d9e9..710cca9 100644 --- a/src/i18n/lang/ja-JP/download.ts +++ b/src/i18n/lang/ja-JP/download.ts @@ -41,7 +41,8 @@ export default { 'すべてのダウンロード記録をクリアしますか?この操作はダウンロード済みの音楽ファイルを削除しませんが、すべての記録をクリアします。', confirm: 'クリア確認', cancel: 'キャンセル', - success: 'ダウンロード記録をクリアしました' + success: 'ダウンロード記録をクリアしました', + failed: 'ダウンロード記録のクリアに失敗しました' }, message: { downloadComplete: '{filename}のダウンロードが完了しました', diff --git a/src/i18n/lang/ja-JP/podcast.ts b/src/i18n/lang/ja-JP/podcast.ts index 7429ff9..4f72259 100644 --- a/src/i18n/lang/ja-JP/podcast.ts +++ b/src/i18n/lang/ja-JP/podcast.ts @@ -12,6 +12,10 @@ export default { subscribe: '購読', subscribed: '購読中', unsubscribe: '購読解除', + unsubscribed: '購読を解除しました', + subscribeSuccess: '購読しました', + unsubscribeFailed: '購読解除に失敗しました', + subscribeFailed: '購読に失敗しました', radioDetail: 'ラジオ詳細', programList: 'エピソード一覧', playProgram: '再生', diff --git a/src/i18n/lang/ja-JP/settings.ts b/src/i18n/lang/ja-JP/settings.ts index eb512bb..d267b07 100644 --- a/src/i18n/lang/ja-JP/settings.ts +++ b/src/i18n/lang/ja-JP/settings.ts @@ -195,6 +195,35 @@ export default { system: { cache: 'キャッシュ管理', cacheDesc: 'キャッシュをクリア', + diskCache: 'ディスクキャッシュ', + diskCacheDesc: '再生した音楽と歌詞をローカルディスクへ保存し、再生速度を向上します', + cacheDirectory: 'キャッシュディレクトリ', + cacheDirectoryDesc: '音楽・歌詞キャッシュの保存先を指定', + selectDirectory: 'ディレクトリ選択', + openDirectory: 'ディレクトリを開く', + cacheMaxSize: 'キャッシュ上限', + cacheMaxSizeDesc: '上限に達すると古いキャッシュを自動削除します', + cleanupPolicy: 'クリーンアップポリシー', + cleanupPolicyDesc: 'キャッシュ上限到達時の自動削除ルール', + cleanupPolicyOptions: { + lru: '最近未使用優先', + fifo: '先入れ先出し' + }, + cacheStatus: 'キャッシュ状態', + cacheStatusDesc: '使用量 {used} / 上限 {limit}', + cacheStatusDetail: '音楽 {musicCount} 曲、歌詞 {lyricCount} 曲', + manageDiskCache: '手動キャッシュクリア', + manageDiskCacheDesc: '種類ごとにキャッシュを削除', + clearMusicCache: '音楽キャッシュを削除', + clearLyricCache: '歌詞キャッシュを削除', + clearAllCache: 'すべて削除', + switchDirectoryMigrateTitle: '既存キャッシュを検出', + switchDirectoryMigrateContent: '旧ディレクトリのキャッシュを新ディレクトリへ移行しますか?', + switchDirectoryMigrateConfirm: '移行する', + switchDirectoryDestroyTitle: '旧キャッシュを削除', + switchDirectoryDestroyContent: '移行しない場合、旧ディレクトリのキャッシュを削除しますか?', + switchDirectoryDestroyConfirm: '削除する', + switchDirectoryKeepOld: '旧キャッシュを保持', cacheClearTitle: 'クリアするキャッシュタイプを選択してください:', cacheTypes: { history: { @@ -229,7 +258,15 @@ export default { restart: '再起動', restartDesc: 'アプリを再起動', messages: { - clearSuccess: 'クリア成功。一部の設定は再起動後に有効になります' + clearSuccess: 'クリア成功。一部の設定は再起動後に有効になります', + diskCacheClearSuccess: 'ディスクキャッシュを削除しました', + diskCacheClearFailed: 'ディスクキャッシュの削除に失敗しました', + diskCacheStatsLoadFailed: 'キャッシュ状態の取得に失敗しました', + switchDirectorySuccess: 'キャッシュディレクトリを切り替えました(旧キャッシュは保持)', + switchDirectoryFailed: 'キャッシュディレクトリの切り替えに失敗しました', + switchDirectoryMigrated: 'キャッシュディレクトリを切り替え、{count} 件を移行しました', + switchDirectoryDestroyed: + 'キャッシュディレクトリを切り替え、旧キャッシュ {count} 件を削除しました' } }, about: { diff --git a/src/i18n/lang/ko-KR/common.ts b/src/i18n/lang/ko-KR/common.ts index d056b62..46f62c7 100644 --- a/src/i18n/lang/ko-KR/common.ts +++ b/src/i18n/lang/ko-KR/common.ts @@ -29,6 +29,7 @@ export default { retry: '다시 시도', reset: '재설정', loadFailed: '로드 실패', + noData: '데이터 없음', back: '뒤로', copySuccess: '클립보드에 복사됨', copyFailed: '복사 실패', diff --git a/src/i18n/lang/ko-KR/download.ts b/src/i18n/lang/ko-KR/download.ts index ed0f1ec..9bf8ad8 100644 --- a/src/i18n/lang/ko-KR/download.ts +++ b/src/i18n/lang/ko-KR/download.ts @@ -41,7 +41,8 @@ export default { '모든 다운로드 기록을 지우시겠습니까? 이 작업은 다운로드된 음악 파일을 삭제하지 않지만 모든 기록을 지웁니다.', confirm: '지우기 확인', cancel: '취소', - success: '다운로드 기록이 지워졌습니다' + success: '다운로드 기록이 지워졌습니다', + failed: '다운로드 기록 삭제에 실패했습니다' }, message: { downloadComplete: '{filename} 다운로드 완료', diff --git a/src/i18n/lang/ko-KR/podcast.ts b/src/i18n/lang/ko-KR/podcast.ts index 60cb39b..8a1d6ba 100644 --- a/src/i18n/lang/ko-KR/podcast.ts +++ b/src/i18n/lang/ko-KR/podcast.ts @@ -12,6 +12,10 @@ export default { subscribe: '구독', subscribed: '구독 중', unsubscribe: '구독 취소', + unsubscribed: '구독이 취소되었습니다', + subscribeSuccess: '구독되었습니다', + unsubscribeFailed: '구독 취소에 실패했습니다', + subscribeFailed: '구독에 실패했습니다', radioDetail: '라디오 상세', programList: '에피소드 목록', playProgram: '재생', diff --git a/src/i18n/lang/ko-KR/settings.ts b/src/i18n/lang/ko-KR/settings.ts index 8f2d537..5b209bc 100644 --- a/src/i18n/lang/ko-KR/settings.ts +++ b/src/i18n/lang/ko-KR/settings.ts @@ -196,6 +196,36 @@ export default { system: { cache: '캐시 관리', cacheDesc: '캐시 지우기', + diskCache: '디스크 캐시', + diskCacheDesc: '재생한 음악과 가사를 로컬 디스크에 캐시하여 재생 속도를 높입니다', + cacheDirectory: '캐시 디렉터리', + cacheDirectoryDesc: '음악 및 가사 캐시 저장 경로를 사용자 지정', + selectDirectory: '디렉터리 선택', + openDirectory: '디렉터리 열기', + cacheMaxSize: '캐시 용량 제한', + cacheMaxSizeDesc: '용량 제한 도달 시 오래된 캐시를 자동 정리합니다', + cleanupPolicy: '정리 정책', + cleanupPolicyDesc: '캐시 용량 제한 도달 시 적용할 자동 정리 규칙', + cleanupPolicyOptions: { + lru: '최근 사용 안 함 우선', + fifo: '선입선출' + }, + cacheStatus: '캐시 상태', + cacheStatusDesc: '사용량 {used} / 제한 {limit}', + cacheStatusDetail: '음악 {musicCount}곡, 가사 {lyricCount}곡', + manageDiskCache: '수동 디스크 캐시 정리', + manageDiskCacheDesc: '캐시 유형별로 정리', + clearMusicCache: '음악 캐시 정리', + clearLyricCache: '가사 캐시 정리', + clearAllCache: '전체 캐시 정리', + switchDirectoryMigrateTitle: '기존 캐시가 감지되었습니다', + switchDirectoryMigrateContent: '기존 캐시를 새 디렉터리로 마이그레이션할까요?', + switchDirectoryMigrateConfirm: '마이그레이션', + switchDirectoryDestroyTitle: '기존 캐시 삭제', + switchDirectoryDestroyContent: + '마이그레이션하지 않을 경우, 이전 디렉터리의 캐시 파일을 삭제할까요?', + switchDirectoryDestroyConfirm: '삭제', + switchDirectoryKeepOld: '기존 캐시 유지', cacheClearTitle: '지울 캐시 유형을 선택하세요:', cacheTypes: { history: { @@ -230,7 +260,14 @@ export default { restart: '재시작', restartDesc: '앱 재시작', messages: { - clearSuccess: '지우기 성공, 일부 설정은 재시작 후 적용됩니다' + clearSuccess: '지우기 성공, 일부 설정은 재시작 후 적용됩니다', + diskCacheClearSuccess: '디스크 캐시를 정리했습니다', + diskCacheClearFailed: '디스크 캐시 정리에 실패했습니다', + diskCacheStatsLoadFailed: '캐시 상태를 불러오지 못했습니다', + switchDirectorySuccess: '캐시 디렉터리가 변경되었습니다. 기존 캐시는 유지됩니다', + switchDirectoryFailed: '캐시 디렉터리 변경에 실패했습니다', + switchDirectoryMigrated: '캐시 디렉터리를 변경하고 {count}개 파일을 마이그레이션했습니다', + switchDirectoryDestroyed: '캐시 디렉터리를 변경하고 기존 캐시 {count}개 파일을 삭제했습니다' } }, about: { diff --git a/src/i18n/lang/zh-CN/common.ts b/src/i18n/lang/zh-CN/common.ts index 92de48e..29aca49 100644 --- a/src/i18n/lang/zh-CN/common.ts +++ b/src/i18n/lang/zh-CN/common.ts @@ -29,6 +29,7 @@ export default { retry: '重试', reset: '重置', loadFailed: '加载失败', + noData: '暂无数据', back: '返回', copySuccess: '已复制到剪贴板', copyFailed: '复制失败', diff --git a/src/i18n/lang/zh-CN/download.ts b/src/i18n/lang/zh-CN/download.ts index 9cc4532..81ecead 100644 --- a/src/i18n/lang/zh-CN/download.ts +++ b/src/i18n/lang/zh-CN/download.ts @@ -40,7 +40,8 @@ export default { message: '确定要清空所有下载记录吗?此操作不会删除已下载的音乐文件,但将清空所有记录。', confirm: '确定清空', cancel: '取消', - success: '下载记录已清空' + success: '下载记录已清空', + failed: '清空下载记录失败' }, message: { downloadComplete: '{filename} 下载完成', diff --git a/src/i18n/lang/zh-CN/podcast.ts b/src/i18n/lang/zh-CN/podcast.ts index 3d5797f..90ac01a 100644 --- a/src/i18n/lang/zh-CN/podcast.ts +++ b/src/i18n/lang/zh-CN/podcast.ts @@ -12,6 +12,10 @@ export default { subscribe: '订阅', subscribed: '已订阅', unsubscribe: '取消订阅', + unsubscribed: '已取消订阅', + subscribeSuccess: '订阅成功', + unsubscribeFailed: '取消订阅失败', + subscribeFailed: '订阅失败', radioDetail: '电台详情', programList: '节目列表', playProgram: '播放节目', diff --git a/src/i18n/lang/zh-CN/settings.ts b/src/i18n/lang/zh-CN/settings.ts index 8d3c1c5..a9734fe 100644 --- a/src/i18n/lang/zh-CN/settings.ts +++ b/src/i18n/lang/zh-CN/settings.ts @@ -193,6 +193,35 @@ export default { system: { cache: '缓存管理', cacheDesc: '清除缓存', + diskCache: '磁盘缓存', + diskCacheDesc: '将播放过的音乐与歌词缓存到本地磁盘,提升二次播放速度', + cacheDirectory: '缓存目录', + cacheDirectoryDesc: '自定义音乐与歌词缓存保存目录', + selectDirectory: '选择目录', + openDirectory: '打开目录', + cacheMaxSize: '缓存上限', + cacheMaxSizeDesc: '达到上限后将自动清理最旧缓存', + cleanupPolicy: '清理策略', + cleanupPolicyDesc: '达到缓存上限时的自动清理规则', + cleanupPolicyOptions: { + lru: '最近最少使用', + fifo: '先进先出' + }, + cacheStatus: '缓存状态', + cacheStatusDesc: '已用 {used} / 上限 {limit}', + cacheStatusDetail: '音乐 {musicCount} 首,歌词 {lyricCount} 首', + manageDiskCache: '手动清理磁盘缓存', + manageDiskCacheDesc: '按缓存类型进行清理', + clearMusicCache: '清理音乐缓存', + clearLyricCache: '清理歌词缓存', + clearAllCache: '清理全部缓存', + switchDirectoryMigrateTitle: '检测到已有缓存', + switchDirectoryMigrateContent: '是否将旧目录缓存迁移到新目录?', + switchDirectoryMigrateConfirm: '迁移', + switchDirectoryDestroyTitle: '是否销毁旧缓存', + switchDirectoryDestroyContent: '不迁移时,是否销毁旧目录缓存文件?', + switchDirectoryDestroyConfirm: '销毁', + switchDirectoryKeepOld: '保留旧缓存', cacheClearTitle: '请选择要清除的缓存类型:', cacheTypes: { history: { @@ -227,7 +256,14 @@ export default { restart: '重启', restartDesc: '重启应用', messages: { - clearSuccess: '清除成功,部分设置在重启后生效' + clearSuccess: '清除成功,部分设置在重启后生效', + diskCacheClearSuccess: '磁盘缓存已清理', + diskCacheClearFailed: '清理磁盘缓存失败', + diskCacheStatsLoadFailed: '读取缓存状态失败', + switchDirectorySuccess: '缓存目录已切换,旧缓存已保留', + switchDirectoryFailed: '缓存目录切换失败', + switchDirectoryMigrated: '缓存目录已切换,已迁移 {count} 个缓存文件', + switchDirectoryDestroyed: '缓存目录已切换,已销毁 {count} 个旧缓存文件' } }, about: { diff --git a/src/i18n/lang/zh-Hant/common.ts b/src/i18n/lang/zh-Hant/common.ts index f1bf0b4..12d820e 100644 --- a/src/i18n/lang/zh-Hant/common.ts +++ b/src/i18n/lang/zh-Hant/common.ts @@ -29,6 +29,7 @@ export default { retry: '重試', reset: '重設', loadFailed: '載入失敗', + noData: '暫無資料', back: '返回', copySuccess: '已複製到剪貼簿', copyFailed: '複製失敗', diff --git a/src/i18n/lang/zh-Hant/download.ts b/src/i18n/lang/zh-Hant/download.ts index e6930cc..eb63dc4 100644 --- a/src/i18n/lang/zh-Hant/download.ts +++ b/src/i18n/lang/zh-Hant/download.ts @@ -40,7 +40,8 @@ export default { message: '確定要清空所有下載記錄嗎?此操作不會刪除已下載的音樂檔案,但將清空所有記錄。', confirm: '確定清空', cancel: '取消', - success: '下載記錄已清空' + success: '下載記錄已清空', + failed: '清空下載記錄失敗' }, message: { downloadComplete: '{filename} 下載完成', diff --git a/src/i18n/lang/zh-Hant/podcast.ts b/src/i18n/lang/zh-Hant/podcast.ts index 886801c..42c9e6d 100644 --- a/src/i18n/lang/zh-Hant/podcast.ts +++ b/src/i18n/lang/zh-Hant/podcast.ts @@ -12,6 +12,10 @@ export default { subscribe: '訂閱', subscribed: '已訂閱', unsubscribe: '取消訂閱', + unsubscribed: '已取消訂閱', + subscribeSuccess: '訂閱成功', + unsubscribeFailed: '取消訂閱失敗', + subscribeFailed: '訂閱失敗', radioDetail: '電台詳情', programList: '節目列表', playProgram: '播放節目', diff --git a/src/i18n/lang/zh-Hant/settings.ts b/src/i18n/lang/zh-Hant/settings.ts index 8dd857d..2f2cae0 100644 --- a/src/i18n/lang/zh-Hant/settings.ts +++ b/src/i18n/lang/zh-Hant/settings.ts @@ -25,7 +25,6 @@ export default { tokenSet: '已設定', tokenNotSet: '未設定', setToken: '設定Cookie', - setCookie: '設定Cookie', modifyToken: '修改Cookie', clearToken: '清除Cookie', font: '字體設定', @@ -190,6 +189,35 @@ export default { system: { cache: '快取管理', cacheDesc: '清除快取', + diskCache: '磁碟快取', + diskCacheDesc: '將播放過的音樂與歌詞快取到本機磁碟,加速二次播放', + cacheDirectory: '快取目錄', + cacheDirectoryDesc: '自訂音樂與歌詞快取儲存位置', + selectDirectory: '選擇目錄', + openDirectory: '開啟目錄', + cacheMaxSize: '快取上限', + cacheMaxSizeDesc: '達到上限時會自動清理較舊快取', + cleanupPolicy: '清理策略', + cleanupPolicyDesc: '快取達到上限時的自動清理規則', + cleanupPolicyOptions: { + lru: '最近最少使用', + fifo: '先進先出' + }, + cacheStatus: '快取狀態', + cacheStatusDesc: '已用 {used} / 上限 {limit}', + cacheStatusDetail: '音樂 {musicCount} 首,歌詞 {lyricCount} 首', + manageDiskCache: '手動清理磁碟快取', + manageDiskCacheDesc: '依快取類型進行清理', + clearMusicCache: '清理音樂快取', + clearLyricCache: '清理歌詞快取', + clearAllCache: '清理全部快取', + switchDirectoryMigrateTitle: '偵測到既有快取', + switchDirectoryMigrateContent: '是否將舊目錄快取搬移到新目錄?', + switchDirectoryMigrateConfirm: '搬移', + switchDirectoryDestroyTitle: '是否刪除舊快取', + switchDirectoryDestroyContent: '不搬移時,是否刪除舊目錄的快取檔案?', + switchDirectoryDestroyConfirm: '刪除', + switchDirectoryKeepOld: '保留舊快取', cacheClearTitle: '請選擇要清除的快取類型:', cacheTypes: { history: { @@ -224,7 +252,14 @@ export default { restart: '重新啟動', restartDesc: '重新啟動應用程式', messages: { - clearSuccess: '清除成功,部分設定在重啟後生效' + clearSuccess: '清除成功,部分設定在重啟後生效', + diskCacheClearSuccess: '磁碟快取已清理', + diskCacheClearFailed: '清理磁碟快取失敗', + diskCacheStatsLoadFailed: '讀取快取狀態失敗', + switchDirectorySuccess: '快取目錄已切換,舊快取已保留', + switchDirectoryFailed: '快取目錄切換失敗', + switchDirectoryMigrated: '快取目錄已切換,已搬移 {count} 個快取檔案', + switchDirectoryDestroyed: '快取目錄已切換,已刪除 {count} 個舊快取檔案' } }, about: { diff --git a/src/main/modules/cache.ts b/src/main/modules/cache.ts index 45d0f5c..8e017e5 100644 --- a/src/main/modules/cache.ts +++ b/src/main/modules/cache.ts @@ -1,84 +1,1062 @@ -import { ipcMain } from 'electron'; +import { createHash } from 'node:crypto'; + +import axios from 'axios'; +import { app, ipcMain } from 'electron'; import Store from 'electron-store'; +import * as fs from 'fs'; +import * as path from 'path'; -interface LyricData { - id: number; - data: any; - timestamp: number; -} +import { getStore } from './config'; -interface StoreSchema { - lyrics: Record; -} +type CacheCleanupPolicy = 'lru' | 'fifo'; +type CacheItemType = 'music' | 'lyrics'; +type CacheScope = 'all' | CacheItemType; +type CacheSwitchAction = 'migrate' | 'destroy' | 'keep'; -class CacheManager { - private store: Store; +type DiskCacheConfig = { + enabled: boolean; + directory: string; + maxSizeMB: number; + cleanupPolicy: CacheCleanupPolicy; +}; + +type MusicCacheEntry = { + key: string; + songId: number; + source: string; + filePath: string; + urlHash: string; + size: number; + createdAt: number; + lastAccessAt: number; + playCount: number; + title?: string; + artist?: string; +}; + +type LyricCacheEntry = { + key: string; + songId: number; + filePath: string; + size: number; + createdAt: number; + lastAccessAt: number; + title?: string; + artist?: string; +}; + +type CacheStoreSchema = { + musicEntries: Record; + lyricEntries: Record; +}; + +type ResolveMusicUrlPayload = { + songId: number; + source?: string; + url: string; + title?: string; + artist?: string; +}; + +type ResolveMusicUrlResult = { + url: string; + cached: boolean; + queued: boolean; +}; + +type DiskCacheStats = { + enabled: boolean; + directory: string; + maxSizeMB: number; + cleanupPolicy: CacheCleanupPolicy; + totalSizeBytes: number; + musicSizeBytes: number; + lyricSizeBytes: number; + totalFiles: number; + musicFiles: number; + lyricFiles: number; + usage: number; +}; + +type SwitchCacheDirectoryPayload = { + directory: string; + action?: CacheSwitchAction; +}; + +type SwitchCacheDirectoryResult = { + success: boolean; + config: DiskCacheConfig; + migratedFiles: number; + destroyedFiles: number; +}; + +type CacheEvictionItem = { + type: CacheItemType; + key: string; + filePath: string; + size: number; + createdAt: number; + lastAccessAt: number; +}; + +const DEFAULT_CACHE_MAX_SIZE_MB = 4096; +const MIN_CACHE_SIZE_MB = 256; +const MAX_CACHE_SIZE_MB = 102400; +const DEFAULT_CLEANUP_POLICY: CacheCleanupPolicy = 'lru'; +const CACHE_ROOT_DIR_NAME = 'cache'; +const MUSIC_CACHE_DIR = 'music'; +const LYRIC_CACHE_DIR = 'lyrics'; + +const AUDIO_EXTENSION_BY_CONTENT_TYPE: Record = { + 'audio/mpeg': '.mp3', + 'audio/mp3': '.mp3', + 'audio/mp4': '.m4a', + 'audio/x-m4a': '.m4a', + 'audio/aac': '.aac', + 'audio/flac': '.flac', + 'audio/x-flac': '.flac', + 'audio/wav': '.wav', + 'audio/x-wav': '.wav', + 'audio/ogg': '.ogg', + 'audio/webm': '.webm' +}; + +class DiskCacheManager { + private metadataStore: Store; + + private pendingMusicDownloads = new Map>(); constructor() { - this.store = new Store({ - name: 'lyrics', + this.metadataStore = new Store({ + name: 'disk-cache', defaults: { - lyrics: {} + musicEntries: {}, + lyricEntries: {} } }); } - async cacheLyric(id: number, data: any) { + public initialize(): void { + this.ensureConfigDefaults(); + this.ensureDirectories(); + } + + private getDefaultCacheDirectory(): string { + return path.join(app.getPath('userData'), CACHE_ROOT_DIR_NAME); + } + + private normalizeCacheDirectory(directory: string): string { + const trimmed = directory?.trim(); + if (!trimmed) { + return this.getDefaultCacheDirectory(); + } + if (path.isAbsolute(trimmed)) { + return path.normalize(trimmed); + } + return path.resolve(trimmed); + } + + private normalizeCacheSize(maxSizeMB: number): number { + if (!Number.isFinite(maxSizeMB)) { + return DEFAULT_CACHE_MAX_SIZE_MB; + } + return Math.min(MAX_CACHE_SIZE_MB, Math.max(MIN_CACHE_SIZE_MB, Math.floor(maxSizeMB))); + } + + private ensureConfigDefaults(): void { + const configStore = getStore(); + if (!configStore) return; + + const defaultDirectory = this.getDefaultCacheDirectory(); + + if (configStore.get('set.enableDiskCache') === undefined) { + configStore.set('set.enableDiskCache', true); + } + if (!configStore.get('set.diskCacheDir')) { + configStore.set('set.diskCacheDir', defaultDirectory); + } + if (configStore.get('set.diskCacheMaxSizeMB') === undefined) { + configStore.set('set.diskCacheMaxSizeMB', DEFAULT_CACHE_MAX_SIZE_MB); + } + if (!configStore.get('set.diskCacheCleanupPolicy')) { + configStore.set('set.diskCacheCleanupPolicy', DEFAULT_CLEANUP_POLICY); + } + } + + private saveConfig(config: DiskCacheConfig): void { + const configStore = getStore(); + if (!configStore) return; + + configStore.set('set.enableDiskCache', config.enabled); + configStore.set('set.diskCacheDir', config.directory); + configStore.set('set.diskCacheMaxSizeMB', config.maxSizeMB); + configStore.set('set.diskCacheCleanupPolicy', config.cleanupPolicy); + } + + private getMusicCacheDir(directory: string): string { + return path.join(directory, MUSIC_CACHE_DIR); + } + + private getLyricCacheDir(directory: string): string { + return path.join(directory, LYRIC_CACHE_DIR); + } + + private ensureDirectories(config?: DiskCacheConfig): void { + const currentConfig = config ?? this.getCacheConfig(); try { - const lyrics = this.store.get('lyrics'); - lyrics[id] = { - id, - data, - timestamp: Date.now() + fs.mkdirSync(currentConfig.directory, { recursive: true }); + fs.mkdirSync(this.getMusicCacheDir(currentConfig.directory), { recursive: true }); + fs.mkdirSync(this.getLyricCacheDir(currentConfig.directory), { recursive: true }); + } catch (error) { + console.error('创建缓存目录失败:', error); + } + } + + private isPathInsideDirectory(filePath: string, directory: string): boolean { + const normalizedFile = path.resolve(filePath); + const normalizedDir = path.resolve(directory); + const relativePath = path.relative(normalizedDir, normalizedFile); + return ( + relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)) + ); + } + + private async moveFile(sourcePath: string, targetPath: string): Promise { + try { + await fs.promises.rename(sourcePath, targetPath); + return; + } catch { + await fs.promises.copyFile(sourcePath, targetPath); + await fs.promises.unlink(sourcePath); + } + } + + private generateAvailableFilePath(directory: string, fileName: string): string { + const extension = path.extname(fileName); + const baseName = extension ? fileName.slice(0, -extension.length) : fileName; + let nextPath = path.join(directory, fileName); + let index = 1; + + while (fs.existsSync(nextPath)) { + nextPath = path.join(directory, `${baseName}_${index}${extension}`); + index++; + } + + return nextPath; + } + + private async migrateEntriesToDirectory( + type: CacheItemType, + oldDirectory: string, + newDirectory: string + ): Promise { + let migratedCount = 0; + + if (type === 'music') { + const entries = this.getMusicEntries(); + for (const [key, entry] of Object.entries(entries)) { + if (!this.isPathInsideDirectory(entry.filePath, oldDirectory)) { + continue; + } + + if (!fs.existsSync(entry.filePath)) { + delete entries[key]; + continue; + } + + try { + const targetPath = this.generateAvailableFilePath( + newDirectory, + path.basename(entry.filePath) + ); + await this.moveFile(entry.filePath, targetPath); + const latestSize = fs.statSync(targetPath).size; + entries[key] = { + ...entry, + filePath: targetPath, + size: latestSize + }; + migratedCount++; + } catch (error) { + console.error(`迁移音乐缓存失败: ${key}`, error); + } + } + this.setMusicEntries(entries); + return migratedCount; + } + + const entries = this.getLyricEntries(); + for (const [key, entry] of Object.entries(entries)) { + if (!this.isPathInsideDirectory(entry.filePath, oldDirectory)) { + continue; + } + + if (!fs.existsSync(entry.filePath)) { + delete entries[key]; + continue; + } + + try { + const targetPath = this.generateAvailableFilePath( + newDirectory, + path.basename(entry.filePath) + ); + await this.moveFile(entry.filePath, targetPath); + const latestSize = fs.statSync(targetPath).size; + entries[key] = { + ...entry, + filePath: targetPath, + size: latestSize + }; + migratedCount++; + } catch (error) { + console.error(`迁移歌词缓存失败: ${key}`, error); + } + } + this.setLyricEntries(entries); + + return migratedCount; + } + + private async removeEntriesInDirectory(type: CacheItemType, directory: string): Promise { + let removedCount = 0; + + if (type === 'music') { + const entries = this.getMusicEntries(); + for (const [key, entry] of Object.entries(entries)) { + if (!this.isPathInsideDirectory(entry.filePath, directory)) { + continue; + } + + if (fs.existsSync(entry.filePath)) { + try { + await fs.promises.unlink(entry.filePath); + } catch (error) { + console.error(`删除音乐缓存文件失败: ${entry.filePath}`, error); + } + } + + delete entries[key]; + removedCount++; + } + this.setMusicEntries(entries); + return removedCount; + } + + const entries = this.getLyricEntries(); + for (const [key, entry] of Object.entries(entries)) { + if (!this.isPathInsideDirectory(entry.filePath, directory)) { + continue; + } + + if (fs.existsSync(entry.filePath)) { + try { + await fs.promises.unlink(entry.filePath); + } catch (error) { + console.error(`删除歌词缓存文件失败: ${entry.filePath}`, error); + } + } + + delete entries[key]; + removedCount++; + } + this.setLyricEntries(entries); + + return removedCount; + } + + private async cleanupCacheDirectory(oldDirectory: string): Promise { + const oldMusicDir = this.getMusicCacheDir(oldDirectory); + const oldLyricDir = this.getLyricCacheDir(oldDirectory); + + for (const targetDir of [oldMusicDir, oldLyricDir, oldDirectory]) { + if (!fs.existsSync(targetDir)) { + continue; + } + + try { + const files = await fs.promises.readdir(targetDir); + if (files.length === 0) { + await fs.promises.rmdir(targetDir); + } + } catch (error) { + // 目录不为空或权限不足时忽略 + console.warn(`清理缓存目录失败: ${targetDir}`, error); + } + } + } + + public getCacheConfig(): DiskCacheConfig { + const configStore = getStore(); + const defaultDirectory = this.getDefaultCacheDirectory(); + + const enabled = Boolean(configStore?.get('set.enableDiskCache') ?? true); + const directory = this.normalizeCacheDirectory( + String(configStore?.get('set.diskCacheDir') ?? defaultDirectory) + ); + + const rawMaxSize = Number( + configStore?.get('set.diskCacheMaxSizeMB') ?? DEFAULT_CACHE_MAX_SIZE_MB + ); + const maxSizeMB = this.normalizeCacheSize(rawMaxSize); + + const rawPolicy = String( + configStore?.get('set.diskCacheCleanupPolicy') ?? DEFAULT_CLEANUP_POLICY + ); + const cleanupPolicy: CacheCleanupPolicy = rawPolicy === 'fifo' ? 'fifo' : 'lru'; + + const normalizedConfig: DiskCacheConfig = { + enabled, + directory, + maxSizeMB, + cleanupPolicy + }; + + this.saveConfig(normalizedConfig); + return normalizedConfig; + } + + public async updateCacheConfig(partial: Partial): Promise { + const current = this.getCacheConfig(); + const updated: DiskCacheConfig = { + enabled: partial.enabled ?? current.enabled, + directory: this.normalizeCacheDirectory(partial.directory ?? current.directory), + maxSizeMB: this.normalizeCacheSize(partial.maxSizeMB ?? current.maxSizeMB), + cleanupPolicy: + partial.cleanupPolicy === 'fifo' || partial.cleanupPolicy === 'lru' + ? partial.cleanupPolicy + : current.cleanupPolicy + }; + + this.saveConfig(updated); + this.ensureDirectories(updated); + await this.enforceCacheLimit(); + + return updated; + } + + public async switchCacheDirectory( + payload: SwitchCacheDirectoryPayload + ): Promise { + const currentConfig = this.getCacheConfig(); + const targetDirectory = this.normalizeCacheDirectory(payload.directory); + const action: CacheSwitchAction = + payload.action === 'migrate' || payload.action === 'destroy' || payload.action === 'keep' + ? payload.action + : 'keep'; + + if (targetDirectory === currentConfig.directory) { + return { + success: true, + config: currentConfig, + migratedFiles: 0, + destroyedFiles: 0 }; - this.store.set('lyrics', lyrics); + } + + await this.pruneMissingEntries(); + + const oldDirectory = currentConfig.directory; + const oldMusicDir = this.getMusicCacheDir(oldDirectory); + const oldLyricDir = this.getLyricCacheDir(oldDirectory); + const newMusicDir = this.getMusicCacheDir(targetDirectory); + const newLyricDir = this.getLyricCacheDir(targetDirectory); + + let migratedFiles = 0; + let destroyedFiles = 0; + + try { + fs.mkdirSync(targetDirectory, { recursive: true }); + fs.mkdirSync(newMusicDir, { recursive: true }); + fs.mkdirSync(newLyricDir, { recursive: true }); + + if (action === 'migrate') { + migratedFiles += await this.migrateEntriesToDirectory('music', oldMusicDir, newMusicDir); + migratedFiles += await this.migrateEntriesToDirectory('lyrics', oldLyricDir, newLyricDir); + } else if (action === 'destroy') { + destroyedFiles += await this.removeEntriesInDirectory('music', oldMusicDir); + destroyedFiles += await this.removeEntriesInDirectory('lyrics', oldLyricDir); + await this.cleanupCacheDirectory(oldDirectory); + } + + const updatedConfig: DiskCacheConfig = { + ...currentConfig, + directory: targetDirectory + }; + + this.saveConfig(updatedConfig); + this.ensureDirectories(updatedConfig); + await this.enforceCacheLimit(); + + return { + success: true, + config: updatedConfig, + migratedFiles, + destroyedFiles + }; + } catch (error) { + console.error('切换缓存目录失败:', error); + return { + success: false, + config: currentConfig, + migratedFiles, + destroyedFiles + }; + } + } + + private getMusicEntries(): Record { + return this.metadataStore.get('musicEntries'); + } + + private getLyricEntries(): Record { + return this.metadataStore.get('lyricEntries'); + } + + private setMusicEntries(entries: Record): void { + this.metadataStore.set('musicEntries', entries); + } + + private setLyricEntries(entries: Record): void { + this.metadataStore.set('lyricEntries', entries); + } + + private buildMusicKey(songId: number, source?: string): string { + const safeSource = (source || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '_'); + return `${songId}_${safeSource}`; + } + + private buildLyricKey(songId: number): string { + return String(songId); + } + + private buildUrlHash(url: string): string { + return createHash('sha1').update(url).digest('hex'); + } + + private toLocalUrl(filePath: string): string { + const normalized = path.normalize(filePath).replace(/\\/g, '/'); + return `local:///${encodeURIComponent(normalized)}`; + } + + private isRemoteAudioUrl(url: string): boolean { + return /^https?:\/\//i.test(url); + } + + private getExtensionFromUrl(url: string): string { + try { + const pathname = new URL(url).pathname; + const ext = path.extname(pathname).toLowerCase(); + if (ext && ext.length <= 6) { + return ext; + } + } catch { + // 忽略 URL 解析错误,回退到默认扩展名 + } + return ''; + } + + private getExtensionFromContentType(contentType?: string): string { + if (!contentType) { + return ''; + } + const normalizedType = contentType.split(';')[0].trim().toLowerCase(); + return AUDIO_EXTENSION_BY_CONTENT_TYPE[normalizedType] || ''; + } + + private resolveAudioExtension(url: string, contentType?: string): string { + const urlExtension = this.getExtensionFromUrl(url); + if (urlExtension) { + return urlExtension; + } + const contentTypeExtension = this.getExtensionFromContentType(contentType); + if (contentTypeExtension) { + return contentTypeExtension; + } + return '.mp3'; + } + + private async pruneMissingEntries(): Promise { + let musicEntriesChanged = false; + const musicEntries = this.getMusicEntries(); + const nextMusicEntries = { ...musicEntries }; + + for (const [key, entry] of Object.entries(musicEntries)) { + if (!fs.existsSync(entry.filePath)) { + delete nextMusicEntries[key]; + musicEntriesChanged = true; + continue; + } + + try { + const currentSize = fs.statSync(entry.filePath).size; + if (currentSize !== entry.size) { + nextMusicEntries[key] = { + ...entry, + size: currentSize + }; + musicEntriesChanged = true; + } + } catch { + delete nextMusicEntries[key]; + musicEntriesChanged = true; + } + } + + if (musicEntriesChanged) { + this.setMusicEntries(nextMusicEntries); + } + + let lyricEntriesChanged = false; + const lyricEntries = this.getLyricEntries(); + const nextLyricEntries = { ...lyricEntries }; + + for (const [key, entry] of Object.entries(lyricEntries)) { + if (!fs.existsSync(entry.filePath)) { + delete nextLyricEntries[key]; + lyricEntriesChanged = true; + continue; + } + + try { + const currentSize = fs.statSync(entry.filePath).size; + if (currentSize !== entry.size) { + nextLyricEntries[key] = { + ...entry, + size: currentSize + }; + lyricEntriesChanged = true; + } + } catch { + delete nextLyricEntries[key]; + lyricEntriesChanged = true; + } + } + + if (lyricEntriesChanged) { + this.setLyricEntries(nextLyricEntries); + } + } + + private getEvictionItems(): CacheEvictionItem[] { + const musicItems: CacheEvictionItem[] = Object.entries(this.getMusicEntries()).map( + ([key, entry]) => ({ + type: 'music', + key, + filePath: entry.filePath, + size: entry.size, + createdAt: entry.createdAt, + lastAccessAt: entry.lastAccessAt + }) + ); + + const lyricItems: CacheEvictionItem[] = Object.entries(this.getLyricEntries()).map( + ([key, entry]) => ({ + type: 'lyrics', + key, + filePath: entry.filePath, + size: entry.size, + createdAt: entry.createdAt, + lastAccessAt: entry.lastAccessAt + }) + ); + + return [...musicItems, ...lyricItems]; + } + + private async removeEntry(type: CacheItemType, key: string): Promise { + if (type === 'music') { + const entries = this.getMusicEntries(); + const entry = entries[key]; + if (!entry) return 0; + + if (fs.existsSync(entry.filePath)) { + try { + await fs.promises.unlink(entry.filePath); + } catch (error) { + console.error('删除音乐缓存文件失败:', error); + } + } + + delete entries[key]; + this.setMusicEntries(entries); + return entry.size; + } + + const entries = this.getLyricEntries(); + const entry = entries[key]; + if (!entry) return 0; + + if (fs.existsSync(entry.filePath)) { + try { + await fs.promises.unlink(entry.filePath); + } catch (error) { + console.error('删除歌词缓存文件失败:', error); + } + } + + delete entries[key]; + this.setLyricEntries(entries); + return entry.size; + } + + private async enforceCacheLimit(): Promise { + const config = this.getCacheConfig(); + await this.pruneMissingEntries(); + + const maxBytes = config.maxSizeMB * 1024 * 1024; + const items = this.getEvictionItems(); + let totalBytes = items.reduce((sum, item) => sum + item.size, 0); + + if (totalBytes <= maxBytes) { + return; + } + + items.sort((a, b) => { + if (config.cleanupPolicy === 'fifo') { + return a.createdAt - b.createdAt; + } + return a.lastAccessAt - b.lastAccessAt; + }); + + for (const item of items) { + if (totalBytes <= maxBytes) break; + const removedSize = await this.removeEntry(item.type, item.key); + totalBytes -= removedSize; + } + } + + private updateMusicAccess(key: string): void { + const entries = this.getMusicEntries(); + const entry = entries[key]; + if (!entry) return; + + entries[key] = { + ...entry, + lastAccessAt: Date.now(), + playCount: entry.playCount + 1 + }; + this.setMusicEntries(entries); + } + + private updateLyricAccess(key: string): void { + const entries = this.getLyricEntries(); + const entry = entries[key]; + if (!entry) return; + + entries[key] = { + ...entry, + lastAccessAt: Date.now() + }; + this.setLyricEntries(entries); + } + + private async getCachedMusicUrl(payload: ResolveMusicUrlPayload): Promise { + const key = this.buildMusicKey(payload.songId, payload.source); + const entries = this.getMusicEntries(); + const entry = entries[key]; + if (!entry) return null; + + if (!fs.existsSync(entry.filePath)) { + delete entries[key]; + this.setMusicEntries(entries); + return null; + } + + if (entry.urlHash !== this.buildUrlHash(payload.url)) { + return null; + } + + this.updateMusicAccess(key); + return this.toLocalUrl(entry.filePath); + } + + private async downloadAndCacheMusic(payload: ResolveMusicUrlPayload): Promise { + const config = this.getCacheConfig(); + if (!config.enabled || !this.isRemoteAudioUrl(payload.url)) { + return; + } + + this.ensureDirectories(config); + + const key = this.buildMusicKey(payload.songId, payload.source); + const source = payload.source || 'unknown'; + const urlHash = this.buildUrlHash(payload.url); + const musicDir = this.getMusicCacheDir(config.directory); + + const existingEntry = this.getMusicEntries()[key]; + if ( + existingEntry && + existingEntry.urlHash === urlHash && + fs.existsSync(existingEntry.filePath) + ) { + return; + } + + const tempFilePath = path.join(musicDir, `${key}_${Date.now()}.tmp`); + let contentType: string | undefined; + + try { + const response = await axios({ + url: payload.url, + method: 'GET', + responseType: 'stream', + timeout: 30000, + maxRedirects: 5 + }); + + contentType = + typeof response.headers['content-type'] === 'string' + ? response.headers['content-type'] + : undefined; + + const writer = fs.createWriteStream(tempFilePath); + await new Promise((resolve, reject) => { + response.data.on('error', reject); + writer.on('error', reject); + writer.on('finish', resolve); + response.data.pipe(writer); + }); + + const extension = this.resolveAudioExtension(payload.url, contentType); + const filePath = path.join(musicDir, `${key}_${urlHash}${extension}`); + + if (fs.existsSync(filePath)) { + await fs.promises.unlink(tempFilePath); + } else { + await fs.promises.rename(tempFilePath, filePath); + } + + if ( + existingEntry?.filePath && + existingEntry.filePath !== filePath && + fs.existsSync(existingEntry.filePath) + ) { + await fs.promises.unlink(existingEntry.filePath); + } + + const size = fs.statSync(filePath).size; + const now = Date.now(); + + const entries = this.getMusicEntries(); + entries[key] = { + key, + songId: payload.songId, + source, + filePath, + urlHash, + size, + createdAt: existingEntry?.createdAt || now, + lastAccessAt: now, + playCount: (existingEntry?.playCount || 0) + 1, + title: payload.title, + artist: payload.artist + }; + this.setMusicEntries(entries); + + await this.enforceCacheLimit(); + } catch (error) { + console.error(`缓存音乐失败: ${payload.songId}`, error); + if (fs.existsSync(tempFilePath)) { + try { + await fs.promises.unlink(tempFilePath); + } catch { + // 忽略临时文件清理错误 + } + } + } + } + + private queueMusicCache(payload: ResolveMusicUrlPayload): void { + const key = this.buildMusicKey(payload.songId, payload.source); + const task = this.pendingMusicDownloads.get(key); + if (task) return; + + const pendingTask = this.downloadAndCacheMusic(payload).finally(() => { + this.pendingMusicDownloads.delete(key); + }); + this.pendingMusicDownloads.set(key, pendingTask); + } + + public async resolveMusicUrl(payload: ResolveMusicUrlPayload): Promise { + if (!payload || !payload.url || !payload.songId) { + return { + url: payload?.url || '', + cached: false, + queued: false + }; + } + + if (/^(local|file):\/\//i.test(payload.url)) { + return { + url: payload.url, + cached: true, + queued: false + }; + } + + const config = this.getCacheConfig(); + if (!config.enabled) { + return { + url: payload.url, + cached: false, + queued: false + }; + } + + await this.pruneMissingEntries(); + + const cachedUrl = await this.getCachedMusicUrl(payload); + if (cachedUrl) { + return { + url: cachedUrl, + cached: true, + queued: false + }; + } + + this.queueMusicCache(payload); + return { + url: payload.url, + cached: false, + queued: true + }; + } + + public async cacheLyric(songId: number, lyricData: unknown): Promise { + try { + const config = this.getCacheConfig(); + if (!config.enabled) { + return false; + } + + this.ensureDirectories(config); + const key = this.buildLyricKey(songId); + const lyricDir = this.getLyricCacheDir(config.directory); + const filePath = path.join(lyricDir, `${key}.json`); + const content = JSON.stringify(lyricData); + + await fs.promises.writeFile(filePath, content, 'utf8'); + + const now = Date.now(); + const entries = this.getLyricEntries(); + entries[key] = { + key, + songId, + filePath, + size: Buffer.byteLength(content, 'utf8'), + createdAt: entries[key]?.createdAt || now, + lastAccessAt: now + }; + this.setLyricEntries(entries); + + await this.enforceCacheLimit(); return true; } catch (error) { - console.error('Error caching lyric:', error); + console.error('缓存歌词失败:', error); return false; } } - async getCachedLyric(id: number) { + public async getCachedLyric(songId: number): Promise { try { - const lyrics = this.store.get('lyrics'); - const result = lyrics[id]; - - if (!result) return undefined; - - // 检查缓存是否过期(24小时) - if (Date.now() - result.timestamp > 24 * 60 * 60 * 1000) { - delete lyrics[id]; - this.store.set('lyrics', lyrics); + const config = this.getCacheConfig(); + if (!config.enabled) { return undefined; } - return result.data; + const key = this.buildLyricKey(songId); + const entries = this.getLyricEntries(); + const entry = entries[key]; + if (!entry) { + return undefined; + } + + if (!fs.existsSync(entry.filePath)) { + delete entries[key]; + this.setLyricEntries(entries); + return undefined; + } + + const content = await fs.promises.readFile(entry.filePath, 'utf8'); + this.updateLyricAccess(key); + return JSON.parse(content); } catch (error) { - console.error('Error getting cached lyric:', error); + console.error('读取缓存歌词失败:', error); return undefined; } } - async clearLyricCache() { + private async clearByType(type: CacheItemType): Promise { + if (type === 'music') { + const keys = Object.keys(this.getMusicEntries()); + for (const key of keys) { + await this.removeEntry('music', key); + } + return; + } + + const keys = Object.keys(this.getLyricEntries()); + for (const key of keys) { + await this.removeEntry('lyrics', key); + } + } + + public async clearCache(scope: CacheScope = 'all'): Promise { try { - this.store.set('lyrics', {}); + if (scope === 'all' || scope === 'music') { + await this.clearByType('music'); + } + if (scope === 'all' || scope === 'lyrics') { + await this.clearByType('lyrics'); + } return true; } catch (error) { - console.error('Error clearing lyric cache:', error); + console.error('清理缓存失败:', error); return false; } } + + public async clearLyricCache(): Promise { + return await this.clearCache('lyrics'); + } + + public async getCacheStats(): Promise { + const config = this.getCacheConfig(); + await this.pruneMissingEntries(); + + const musicEntries = Object.values(this.getMusicEntries()); + const lyricEntries = Object.values(this.getLyricEntries()); + + const musicSizeBytes = musicEntries.reduce((sum, entry) => sum + entry.size, 0); + const lyricSizeBytes = lyricEntries.reduce((sum, entry) => sum + entry.size, 0); + const totalSizeBytes = musicSizeBytes + lyricSizeBytes; + const totalLimitBytes = config.maxSizeMB * 1024 * 1024; + const usage = totalLimitBytes > 0 ? Math.min(1, totalSizeBytes / totalLimitBytes) : 0; + + return { + enabled: config.enabled, + directory: config.directory, + maxSizeMB: config.maxSizeMB, + cleanupPolicy: config.cleanupPolicy, + totalSizeBytes, + musicSizeBytes, + lyricSizeBytes, + totalFiles: musicEntries.length + lyricEntries.length, + musicFiles: musicEntries.length, + lyricFiles: lyricEntries.length, + usage + }; + } } -export const cacheManager = new CacheManager(); +export const cacheManager = new DiskCacheManager(); -export function initializeCacheManager() { - // 兼容历史通道命名 +export function initializeCacheManager(): void { const CLEAR_LYRIC_CHANNELS = ['clear-lyric-cache', 'clear-lyrics-cache'] as const; + cacheManager.initialize(); - // 添加歌词缓存相关的 IPC 处理 - ipcMain.handle('cache-lyric', async (_, id: number, lyricData: any) => { + ipcMain.handle('cache-lyric', async (_, id: number, lyricData: unknown) => { return await cacheManager.cacheLyric(id, lyricData); }); @@ -86,6 +1064,30 @@ export function initializeCacheManager() { return await cacheManager.getCachedLyric(id); }); + ipcMain.handle('resolve-cached-music-url', async (_, payload: ResolveMusicUrlPayload) => { + return await cacheManager.resolveMusicUrl(payload); + }); + + ipcMain.handle('get-disk-cache-config', async () => { + return cacheManager.getCacheConfig(); + }); + + ipcMain.handle('set-disk-cache-config', async (_, partial: Partial) => { + return await cacheManager.updateCacheConfig(partial); + }); + + ipcMain.handle('switch-disk-cache-directory', async (_, payload: SwitchCacheDirectoryPayload) => { + return await cacheManager.switchCacheDirectory(payload); + }); + + ipcMain.handle('get-disk-cache-stats', async () => { + return await cacheManager.getCacheStats(); + }); + + ipcMain.handle('clear-disk-cache', async (_, scope: CacheScope = 'all') => { + return await cacheManager.clearCache(scope); + }); + for (const channel of CLEAR_LYRIC_CHANNELS) { ipcMain.handle(channel, async () => { return await cacheManager.clearLyricCache(); diff --git a/src/main/modules/config.ts b/src/main/modules/config.ts index 884f56e..b59f04f 100644 --- a/src/main/modules/config.ts +++ b/src/main/modules/config.ts @@ -1,5 +1,6 @@ import { app, ipcMain } from 'electron'; import Store from 'electron-store'; +import * as path from 'path'; import { createDefaultShortcuts, type ShortcutsConfig } from '../../shared/shortcuts'; import set from '../set.json'; @@ -26,6 +27,11 @@ type SetConfig = { language: string; showTopAction: boolean; enableGpuAcceleration: boolean; + downloadPath: string; + enableDiskCache: boolean; + diskCacheDir: string; + diskCacheMaxSizeMB: number; + diskCacheCleanupPolicy: 'lru' | 'fifo'; }; interface StoreType { set: SetConfig; @@ -47,6 +53,17 @@ export function initializeConfig() { }); store.get('set.downloadPath') || store.set('set.downloadPath', app.getPath('downloads')); + store.get('set.diskCacheDir') || + store.set('set.diskCacheDir', path.join(app.getPath('userData'), 'cache')); + if (store.get('set.diskCacheMaxSizeMB') === undefined) { + store.set('set.diskCacheMaxSizeMB', 4096); + } + if (!store.get('set.diskCacheCleanupPolicy')) { + store.set('set.diskCacheCleanupPolicy', 'lru'); + } + if (store.get('set.enableDiskCache') === undefined) { + store.set('set.enableDiskCache', true); + } // 定义ipcRenderer监听事件 ipcMain.on('set-store-value', (_, key, value) => { diff --git a/src/main/set.json b/src/main/set.json index cf63ae5..fce24dd 100644 --- a/src/main/set.json +++ b/src/main/set.json @@ -34,5 +34,9 @@ "customApiPluginName": "", "lxMusicScripts": [], "activeLxMusicApiId": null, - "enableGpuAcceleration": true + "enableGpuAcceleration": true, + "enableDiskCache": true, + "diskCacheDir": "", + "diskCacheMaxSizeMB": 4096, + "diskCacheCleanupPolicy": "lru" } diff --git a/src/renderer/components/common/AlbumItem.vue b/src/renderer/components/common/AlbumItem.vue index aa28598..2dc0506 100644 --- a/src/renderer/components/common/AlbumItem.vue +++ b/src/renderer/components/common/AlbumItem.vue @@ -55,7 +55,7 @@ const getDescription = () => { } if (props.item.size !== undefined) { - parts.push(t('user.album.songCount', { count: props.item.size })); + parts.push(t('common.songCount', { count: props.item.size })); } return parts.join(' · ') || t('history.noDescription'); diff --git a/src/renderer/components/common/MobileUpdateModal.vue b/src/renderer/components/common/MobileUpdateModal.vue index ca689b4..ba8ec56 100644 --- a/src/renderer/components/common/MobileUpdateModal.vue +++ b/src/renderer/components/common/MobileUpdateModal.vue @@ -33,7 +33,7 @@ - {{ t('comp.update.newVersion') }} + {{ t('comp.update.title') }}

@@ -65,7 +65,7 @@ @click="handleLater" class="flex-1 py-4 px-4 rounded-2xl text-base font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 active:scale-[0.98] transition-all duration-200" > - {{ t('comp.update.later') }} + {{ t('comp.update.noThanks') }} diff --git a/src/renderer/components/player/MobilePlayerSettings.vue b/src/renderer/components/player/MobilePlayerSettings.vue index 40fba34..2828047 100644 --- a/src/renderer/components/player/MobilePlayerSettings.vue +++ b/src/renderer/components/player/MobilePlayerSettings.vue @@ -213,9 +213,9 @@ let timerInterval: number | null = null; const hasTimerActive = computed(() => playerStore.hasSleepTimerActive); const timerStatusText = computed(() => { - if (sleepTimer.value.type === 'time') return t('player.sleepTimer.activeTime'); - if (sleepTimer.value.type === 'songs') return t('player.sleepTimer.activeSongs'); - if (sleepTimer.value.type === 'end') return t('player.sleepTimer.activeEnd'); + if (sleepTimer.value.type === 'time') return t('player.sleepTimer.timeMode'); + if (sleepTimer.value.type === 'songs') return t('player.sleepTimer.songsMode'); + if (sleepTimer.value.type === 'end') return t('player.sleepTimer.afterPlaylist'); return ''; }); diff --git a/src/renderer/components/settings/MusicSourceSettings.vue b/src/renderer/components/settings/MusicSourceSettings.vue index dcc50fb..4644c7e 100644 --- a/src/renderer/components/settings/MusicSourceSettings.vue +++ b/src/renderer/components/settings/MusicSourceSettings.vue @@ -396,7 +396,7 @@ const toggleSource = (sourceKey: string) => { if (index > -1) { // 至少保留一个音源 if (selectedSources.value.length <= 1) { - message.warning(t('settings.playback.musicSourcesMinWarning')); + message.warning(t('settings.playback.musicSourcesWarning')); return; } selectedSources.value.splice(index, 1); diff --git a/src/renderer/hooks/usePlayerHooks.ts b/src/renderer/hooks/usePlayerHooks.ts index 52dd73f..b1d80ee 100644 --- a/src/renderer/hooks/usePlayerHooks.ts +++ b/src/renderer/hooks/usePlayerHooks.ts @@ -6,12 +6,57 @@ import { getMusicLrc, getMusicUrl, getParsingMusicUrl } from '@/api/music'; import { playbackRequestManager } from '@/services/playbackRequestManager'; import { SongSourceConfigManager } from '@/services/SongSourceConfigManager'; import type { ILyric, ILyricText, IWordData, SongResult } from '@/types/music'; -import { getImgUrl } from '@/utils'; +import { getImgUrl, isElectron } from '@/utils'; import { getImageLinearBackground } from '@/utils/linearColor'; import { parseLyrics as parseYrcLyrics } from '@/utils/yrcParser'; const { message } = createDiscreteApi(['message']); +type DiskCacheResolveResult = { + url?: string; + cached?: boolean; + queued?: boolean; +}; + +const getSongArtistText = (songData: SongResult): string => { + if (songData?.ar?.length) { + return songData.ar.map((artist) => artist.name).join(' / '); + } + + if (songData?.song?.artists?.length) { + return songData.song.artists.map((artist) => artist.name).join(' / '); + } + + return ''; +}; + +const resolveCachedPlaybackUrl = async ( + url: string | null | undefined, + songData: SongResult +): Promise => { + if (!url || !isElectron || !/^https?:\/\//i.test(url)) { + return url; + } + + try { + const result = (await window.electron.ipcRenderer.invoke('resolve-cached-music-url', { + songId: Number(songData.id), + source: songData.source, + url, + title: songData.name, + artist: getSongArtistText(songData) + })) as DiskCacheResolveResult; + + if (result?.url) { + return result.url; + } + } catch (error) { + console.warn('解析缓存播放地址失败,回退到在线地址:', error); + } + + return url; +}; + /** * 获取歌曲播放URL(独立函数) */ @@ -35,7 +80,8 @@ export const getSongUrl = async ( } if (songData.playMusicUrl) { - return songData.playMusicUrl; + if (isDownloaded) return songData.playMusicUrl; + return await resolveCachedPlaybackUrl(songData.playMusicUrl, songData); } // ==================== 自定义API最优先 ==================== @@ -70,7 +116,7 @@ export const getSongUrl = async ( ) { console.log('自定义API解析成功!'); if (isDownloaded) return customResult.data.data as any; - return customResult.data.data.url; + return await resolveCachedPlaybackUrl(customResult.data.data.url, songData); } else { console.log('自定义API解析失败,将使用默认降级流程...'); message.warning(i18n.global.t('player.reparse.customApiFailed')); @@ -98,7 +144,7 @@ export const getSongUrl = async ( } if (res && res.data && res.data.data && res.data.data.url) { - return res.data.data.url; + return await resolveCachedPlaybackUrl(res.data.data.url, songData); } console.warn('自定义音源解析失败,使用默认音源'); } catch (error) { @@ -133,12 +179,13 @@ export const getSongUrl = async ( throw new Error('Request cancelled'); } if (isDownloaded) return res?.data?.data as any; - return res?.data?.data?.url || null; + const parsedUrl = res?.data?.data?.url || null; + return await resolveCachedPlaybackUrl(parsedUrl, songData); } console.log('官方API解析成功!'); if (isDownloaded) return songDetail as any; - return songDetail.url; + return await resolveCachedPlaybackUrl(songDetail.url, songData); } console.log('官方API返回数据结构异常,进入内置备用解析...'); @@ -149,7 +196,8 @@ export const getSongUrl = async ( throw new Error('Request cancelled'); } if (isDownloaded) return res?.data?.data as any; - return res?.data?.data?.url || null; + const parsedUrl = res?.data?.data?.url || null; + return await resolveCachedPlaybackUrl(parsedUrl, songData); } catch (error) { if ((error as Error).message === 'Request cancelled') { throw error; @@ -157,7 +205,8 @@ export const getSongUrl = async ( console.error('官方API请求失败,进入内置备用解析流程:', error); const res = await getParsingMusicUrl(numericId, cloneDeep(songData)); if (isDownloaded) return res?.data?.data as any; - return res?.data?.data?.url || null; + const parsedUrl = res?.data?.data?.url || null; + return await resolveCachedPlaybackUrl(parsedUrl, songData); } }; @@ -218,7 +267,28 @@ const parseLyrics = (lyricsString: string): { lyrics: ILyricText[]; times: numbe export const loadLrc = async (id: string | number): Promise => { try { const numericId = typeof id === 'string' ? parseInt(id, 10) : id; - const { data } = await getMusicLrc(numericId); + let lyricData: any; + + if (isElectron) { + try { + lyricData = await window.electron.ipcRenderer.invoke('get-cached-lyric', numericId); + } catch (error) { + console.warn('读取磁盘歌词缓存失败:', error); + } + } + + if (!lyricData) { + const { data } = await getMusicLrc(numericId); + lyricData = data; + + if (isElectron && lyricData) { + void window.electron.ipcRenderer + .invoke('cache-lyric', numericId, lyricData) + .catch((error) => console.warn('写入磁盘歌词缓存失败:', error)); + } + } + + const data = lyricData ?? {}; const { lyrics, times } = parseLyrics(data?.yrc?.lyric || data?.lrc?.lyric); // 检查是否有逐字歌词 diff --git a/src/renderer/views/home/components/HomeDailyRecommend.vue b/src/renderer/views/home/components/HomeDailyRecommend.vue index 70006d6..e21d0ca 100644 --- a/src/renderer/views/home/components/HomeDailyRecommend.vue +++ b/src/renderer/views/home/components/HomeDailyRecommend.vue @@ -16,7 +16,7 @@ @click="playAll" > - {{ t('musicList.playAll') }} + {{ t('comp.musicList.playAll') }} diff --git a/src/renderer/views/mobile-search-result/index.vue b/src/renderer/views/mobile-search-result/index.vue index 26663b5..2275111 100644 --- a/src/renderer/views/mobile-search-result/index.vue +++ b/src/renderer/views/mobile-search-result/index.vue @@ -67,7 +67,7 @@
- {{ t('search.noResult') }} + {{ t('comp.musicList.noSearchResults') }}
diff --git a/src/renderer/views/podcast/index.vue b/src/renderer/views/podcast/index.vue index d9f4721..7b4cf44 100644 --- a/src/renderer/views/podcast/index.vue +++ b/src/renderer/views/podcast/index.vue @@ -342,7 +342,7 @@ const categoryList = computed(() => { }); const currentCategoryName = computed(() => { - if (currentCategoryId.value === -1) return t('podcast.recommend'); + if (currentCategoryId.value === -1) return t('podcast.recommended'); return categories.value.find((c) => c.id === currentCategoryId.value)?.name || ''; }); diff --git a/src/renderer/views/search/SearchResult.vue b/src/renderer/views/search/SearchResult.vue index add8ec0..567b7e6 100644 --- a/src/renderer/views/search/SearchResult.vue +++ b/src/renderer/views/search/SearchResult.vue @@ -207,7 +207,7 @@ class="flex flex-col items-center justify-center py-20 text-neutral-400" > -

{{ t('search.noResults') }}

+

{{ t('comp.musicList.noSearchResults') }}

diff --git a/src/renderer/views/set/index.vue b/src/renderer/views/set/index.vue index efa9ac3..f536458 100644 --- a/src/renderer/views/set/index.vue +++ b/src/renderer/views/set/index.vue @@ -473,6 +473,119 @@
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + import { useDebounceFn } from '@vueuse/core'; -import { useMessage } from 'naive-ui'; +import { useDialog, useMessage } from 'naive-ui'; import { computed, h, onMounted, onUnmounted, ref, watch } from 'vue'; import { useI18n } from 'vue-i18n'; import { useRouter } from 'vue-router'; @@ -611,11 +724,40 @@ const fontPreviews = [ { key: 'korean' } ]; +type DiskCacheScope = 'all' | 'music' | 'lyrics'; +type DiskCacheCleanupPolicy = 'lru' | 'fifo'; +type CacheSwitchAction = 'migrate' | 'destroy' | 'keep'; + +type DiskCacheConfig = { + enabled: boolean; + directory: string; + maxSizeMB: number; + cleanupPolicy: DiskCacheCleanupPolicy; +}; + +type DiskCacheStats = DiskCacheConfig & { + totalSizeBytes: number; + musicSizeBytes: number; + lyricSizeBytes: number; + totalFiles: number; + musicFiles: number; + lyricFiles: number; + usage: number; +}; + +type SwitchCacheDirectoryResult = { + success: boolean; + config: DiskCacheConfig; + migratedFiles: number; + destroyedFiles: number; +}; + // ==================== 平台和Store ==================== const platform = window.electron ? window.electron.ipcRenderer.sendSync('get-platform') : 'web'; const settingsStore = useSettingsStore(); const userStore = useUserStore(); const message = useMessage(); +const dialog = useDialog(); const { t } = useI18n(); const router = useRouter(); @@ -764,6 +906,268 @@ const openDownloadPath = () => { openDirectory(setData.value.downloadPath, message); }; +// ==================== 磁盘缓存设置 ==================== +const diskCacheStats = ref({ + enabled: true, + directory: '', + maxSizeMB: 4096, + cleanupPolicy: 'lru', + totalSizeBytes: 0, + musicSizeBytes: 0, + lyricSizeBytes: 0, + totalFiles: 0, + musicFiles: 0, + lyricFiles: 0, + usage: 0 +}); +const applyingDiskCacheConfig = ref(false); +const switchingCacheDirectory = ref(false); + +const cleanupPolicyOptions = computed(() => [ + { label: t('settings.system.cleanupPolicyOptions.lru'), value: 'lru' }, + { label: t('settings.system.cleanupPolicyOptions.fifo'), value: 'fifo' } +]); + +const diskCacheUsagePercent = computed(() => + Math.min(100, Math.max(0, Math.round((diskCacheStats.value.usage || 0) * 100))) +); + +const formatBytes = (bytes: number) => { + if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let value = bytes; + let unitIndex = 0; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex++; + } + return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`; +}; + +const readDiskCacheConfigFromUI = (): DiskCacheConfig => { + const cleanupPolicy: DiskCacheCleanupPolicy = + setData.value.diskCacheCleanupPolicy === 'fifo' ? 'fifo' : 'lru'; + const maxSizeMB = Math.max(256, Math.floor(Number(setData.value.diskCacheMaxSizeMB || 4096))); + + return { + enabled: setData.value.enableDiskCache !== false, + directory: String(setData.value.diskCacheDir || ''), + maxSizeMB, + cleanupPolicy + }; +}; + +const refreshDiskCacheStats = async (silent: boolean = true) => { + if (!window.electron) return; + try { + const stats = (await window.electron.ipcRenderer.invoke( + 'get-disk-cache-stats' + )) as DiskCacheStats; + if (stats) { + diskCacheStats.value = stats; + } + } catch (error) { + console.error('读取磁盘缓存统计失败:', error); + if (!silent) { + message.error(t('settings.system.messages.diskCacheStatsLoadFailed')); + } + } +}; + +const loadDiskCacheConfig = async () => { + if (!window.electron) return; + + try { + const config = (await window.electron.ipcRenderer.invoke( + 'get-disk-cache-config' + )) as DiskCacheConfig; + if (config) { + setData.value = { + ...setData.value, + enableDiskCache: config.enabled, + diskCacheDir: config.directory, + diskCacheMaxSizeMB: config.maxSizeMB, + diskCacheCleanupPolicy: config.cleanupPolicy + }; + } + } catch (error) { + console.error('读取磁盘缓存配置失败:', error); + } +}; + +const applyDiskCacheConfig = async () => { + if (!window.electron || applyingDiskCacheConfig.value) return; + + applyingDiskCacheConfig.value = true; + try { + const config = readDiskCacheConfigFromUI(); + const updated = (await window.electron.ipcRenderer.invoke( + 'set-disk-cache-config', + config + )) as DiskCacheConfig; + + if (updated) { + setData.value = { + ...setData.value, + enableDiskCache: updated.enabled, + diskCacheDir: updated.directory, + diskCacheMaxSizeMB: updated.maxSizeMB, + diskCacheCleanupPolicy: updated.cleanupPolicy + }; + } + await refreshDiskCacheStats(); + } catch (error) { + console.error('更新磁盘缓存配置失败:', error); + } finally { + applyingDiskCacheConfig.value = false; + } +}; + +const applyDiskCacheConfigDebounced = useDebounceFn(() => { + void applyDiskCacheConfig(); +}, 500); + +watch( + () => [ + setData.value.enableDiskCache, + setData.value.diskCacheDir, + setData.value.diskCacheMaxSizeMB, + setData.value.diskCacheCleanupPolicy + ], + () => { + if (!window.electron || applyingDiskCacheConfig.value || switchingCacheDirectory.value) return; + applyDiskCacheConfigDebounced(); + } +); + +const askCacheSwitchMigrate = (): Promise => { + return new Promise((resolve) => { + let resolved = false; + const finish = (value: boolean) => { + if (resolved) return; + resolved = true; + resolve(value); + }; + + dialog.warning({ + title: t('settings.system.switchDirectoryMigrateTitle'), + content: t('settings.system.switchDirectoryMigrateContent'), + positiveText: t('settings.system.switchDirectoryMigrateConfirm'), + negativeText: t('settings.system.switchDirectoryKeepOld'), + onPositiveClick: () => finish(true), + onNegativeClick: () => finish(false), + onClose: () => finish(false) + }); + }); +}; + +const askCacheSwitchDestroy = (): Promise => { + return new Promise((resolve) => { + let resolved = false; + const finish = (value: boolean) => { + if (resolved) return; + resolved = true; + resolve(value); + }; + + dialog.warning({ + title: t('settings.system.switchDirectoryDestroyTitle'), + content: t('settings.system.switchDirectoryDestroyContent'), + positiveText: t('settings.system.switchDirectoryDestroyConfirm'), + negativeText: t('settings.system.switchDirectoryKeepOld'), + onPositiveClick: () => finish(true), + onNegativeClick: () => finish(false), + onClose: () => finish(false) + }); + }); +}; + +const selectCacheDirectory = async () => { + if (!window.electron) return; + + const selectedPath = await selectDirectory(message); + if (!selectedPath) return; + + const currentDirectory = setData.value.diskCacheDir || diskCacheStats.value.directory; + if (currentDirectory && selectedPath === currentDirectory) { + return; + } + + let action: CacheSwitchAction = 'keep'; + if (currentDirectory && diskCacheStats.value.totalFiles > 0) { + const shouldMigrate = await askCacheSwitchMigrate(); + if (shouldMigrate) { + action = 'migrate'; + } else { + const shouldDestroy = await askCacheSwitchDestroy(); + action = shouldDestroy ? 'destroy' : 'keep'; + } + } + + switchingCacheDirectory.value = true; + try { + const result = (await window.electron.ipcRenderer.invoke('switch-disk-cache-directory', { + directory: selectedPath, + action + })) as SwitchCacheDirectoryResult; + + if (!result?.success) { + message.error(t('settings.system.messages.switchDirectoryFailed')); + return; + } + + setData.value = { + ...setData.value, + enableDiskCache: result.config.enabled, + diskCacheDir: result.config.directory, + diskCacheMaxSizeMB: result.config.maxSizeMB, + diskCacheCleanupPolicy: result.config.cleanupPolicy + }; + await refreshDiskCacheStats(); + + if (action === 'migrate') { + message.success( + t('settings.system.messages.switchDirectoryMigrated', { count: result.migratedFiles }) + ); + return; + } + if (action === 'destroy') { + message.success( + t('settings.system.messages.switchDirectoryDestroyed', { count: result.destroyedFiles }) + ); + return; + } + message.success(t('settings.system.messages.switchDirectorySuccess')); + } catch (error) { + console.error('切换缓存目录失败:', error); + message.error(t('settings.system.messages.switchDirectoryFailed')); + } finally { + switchingCacheDirectory.value = false; + } +}; + +const openCacheDirectory = () => { + const targetPath = setData.value.diskCacheDir || diskCacheStats.value.directory; + openDirectory(targetPath, message); +}; + +const clearDiskCacheByScope = async (scope: DiskCacheScope) => { + if (!window.electron) return; + + try { + const success = await window.electron.ipcRenderer.invoke('clear-disk-cache', scope); + if (success) { + await refreshDiskCacheStats(); + message.success(t('settings.system.messages.diskCacheClearSuccess')); + return; + } + message.error(t('settings.system.messages.diskCacheClearFailed')); + } catch (error) { + console.error('手动清理磁盘缓存失败:', error); + message.error(t('settings.system.messages.diskCacheClearFailed')); + } +}; + // ==================== 代理设置 ==================== const showProxyModal = ref(false); const proxyForm = ref({ protocol: 'http', host: '127.0.0.1', port: 7890 }); @@ -883,6 +1287,7 @@ const clearCache = async (selectedCacheTypes: string[]) => { case 'resources': if (window.electron) { window.electron.ipcRenderer.send('clear-audio-cache'); + await window.electron.ipcRenderer.invoke('clear-disk-cache', 'music'); } localStorage.removeItem('lyricCache'); localStorage.removeItem('musicUrlCache'); @@ -897,11 +1302,15 @@ const clearCache = async (selectedCacheTypes: string[]) => { } break; case 'lyrics': - window.api.invoke('clear-lyrics-cache'); + if (window.electron) { + await window.electron.ipcRenderer.invoke('clear-disk-cache', 'lyrics'); + } + await window.api.invoke('clear-lyrics-cache'); break; } }); await Promise.all(clearTasks); + await refreshDiskCacheStats(); message.success(t('settings.system.messages.clearSuccess')); }; @@ -997,6 +1406,18 @@ onMounted(async () => { if (setData.value.enableRealIP === undefined) { setData.value = { ...setData.value, enableRealIP: false }; } + if (setData.value.enableDiskCache === undefined) { + setData.value = { ...setData.value, enableDiskCache: true }; + } + if (!setData.value.diskCacheMaxSizeMB) { + setData.value = { ...setData.value, diskCacheMaxSizeMB: 4096 }; + } + if (!['lru', 'fifo'].includes(setData.value.diskCacheCleanupPolicy)) { + setData.value = { ...setData.value, diskCacheCleanupPolicy: 'lru' }; + } + + await loadDiskCacheConfig(); + await refreshDiskCacheStats(); if (window.electron) { window.electron.ipcRenderer.on('gpu-acceleration-updated', (_, enabled: boolean) => { diff --git a/src/renderer/views/user/followers.vue b/src/renderer/views/user/followers.vue index eb759c1..4439a59 100644 --- a/src/renderer/views/user/followers.vue +++ b/src/renderer/views/user/followers.vue @@ -204,7 +204,7 @@ const loadFollowerList = async () => { hasMoreFollowers.value = newFollowers.length >= followerLimit.value; } catch (error) { console.error('加载粉丝列表失败:', error); - message.error(t('user.follower.loadFailed')); + message.error(t('common.loadFailed')); } finally { followerListLoading.value = false; } diff --git a/src/renderer/views/user/follows.vue b/src/renderer/views/user/follows.vue index e92b5b7..679fff1 100644 --- a/src/renderer/views/user/follows.vue +++ b/src/renderer/views/user/follows.vue @@ -206,7 +206,7 @@ const loadFollowList = async () => { hasMoreFollows.value = newFollows.length >= followLimit.value; } catch (error) { console.error('加载关注列表失败:', error); - message.error(t('user.follow.loadFailed')); + message.error(t('common.loadFailed')); } finally { followListLoading.value = false; }