feat
This commit is contained in:
parent
8c557fe65f
commit
dcd5444534
3
.idea/inspectionProfiles/Project_Default.xml
generated
3
.idea/inspectionProfiles/Project_Default.xml
generated
@ -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>
|
||||
|
||||
@ -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";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
15
src/main/java/top/baogutang/music/domain/MusicInfo.java
Normal file
15
src/main/java/top/baogutang/music/domain/MusicInfo.java
Normal 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; // 处理状态
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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,8 +123,11 @@ public class NcmDownloadService {
|
||||
|
||||
String maxBrLevel = null;
|
||||
JsonNode privileges = root.path("privileges");
|
||||
if (privileges.isArray() && privileges.size() > 0) {
|
||||
maxBrLevel = privileges.get(0).path("maxBrLevel").asText(null);
|
||||
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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
160
src/main/resources/templates/music-tag.html
Normal file
160
src/main/resources/templates/music-tag.html
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user