This commit is contained in:
N1KO 2026-03-06 11:03:20 +08:00
parent 8c557fe65f
commit dcd5444534
8 changed files with 391 additions and 3 deletions

View File

@ -1,6 +1,9 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="AutoCloseableResource" enabled="true" level="WARNING" enabled_by_default="true">
<option name="METHOD_MATCHER_CONFIG" value="java.util.Formatter,format,java.io.Writer,append,com.google.common.base.Preconditions,checkNotNull,org.hibernate.Session,close,java.io.PrintWriter,printf,java.io.PrintStream,printf,java.lang.foreign.Arena,ofAuto,java.lang.foreign.Arena,global,cn.hutool.http.HttpRequest,execute" />
</inspection_tool>
<inspection_tool class="JavaDoc" enabled="true" level="WARNING" enabled_by_default="true">
<option name="TOP_LEVEL_CLASS_OPTIONS">
<value>

View File

@ -6,7 +6,6 @@ import org.springframework.web.bind.annotation.RequestMapping;
/**
*
* @description:
*
* @author: N1KO
* @date: 2024/12/11 : 10:41
*/
@ -24,4 +23,9 @@ public class MusicStaticController {
return "login";
}
@RequestMapping("/music-tag")
public String musicTag() {
return "music-tag";
}
}

View File

@ -0,0 +1,46 @@
package top.baogutang.music.controller;
import org.springframework.web.bind.annotation.*;
import top.baogutang.music.domain.MusicInfo;
import top.baogutang.music.service.AudioTagService;
import top.baogutang.music.service.ScraperService;
import javax.annotation.Resource;
import java.util.List;
@RestController
@RequestMapping("/api/v1/music/tag")
@CrossOrigin // 允许跨域方便调试
public class MusicTagController {
@Resource
private AudioTagService audioTagService;
@Resource
private ScraperService scraperService;
// 1. 扫描目录
@GetMapping("/scan")
public List<MusicInfo> scan(@RequestParam String path) {
// Docker 内部路径例如 /music
return audioTagService.scanFolder(path);
}
// 2. 单个文件自动刮削
@PostMapping("/scrape")
public MusicInfo scrape(@RequestBody MusicInfo info) {
MusicInfo result = scraperService.scrapeNetease(info);
return result != null ? result : info; // 如果没找到返回原样
}
// 3. 保存标签
@PostMapping("/save")
public String save(@RequestBody MusicInfo info) {
try {
audioTagService.saveTag(info);
return "success";
} catch (Exception e) {
return "error: " + e.getMessage();
}
}
}

View File

@ -0,0 +1,15 @@
package top.baogutang.music.domain;
import lombok.Data;
@Data
public class MusicInfo {
private String filePath; // 文件绝对路径 (ID)
private String fileName; // 文件名
private String title; // 歌名
private String artist; // 歌手
private String album; // 专辑
private String coverUrl; // 封面网络地址
private String source; // 数据来源 (Local/Netease/QQ)
private boolean status; // 处理状态
}

View File

@ -0,0 +1,96 @@
package top.baogutang.music.service;
import cn.hutool.core.io.FileUtil;
import cn.hutool.http.HttpUtil;
import lombok.extern.slf4j.Slf4j;
import org.jaudiotagger.audio.AudioFile;
import org.jaudiotagger.audio.AudioFileIO;
import org.jaudiotagger.tag.FieldKey;
import org.jaudiotagger.tag.Tag;
import org.jaudiotagger.tag.images.Artwork;
import org.jaudiotagger.tag.images.ArtworkFactory;
import org.jaudiotagger.tag.reference.PictureTypes;
import org.springframework.stereotype.Service;
import top.baogutang.music.domain.MusicInfo;
import top.baogutang.music.exceptions.BusinessException;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
@Slf4j
@Service
public class AudioTagService {
// 扫描目录读取文件
public List<MusicInfo> scanFolder(String folderPath) {
List<MusicInfo> list = new ArrayList<>();
List<File> files = FileUtil.loopFiles(folderPath, file -> {
String name = file.getName().toLowerCase();
return name.endsWith(".mp3") || name.endsWith(".flac")||name.endsWith(".wav");
});
for (File file : files) {
try {
AudioFile audioFile = AudioFileIO.read(file);
Tag tag = audioFile.getTag();
MusicInfo info = new MusicInfo();
info.setFilePath(file.getAbsolutePath());
info.setFileName(file.getName());
// 读取现有标签如果没有则为空
if (tag != null) {
info.setTitle(tag.getFirst(FieldKey.TITLE));
info.setArtist(tag.getFirst(FieldKey.ARTIST));
info.setAlbum(tag.getFirst(FieldKey.ALBUM));
}
info.setSource("Local");
list.add(info);
} catch (Exception e) {
log.error("read:{} failed:{}", file.getName(), e.getMessage());
}
}
return list;
}
// 写入标签到文件
public void saveTag(MusicInfo info) throws Exception {
File file = new File(info.getFilePath());
if (!file.exists()) throw new BusinessException("文件不存在");
AudioFile audioFile = AudioFileIO.read(file);
Tag tag = audioFile.getTagOrCreateAndSetDefault();
tag.setField(FieldKey.TITLE, info.getTitle());
tag.setField(FieldKey.ARTIST, info.getArtist());
tag.setField(FieldKey.ALBUM, info.getAlbum());
// --- 修正后的封面写入逻辑 ---
if (info.getCoverUrl() != null && info.getCoverUrl().startsWith("http")) {
try {
// 1. 下载图片二进制数据
byte[] imageBytes = HttpUtil.downloadBytes(info.getCoverUrl());
// 2. 创建一个新的 Artwork 实例
Artwork artwork = ArtworkFactory.getNew();
// 3. 填充数据
artwork.setBinaryData(imageBytes);
artwork.setMimeType("image/jpeg"); // 大多数音乐API返回的是JPG也可以根据URL后缀动态判断
artwork.setDescription("");
artwork.setPictureType(PictureTypes.DEFAULT_ID); // 设置为封面类型 (Cover Front)
// 4. 删除旧封面并写入新封面
tag.deleteArtworkField();
tag.setField(artwork);
} catch (Exception e) {
log.error("封面下载或写入失败:{}", e.getMessage());
// 封面失败不影响文字信息的保存可以选择吞掉异常或记录日志
}
}
// ---------------------------
audioFile.commit();
}
}

View File

@ -11,6 +11,7 @@ import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.*;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import top.baogutang.music.domain.res.download.MusicDownloadRes;
import top.baogutang.music.enums.AudioFileTypeEnum;
@ -122,9 +123,12 @@ public class NcmDownloadService {
String maxBrLevel = null;
JsonNode privileges = root.path("privileges");
if (privileges.isArray() && privileges.size() > 0) {
if (privileges.isArray() && !privileges.isEmpty()) {
maxBrLevel = privileges.get(0).path("downloadMaxBrLevel").asText(null);
if (StringUtils.isBlank(maxBrLevel)) {
maxBrLevel = privileges.get(0).path("maxBrLevel").asText(null);
}
}
d.maxBrLevel = maxBrLevel;
return d;
}

View File

@ -0,0 +1,60 @@
package top.baogutang.music.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import top.baogutang.music.domain.MusicInfo;
import top.baogutang.music.domain.req.search.MusicSearchReq;
import top.baogutang.music.domain.res.search.MusicSearchRes;
import top.baogutang.music.enums.ChannelEnum;
import javax.annotation.Resource;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Objects;
@Slf4j
@Service
public class ScraperService {
@Resource
private IMusicService musicService;
// 简单的网易云搜索实现
public MusicInfo scrapeNetease(MusicInfo original) {
MusicSearchReq req = new MusicSearchReq();
req.setChannel(ChannelEnum.QQ_MUSIC.getCode());
String keyword = original.getFileName().replace(".mp3", "").replace(".flac", "").replace(".wav", "");
keyword = keyword + original.getArtist();
req.setKeywords(keyword);
req.setLimit(1);
MusicSearchRes res = musicService.getMusicService(req.getChannel()).search(req);
if (Objects.isNull(res) || Objects.isNull(res.getResult())) {
req.setChannel(ChannelEnum.NET_EASE_MUSIC.getCode());
res = musicService.getMusicService(req.getChannel()).search(req);
}
if (Objects.isNull(res) || Objects.isNull(res.getResult())) {
return null;
}
List<MusicSearchRes.Song> songList = res.getResult().getSongs();
if (CollectionUtils.isEmpty(songList)) {
return null;
}
MusicSearchRes.Song song = songList.get(0);
MusicInfo matched = new MusicInfo();
matched.setFilePath(original.getFilePath());
matched.setFileName(original.getFileName());
matched.setTitle(song.getName());
if (!CollectionUtils.isEmpty(song.getArtists())) {
if (song.getArtists().size() > 1) {
matched.setArtist(song.getArtists().stream().map(MusicSearchRes.Artist::getName).reduce("/", String::concat));
} else {
matched.setArtist(song.getArtists().get(0).getName());
}
}
matched.setAlbum(song.getAlbum().getName());
matched.setCoverUrl(song.getAlbum().getBlurPicUrl());
return matched;
}
}

View File

@ -0,0 +1,160 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>NAS 音乐标签刮削器</title>
<!-- 引入样式 -->
<link rel="stylesheet" href="https://unpkg.com/element-plus/dist/index.css" />
<style>
body { font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', sans-serif; padding: 20px; background: #f5f7fa; }
.container { max-width: 1200px; margin: 0 auto; background: #fff; padding: 20px; border-radius: 8px; box-shadow: 0 2px 12px 0 rgba(0,0,0,0.1); }
.header { display: flex; gap: 10px; margin-bottom: 20px; }
.cover-img { width: 50px; height: 50px; border-radius: 4px; object-fit: cover; }
.diff-text { color: #409EFF; font-weight: bold; }
</style>
</head>
<body>
<div id="app" class="container">
<h2>🎵 NAS 音乐标签管理</h2>
<!-- 顶部操作栏 -->
<div class="header">
<el-input v-model="scanPath" placeholder="请输入NAS映射路径 (例如 /music)" style="width: 300px;"></el-input>
<el-button type="primary" @click="scanFiles" :loading="loading">扫描文件</el-button>
<el-button type="success" @click="batchScrape" :disabled="tableData.length === 0">一键自动刮削</el-button>
<el-button type="warning" @click="batchSave" :disabled="selection.length === 0">保存选中文件</el-button>
</div>
<!-- 数据表格 -->
<el-table :data="tableData" border style="width: 100%" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55"></el-table-column>
<el-table-column label="原始文件名" prop="fileName" width="200"></el-table-column>
<el-table-column label="当前标签" width="200">
<template #default="scope">
<div style="font-size: 12px; color: #666;">
<div>{{ scope.row.title || '-' }}</div>
<div>{{ scope.row.artist || '-' }}</div>
</div>
</template>
</el-table-column>
<el-table-column label="刮削结果 (预览)" width="250">
<template #default="scope">
<div v-if="scope.row.newMeta" style="display: flex; gap: 10px; align-items: center;">
<img :src="scope.row.newMeta.coverUrl" class="cover-img" v-if="scope.row.newMeta.coverUrl">
<div style="font-size: 12px;">
<div class="diff-text">{{ scope.row.newMeta.title }}</div>
<div>{{ scope.row.newMeta.artist }}</div>
<div>{{ scope.row.newMeta.album }}</div>
</div>
</div>
<span v-else style="color: #999;">等待刮削...</span>
</template>
</el-table-column>
<el-table-column label="操作" width="150">
<template #default="scope">
<el-button size="small" @click="scrapeOne(scope.row)">搜索</el-button>
<el-button size="small" type="success" v-if="scope.row.newMeta" @click="saveOne(scope.row)">写入</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- 引入 Vue 和 Element Plus -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://unpkg.com/element-plus"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script>
const { createApp, ref } = Vue;
createApp({
setup() {
const scanPath = ref('/music'); // 默认 Docker 路径
const tableData = ref([]);
const loading = ref(false);
const selection = ref([]);
// 1. 扫描文件
const scanFiles = async () => {
loading.value = true;
try {
const res = await axios.get(`/api/v1/music/tag/scan?path=${scanPath.value}`);
// 为每个文件添加 newMeta 字段用于存放刮削结果
tableData.value = res.data.map(item => ({ ...item, newMeta: null }));
ElementPlus.ElMessage.success(`扫描到 ${tableData.value.length} 个文件`);
} catch (err) {
ElementPlus.ElMessage.error('扫描失败: ' + err.message);
} finally {
loading.value = false;
}
};
// 2. 单个刮削
const scrapeOne = async (row) => {
try {
const res = await axios.post('/api/v1/music/tag/scrape', row);
row.newMeta = res.data;
// if (res.data) {
// ElementPlus.ElMessage.success('匹配成功');
// } else {
// ElementPlus.ElMessage.warning('未找到匹配信息');
// }
} catch (err) {
console.error(err);
}
};
// 3. 批量刮削 (串行执行防止被封IP)
const batchScrape = async () => {
for (const row of tableData.value) {
if (!row.newMeta) {
await scrapeOne(row);
// 简单的延时
await new Promise(r => setTimeout(r, 500));
}
}
};
// 4. 保存单个
const saveOne = async (row) => {
if (!row.newMeta) return;
try {
const res = await axios.post('/api/v1/music/tag/save', row.newMeta);
if (res.data === 'success') {
ElementPlus.ElMessage.success('写入成功');
// 更新显示
row.title = row.newMeta.title;
row.artist = row.newMeta.artist;
row.newMeta = null; // 清空预览
} else {
ElementPlus.ElMessage.error('写入失败');
}
} catch (err) {
ElementPlus.ElMessage.error('网络错误');
}
};
// 5. 批量保存
const batchSave = async () => {
for (const row of selection.value) {
await saveOne(row);
}
};
const handleSelectionChange = (val) => {
selection.value = val;
};
return {
scanPath, tableData, loading, selection,
scanFiles, scrapeOne, batchScrape, saveOne, batchSave, handleSelectionChange
};
}
}).use(ElementPlus).mount('#app');
</script>
</body>
</html>