From 24e9dc08bcf9583b3e87d1ab2745378c724c3251 Mon Sep 17 00:00:00 2001 From: kjqwer <2990346238@qq.com> Date: Tue, 2 Sep 2025 06:36:25 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=9A=82=E5=81=9C=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=E5=90=8E=E4=B8=8D=E8=83=BD=E6=81=A2=E5=A4=8D=E5=92=8C?= =?UTF-8?q?=E6=9B=B4=E6=94=B9=E6=97=B6=E9=97=B4=E6=97=A0=E6=95=88=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/services/download-executor.js | 125 +++++++++- backend/services/download.js | 59 ++++- backend/services/image-cache.js | 213 ++++++++++++++++-- backend/start.js | 13 +- .../components/artwork/ArtworkInfoPanel.vue | 65 +++++- ui/src/views/ArtistView.vue | 115 ++++++++-- 6 files changed, 521 insertions(+), 69 deletions(-) diff --git a/backend/services/download-executor.js b/backend/services/download-executor.js index adcfec8..b42b54b 100644 --- a/backend/services/download-executor.js +++ b/backend/services/download-executor.js @@ -33,6 +33,10 @@ class DownloadExecutor { // 检查是否应该暂停 if (this.shouldPause(task.id)) { logger.info('任务已暂停,停止下载:', task.id); + // 确保任务状态为暂停 + task.status = 'paused'; + await this.taskManager.saveTasks(); + this.progressManager.notifyProgressUpdate(task.id, task); break; } @@ -75,10 +79,13 @@ class DownloadExecutor { // 验证文件完整性 const integrity = await this.fileManager.checkFileIntegrity(filePath); if (integrity.valid) { - task.completed_files++; - task.progress = Math.round((task.completed_files / task.total_files) * 100); - await this.taskManager.saveTasks(); - this.progressManager.notifyProgressUpdate(task.id, task); + // 只有在非恢复模式下才增加计数,避免重复计算 + if (!task.isResuming) { + task.completed_files++; + task.progress = Math.round((task.completed_files / task.total_files) * 100); + await this.taskManager.saveTasks(); + this.progressManager.notifyProgressUpdate(task.id, task); + } results.push({ success: true, file: fileName, skipped: true }); continue; } else { @@ -115,6 +122,12 @@ class DownloadExecutor { } } + // 检查任务是否被暂停,如果是则不要更新最终状态 + if (task.status === 'paused') { + logger.info('任务已暂停,跳过最终状态更新:', task.id); + return; + } + // 保存作品信息 const infoPath = path.join(artworkDir, 'artwork_info.json'); await fs.writeJson(infoPath, artwork, { spaces: 2 }); @@ -527,32 +540,123 @@ class DownloadExecutor { async resumeTask(taskId) { const task = this.taskManager.getTask(taskId); if (!task) { + logger.error('恢复任务失败:任务不存在', { taskId }); throw new Error('任务不存在'); } + // logger.info('下载执行器检查任务状态', { + // taskId, + // currentStatus: task.status, + // type: task.type + // }); + if (task.status !== 'paused') { + logger.error('恢复任务失败:任务状态不是暂停状态', { + taskId, + currentStatus: task.status + }); throw new Error('任务状态不是暂停状态'); } // 根据任务类型重新开始下载 if (task.type === 'artwork') { + // logger.info('开始恢复单个作品下载任务', { taskId, artwork_id: task.artwork_id }); + // 重新获取作品信息和图片URL - const artworkResult = await this.downloadService.artworkService.getArtwork(task.artwork_id); + const artworkResult = await this.downloadService.artworkService.getArtworkDetail(task.artwork_id); if (!artworkResult.success) { + logger.error('获取作品信息失败', { taskId, error: artworkResult.error }); throw new Error(`获取作品信息失败: ${artworkResult.error}`); } - const imagesResult = await this.downloadService.artworkService.getArtworkImages(task.artwork_id, 'original'); + let imagesResult = await this.downloadService.artworkService.getArtworkImages(task.artwork_id, 'original'); if (!imagesResult.success) { + logger.error('获取图片URL失败', { taskId, error: imagesResult.error }); throw new Error(`获取图片URL失败: ${imagesResult.error}`); } const artwork = artworkResult.data; - const images = imagesResult.data.images; - const artworkDir = await this.fileManager.getArtworkDirectory(artwork); + let images = imagesResult.data.images; + + // 创建作品目录(使用与DownloadService相同的逻辑) + const artistName = this.fileManager.createSafeDirectoryName(artwork.user.name || 'Unknown Artist'); + const artworkTitle = this.fileManager.createSafeDirectoryName(artwork.title || 'Untitled'); + const downloadPath = await this.fileManager.getDownloadPath(); + const artistDir = path.join(downloadPath, artistName); + const artworkDirName = `${task.artwork_id}_${artworkTitle}`; + const artworkDir = path.join(artistDir, artworkDirName); - // 重新开始下载 - this.executeArtworkDownload(task, images, 'original', artworkDir, artwork); + // logger.info('准备恢复下载,重置任务状态', { + // taskId, + // originalTotalFiles: task.total_files, + // newImageCount: images.length, + // artworkDir + // }); + + // 如果新获取的图片数量与原始数量不同,记录警告但使用原始数量 + if (images.length !== task.total_files) { + logger.warn('恢复时图片数量发生变化', { + taskId, + originalTotalFiles: task.total_files, + newImageCount: images.length + }); + // 使用原始数量,避免任务状态混乱 + images = images.slice(0, task.total_files); + } + + // 检查哪些文件已经完成下载 + const completedFiles = []; + const incompleteFiles = []; + + for (let index = 0; index < images.length; index++) { + const imageObj = images[index]; + let imageUrl = imageObj.original || imageObj.large || imageObj.medium; + const fileName = `image_${index + 1}.${this.getFileExtension(imageUrl)}`; + const filePath = path.join(artworkDir, fileName); + + // 检查文件是否存在且完整 + if (await this.fileManager.fileExists(filePath)) { + const integrity = await this.fileManager.checkFileIntegrity(filePath); + if (integrity.valid) { + completedFiles.push({ index, fileName, filePath }); + } else { + incompleteFiles.push({ index, fileName, filePath }); + } + } else { + incompleteFiles.push({ index, fileName, filePath }); + } + } + + // logger.info('文件检查完成', { + // taskId, + // completedCount: completedFiles.length, + // incompleteCount: incompleteFiles.length + // }); + + // 只删除未完成的文件 + for (const fileInfo of incompleteFiles) { + try { + await this.fileManager.safeDeleteFile(fileInfo.filePath); + logger.debug(`删除未完成文件: ${fileInfo.fileName}`); + } catch (error) { + // 忽略删除错误,文件可能不存在 + logger.debug(`删除文件失败(可能不存在): ${fileInfo.filePath}`); + } + } + + // 重置任务状态,但保留已完成的文件计数 + task.completed_files = completedFiles.length; + task.failed_files = 0; + task.progress = Math.round((task.completed_files / task.total_files) * 100); + task.status = 'downloading'; + // 添加恢复标志,避免重复计算已完成的文件 + task.isResuming = true; + await this.taskManager.saveTasks(); + + // logger.info('开始执行作品下载', { taskId }); + + // 重新开始下载 - 等待异步执行开始 + await this.executeArtworkDownload(task, images, 'original', artworkDir, artwork); } else if (task.type === 'batch' || task.type === 'artist') { // 批量下载和作者下载的恢复逻辑 // 这里需要根据具体实现来恢复 @@ -560,6 +664,7 @@ class DownloadExecutor { // TODO: 实现批量下载的恢复逻辑 } + logger.info('任务恢复执行完成', { taskId }); return { success: true }; } diff --git a/backend/services/download.js b/backend/services/download.js index 3c022ab..7a60013 100644 --- a/backend/services/download.js +++ b/backend/services/download.js @@ -224,32 +224,67 @@ class DownloadService { return { success: false, error: '任务不存在' }; } + // 只允许暂停正在下载的任务 + if (task.status !== 'downloading') { + return { success: false, error: '只能暂停正在下载的任务' }; + } + await this.taskManager.updateTask(taskId, { status: 'paused' }); - this.progressManager.notifyProgressUpdate(taskId, task); - return { success: true }; + + // 获取更新后的任务 + const updatedTask = this.taskManager.getTask(taskId); + this.progressManager.notifyProgressUpdate(taskId, updatedTask); + + return { success: true, data: updatedTask }; } async resumeTask(taskId) { const task = this.taskManager.getTask(taskId); if (!task) { + logger.error('恢复任务失败:任务不存在', { taskId }); return { success: false, error: '任务不存在' }; } + // logger.info('尝试恢复任务', { + // taskId, + // currentStatus: task.status, + // type: task.type, + // artwork_id: task.artwork_id + // }); + + // 只允许恢复暂停的任务 if (task.status !== 'paused') { - return { success: false, error: '任务状态不是暂停状态' }; + logger.warn('恢复任务失败:任务状态不是暂停状态', { + taskId, + currentStatus: task.status + }); + return { success: false, error: '只能恢复暂停的任务' }; } - // 更新任务状态为下载中 - await this.taskManager.updateTask(taskId, { status: 'downloading' }); - - // 通知进度更新 - const updatedTask = this.taskManager.getTask(taskId); - this.progressManager.notifyProgressUpdate(taskId, updatedTask); - // 重新开始下载执行 - this.downloadExecutor.resumeTask(taskId); + try { + logger.info('开始恢复任务执行', { taskId }); + await this.downloadExecutor.resumeTask(taskId); + + // 获取更新后的任务状态 + const updatedTask = this.taskManager.getTask(taskId); + this.progressManager.notifyProgressUpdate(taskId, updatedTask); + + logger.info('任务恢复成功', { + taskId, + newStatus: updatedTask.status + }); + } catch (error) { + logger.error('恢复任务执行失败', { + taskId, + error: error.message, + stack: error.stack + }); + // 如果恢复失败,保持暂停状态 + return { success: false, error: `恢复任务失败: ${error.message}` }; + } - return { success: true, data: updatedTask }; + return { success: true, data: this.taskManager.getTask(taskId) }; } // 代理方法 - 历史记录管理 diff --git a/backend/services/image-cache.js b/backend/services/image-cache.js index d00b327..988631f 100644 --- a/backend/services/image-cache.js +++ b/backend/services/image-cache.js @@ -20,13 +20,16 @@ class ImageCacheService { if (isPkg) { // 在打包环境中,使用可执行文件所在目录 this.cacheDir = path.join(process.cwd(), 'data', 'image-cache'); + this.indexPath = path.join(process.cwd(), 'data', 'image-cache-index.json'); } else { // 在开发环境中,使用项目根目录的data文件夹 this.cacheDir = path.join(__dirname, '..', '..', 'data', 'image-cache'); + this.indexPath = path.join(__dirname, '..', '..', 'data', 'image-cache-index.json'); } // 确保路径是绝对路径 this.cacheDir = path.resolve(this.cacheDir); + this.indexPath = path.resolve(this.indexPath); // 创建配置管理器 this.configManager = new CacheConfigManager(); @@ -46,6 +49,9 @@ class ImageCacheService { allowedExtensions: ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'], }; + // 缓存索引 + this.cacheIndex = new Map(); + // 初始化配置 this.initializeConfig(); } @@ -62,6 +68,12 @@ class ImageCacheService { // 确保缓存目录存在 await this.ensureCacheDir(); + // 加载缓存索引 + await this.loadCacheIndex(); + + // 验证并同步缓存索引 + await this.validateAndSyncIndex(); + // 启动定期清理任务 this.startCleanupTask(); @@ -170,9 +182,21 @@ class ImageCacheService { */ async saveToCache(url, data) { try { + const cacheKey = this.generateCacheKey(url); const cachePath = this.getCacheFilePath(url); + const filename = path.basename(cachePath); + await fs.writeFile(cachePath, data); + // 获取文件信息并添加到索引 + const stats = await fs.stat(cachePath); + this.addToIndex(cacheKey, filename, stats.size, stats.mtime.getTime()); + + // 异步保存索引 + this.saveCacheIndex().catch(error => { + logger.error('异步保存缓存索引失败:', error); + }); + // 检查缓存大小,如果超过限制则清理 await this.checkCacheSize(); } catch (error) { @@ -256,25 +280,27 @@ class ImageCacheService { */ async checkCacheSize() { try { - const files = await fs.readdir(this.cacheDir); let totalSize = 0; const fileStats = []; - // 计算总大小和收集文件信息 - for (const file of files) { - const filePath = path.join(this.cacheDir, file); + // 使用索引计算总大小和收集文件信息 + for (const [cacheKey, fileInfo] of this.cacheIndex.entries()) { + const filePath = path.join(this.cacheDir, fileInfo.filename); try { + // 验证文件是否实际存在 const stats = await fs.stat(filePath); totalSize += stats.size; fileStats.push({ path: filePath, size: stats.size, - mtime: stats.mtime + mtime: new Date(fileInfo.mtime), + cacheKey: cacheKey }); } catch (error) { - // 如果文件不存在,记录日志但继续处理其他文件 + // 如果文件不存在,从索引中移除 if (error.code === 'ENOENT') { - logger.warn(`缓存文件不存在,跳过: ${filePath}`); + logger.warn(`缓存文件不存在,从索引中移除: ${filePath}`); + this.removeFromIndex(cacheKey); } else { logger.error(`检查缓存文件失败: ${filePath}`, error); } @@ -293,6 +319,9 @@ class ImageCacheService { await fs.unlink(file.path); totalSize -= file.size; + // 从索引中移除 + this.removeFromIndex(file.cacheKey); + if (totalSize <= this.config.maxSize * 0.8) { // 清理到80% break; } @@ -300,12 +329,17 @@ class ImageCacheService { // 如果删除文件失败,记录日志但继续处理其他文件 if (error.code === 'ENOENT') { logger.warn(`删除缓存文件时文件不存在: ${file.path}`); + // 从索引中移除 + this.removeFromIndex(file.cacheKey); } else { logger.error(`删除缓存文件失败: ${file.path}`, error); } } } + // 保存更新后的索引 + await this.saveCacheIndex(); + logger.info(`缓存清理完成,当前大小: ${totalSize}`); } } catch (error) { @@ -319,31 +353,35 @@ class ImageCacheService { */ async cleanupExpiredCache() { try { - const files = await fs.readdir(this.cacheDir); let cleanedCount = 0; + const now = Date.now(); - for (const file of files) { - const filePath = path.join(this.cacheDir, file); + // 使用索引检查过期文件 + for (const [cacheKey, fileInfo] of this.cacheIndex.entries()) { + const filePath = path.join(this.cacheDir, fileInfo.filename); try { const stats = await fs.stat(filePath); - const age = Date.now() - stats.mtime.getTime(); + const age = now - stats.mtime.getTime(); if (age > this.config.maxAge) { try { await fs.unlink(filePath); + this.removeFromIndex(cacheKey); cleanedCount++; } catch (deleteError) { if (deleteError.code === 'ENOENT') { logger.warn(`删除过期缓存文件时文件不存在: ${filePath}`); + this.removeFromIndex(cacheKey); } else { logger.error(`删除过期缓存文件失败: ${filePath}`, deleteError); } } } } catch (error) { - // 如果文件不存在,记录日志但继续处理其他文件 + // 如果文件不存在,从索引中移除 if (error.code === 'ENOENT') { - logger.warn(`过期缓存文件不存在,跳过: ${filePath}`); + logger.warn(`过期缓存文件不存在,从索引中移除: ${filePath}`); + this.removeFromIndex(cacheKey); } else { logger.error(`检查过期缓存文件失败: ${filePath}`, error); } @@ -351,6 +389,8 @@ class ImageCacheService { } if (cleanedCount > 0) { + // 保存更新后的索引 + await this.saveCacheIndex(); logger.info(`清理了 ${cleanedCount} 个过期缓存文件`); } } catch (error) { @@ -375,12 +415,12 @@ class ImageCacheService { */ async clearAllCache() { try { - const files = await fs.readdir(this.cacheDir); let deletedCount = 0; let errorCount = 0; - for (const file of files) { - const filePath = path.join(this.cacheDir, file); + // 使用索引清理所有文件 + for (const [cacheKey, fileInfo] of this.cacheIndex.entries()) { + const filePath = path.join(this.cacheDir, fileInfo.filename); try { await fs.unlink(filePath); deletedCount++; @@ -394,6 +434,10 @@ class ImageCacheService { } } + // 清空索引 + this.cacheIndex.clear(); + await this.saveCacheIndex(); + if (errorCount === 0) { logger.info(`所有缓存已清理,共删除 ${deletedCount} 个文件`); } else { @@ -411,13 +455,14 @@ class ImageCacheService { */ async getCacheStats() { try { - const files = await fs.readdir(this.cacheDir); let totalSize = 0; let fileCount = 0; let errorCount = 0; + let indexSize = this.cacheIndex.size; - for (const file of files) { - const filePath = path.join(this.cacheDir, file); + // 使用索引获取统计信息 + for (const [cacheKey, fileInfo] of this.cacheIndex.entries()) { + const filePath = path.join(this.cacheDir, fileInfo.filename); try { const stats = await fs.stat(filePath); totalSize += stats.size; @@ -425,6 +470,8 @@ class ImageCacheService { } catch (error) { if (error.code === 'ENOENT') { logger.warn(`统计缓存时文件不存在: ${filePath}`); + // 从索引中移除不存在的文件 + this.removeFromIndex(cacheKey); } else { logger.error(`获取缓存文件统计失败: ${filePath}`, error); } @@ -432,6 +479,11 @@ class ImageCacheService { } } + // 如果有文件被移除,保存索引 + if (errorCount > 0) { + await this.saveCacheIndex(); + } + return { fileCount, totalSize, @@ -439,7 +491,8 @@ class ImageCacheService { maxAge: this.config.maxAge, enabled: this.config.enabled, config: this.config, - errorCount + errorCount, + indexSize }; } catch (error) { logger.error('获取缓存统计失败:', error); @@ -450,7 +503,8 @@ class ImageCacheService { maxAge: this.config.maxAge, enabled: this.config.enabled, config: this.config, - errorCount: 0 + errorCount: 0, + indexSize: this.cacheIndex.size }; } } @@ -483,6 +537,123 @@ class ImageCacheService { this.config = { ...this.config, ...defaultConfig }; return defaultConfig; } + + /** + * 加载缓存索引 + */ + async loadCacheIndex() { + try { + if (await fs.access(this.indexPath).then(() => true).catch(() => false)) { + const indexData = await fs.readFile(this.indexPath, 'utf8'); + const index = JSON.parse(indexData); + this.cacheIndex = new Map(Object.entries(index)); + logger.info(`已加载缓存索引,包含 ${this.cacheIndex.size} 个文件记录`); + } else { + logger.info('缓存索引文件不存在,将创建新的索引'); + this.cacheIndex = new Map(); + } + } catch (error) { + logger.warn('加载缓存索引失败,将创建新的索引:', error.message); + this.cacheIndex = new Map(); + } + } + + /** + * 保存缓存索引 + */ + async saveCacheIndex() { + try { + const indexData = Object.fromEntries(this.cacheIndex); + await fs.writeFile(this.indexPath, JSON.stringify(indexData, null, 2)); + } catch (error) { + logger.error('保存缓存索引失败:', error); + } + } + + /** + * 验证并同步缓存索引 + */ + async validateAndSyncIndex() { + try { + const files = await fs.readdir(this.cacheDir); + const fileSet = new Set(files); + let removedCount = 0; + let addedCount = 0; + + // 检查索引中的文件是否实际存在 + for (const [cacheKey, fileInfo] of this.cacheIndex.entries()) { + if (!fileSet.has(fileInfo.filename)) { + this.cacheIndex.delete(cacheKey); + removedCount++; + } + } + + // 检查实际文件是否在索引中 + for (const filename of files) { + const filePath = path.join(this.cacheDir, filename); + try { + const stats = await fs.stat(filePath); + const cacheKey = this.findCacheKeyByFilename(filename); + + if (!cacheKey) { + // 文件存在但不在索引中,添加到索引 + this.cacheIndex.set(filename, { + filename: filename, + size: stats.size, + mtime: stats.mtime.getTime(), + added: Date.now() + }); + addedCount++; + } + } catch (error) { + // 文件不存在,从索引中移除 + const cacheKey = this.findCacheKeyByFilename(filename); + if (cacheKey) { + this.cacheIndex.delete(cacheKey); + removedCount++; + } + } + } + + if (removedCount > 0 || addedCount > 0) { + logger.info(`缓存索引同步完成: 移除 ${removedCount} 个无效记录,添加 ${addedCount} 个新记录`); + await this.saveCacheIndex(); + } + } catch (error) { + logger.error('验证缓存索引失败:', error); + } + } + + /** + * 根据文件名查找缓存键 + */ + findCacheKeyByFilename(filename) { + for (const [cacheKey, fileInfo] of this.cacheIndex.entries()) { + if (fileInfo.filename === filename) { + return cacheKey; + } + } + return null; + } + + /** + * 添加文件到缓存索引 + */ + addToIndex(cacheKey, filename, size, mtime) { + this.cacheIndex.set(cacheKey, { + filename: filename, + size: size, + mtime: mtime, + added: Date.now() + }); + } + + /** + * 从缓存索引中移除文件 + */ + removeFromIndex(cacheKey) { + this.cacheIndex.delete(cacheKey); + } } module.exports = ImageCacheService; \ No newline at end of file diff --git a/backend/start.js b/backend/start.js index 0526bc9..f27d0d5 100644 --- a/backend/start.js +++ b/backend/start.js @@ -86,12 +86,21 @@ process.on('SIGTERM', async () => { // 处理未捕获的异常 process.on('uncaughtException', error => { logger.error('❌ 未捕获的异常', error); + logger.error('❌ 异常堆栈:', error.stack); process.exit(1); }); process.on('unhandledRejection', (reason, promise) => { - logger.error('❌ 未处理的 Promise 拒绝', reason); - process.exit(1); + logger.error('❌ 未处理的 Promise 拒绝'); + logger.error('❌ 拒绝原因:', reason); + if (reason instanceof Error) { + logger.error('❌ 错误堆栈:', reason.stack); + } + logger.error('❌ Promise:', promise); + + // 不要立即退出进程,而是记录错误并继续运行 + // 这样可以避免因为自动恢复任务的小错误而停止整个服务 + logger.warn('⚠️ 继续运行服务器,但建议检查上述错误'); }); // 启动服务器 diff --git a/ui/src/components/artwork/ArtworkInfoPanel.vue b/ui/src/components/artwork/ArtworkInfoPanel.vue index 5a061e3..45907c2 100644 --- a/ui/src/components/artwork/ArtworkInfoPanel.vue +++ b/ui/src/components/artwork/ArtworkInfoPanel.vue @@ -112,12 +112,16 @@
- + @@ -159,9 +163,33 @@ const emit = defineEmits<{ // 使用统一的图片代理函数 const getImageUrl = getImageProxyUrl; +// 检查更新时间是否有效 +const isValidUpdateDate = computed(() => { + if (!props.artwork.update_date) return false; + + try { + const date = new Date(props.artwork.update_date); + return !isNaN(date.getTime()); + } catch (error) { + return false; + } +}); + // 格式化日期 const formatDate = (dateString: string) => { - return new Date(dateString).toLocaleDateString('zh-CN'); + if (!dateString) return '未知时间'; + + try { + const date = new Date(dateString); + if (isNaN(date.getTime())) { + console.warn('无效的日期格式:', dateString); + return '时间格式错误'; + } + return date.toLocaleDateString('zh-CN'); + } catch (error) { + console.error('日期格式化错误:', error); + return '时间解析失败'; + } }; // 处理标签点击 @@ -374,12 +402,29 @@ const handleTagClick = (event: MouseEvent, tagName: string) => { .artwork-meta { padding-top: 1.5rem; border-top: 1px solid #e5e7eb; -} - -.artwork-meta p { color: #6b7280; font-size: 0.875rem; - margin: 0.25rem 0; +} + +.meta-item { + display: flex; + align-items: center; + margin-bottom: 0.5rem; +} + +.meta-item:last-child { + margin-bottom: 0; +} + +.meta-label { + font-weight: 500; + color: #374151; + min-width: 80px; + margin-right: 0.5rem; +} + +.meta-value { + color: #6b7280; } .artwork-navigation { diff --git a/ui/src/views/ArtistView.vue b/ui/src/views/ArtistView.vue index 2c3bc67..5b79aa2 100644 --- a/ui/src/views/ArtistView.vue +++ b/ui/src/views/ArtistView.vue @@ -38,18 +38,37 @@