This commit is contained in:
N1KO 2025-11-29 14:08:24 +08:00
parent ba272e4b1b
commit 8c557fe65f
15 changed files with 669 additions and 30 deletions

View File

@ -328,4 +328,5 @@ public class KuGouMusicClient implements ChannelClient<MusicSearchReq, MusicSear
public List<MusicRecordEntity> queryMusicByPlatformIdList(List<String> platformIdList) { public List<MusicRecordEntity> queryMusicByPlatformIdList(List<String> platformIdList) {
return List.of(); return List.of();
} }
} }

View File

@ -16,6 +16,7 @@ import top.baogutang.music.processor.AbstractAudioProcessor;
import top.baogutang.music.properties.NetEaseMusicProperties; import top.baogutang.music.properties.NetEaseMusicProperties;
import top.baogutang.music.service.IMusicDownloadRecordService; import top.baogutang.music.service.IMusicDownloadRecordService;
import top.baogutang.music.service.IMusicRecordService; import top.baogutang.music.service.IMusicRecordService;
import top.baogutang.music.service.NcmDownloadService;
import top.baogutang.music.utils.OkHttpUtil; import top.baogutang.music.utils.OkHttpUtil;
import javax.annotation.Resource; import javax.annotation.Resource;
@ -28,11 +29,11 @@ import java.nio.file.Paths;
import java.nio.file.StandardCopyOption; import java.nio.file.StandardCopyOption;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.stream.Stream;
/** /**
* *
* @description: * @description:
*
* @author: N1KO * @author: N1KO
* @date: 2024/12/10 : 17:57 * @date: 2024/12/10 : 17:57
*/ */
@ -51,6 +52,9 @@ public class NetEaseMusicClient implements ChannelClient<MusicSearchReq, MusicSe
@Resource @Resource
private IMusicDownloadRecordService musicDownloadRecordService; private IMusicDownloadRecordService musicDownloadRecordService;
@Resource
private NcmDownloadService ncmDownloadService;
@Override @Override
public MusicSearchRes search(MusicSearchReq req) { public MusicSearchRes search(MusicSearchReq req) {
String searchUrl = String.format(netEaseMusicProperties.getQueryBaseUrl(), req.getKeywords(), req.getLimit(), req.getOffset(), req.getType()); String searchUrl = String.format(netEaseMusicProperties.getQueryBaseUrl(), req.getKeywords(), req.getLimit(), req.getOffset(), req.getType());
@ -88,18 +92,14 @@ public class NetEaseMusicClient implements ChannelClient<MusicSearchReq, MusicSe
@Override @Override
public MusicDownloadRes download(String id) { public MusicDownloadRes download(String id) {
String downloadUrl = String.format(netEaseMusicProperties.getDownloadBaseUrl(), Long.valueOf(id));
MusicDownloadRes res = OkHttpUtil.get(downloadUrl, null, null, new TypeReference<>() {
});
if (Objects.nonNull(res)) {
res.setId(id);
String[] split = res.getUrl().split("\\.");
String fileType = split[split.length - 1];
fileType = fileType.split("\\?")[0];
res.setFileType(AudioFileTypeEnum.parse(fileType));
}
return res; try {
return ncmDownloadService.getDownloadRes(id, netEaseMusicProperties.getCookie());
} catch (Exception e) {
log.error("net ease music download error:{}", e.getMessage(), e);
}
return null;
} }
@Override @Override
@ -121,6 +121,14 @@ public class NetEaseMusicClient implements ChannelClient<MusicSearchReq, MusicSe
if (!Files.exists(baseDir)) { if (!Files.exists(baseDir)) {
Files.createDirectories(baseDir); Files.createDirectories(baseDir);
} }
boolean hasAnyFile;
try (Stream<Path> stream = Files.list(baseDir)) {
hasAnyFile = stream.anyMatch(Files::isRegularFile);
}
if (hasAnyFile) {
log.warn("path:{} has file", baseDir.toString());
return;
}
AbstractAudioProcessor audioProcessor = AbstractAudioProcessor.getAudioProcessor(res.getFileType()); AbstractAudioProcessor audioProcessor = AbstractAudioProcessor.getAudioProcessor(res.getFileType());
try (InputStream musicIn = audioProcessor.processAudioTags(new URL(res.getUrl()).openStream(), res)) { try (InputStream musicIn = audioProcessor.processAudioTags(new URL(res.getUrl()).openStream(), res)) {
if (Objects.nonNull(musicIn)) { if (Objects.nonNull(musicIn)) {

View File

@ -36,7 +36,6 @@ import java.util.stream.Collectors;
/** /**
* *
* @description: * @description:
*
* @author: N1KO * @author: N1KO
* @date: 2024/12/12 : 15:00 * @date: 2024/12/12 : 15:00
*/ */
@ -268,12 +267,21 @@ public class QQMusicClient implements ChannelClient<MusicSearchReq, MusicSearchR
@Override @Override
public MusicDownloadRes download(String id) { public MusicDownloadRes download(String id) {
String downloadUrl = String.format(qqMusicProperties.getDownloadBaseUrl1(), id); String downloadUrl = String.format(qqMusicProperties.getDownloadBaseUrl1(), id);
QQMusicDownloadRes qqMusicDownloadRes = OkHttpUtil.get(downloadUrl, null, null, new TypeReference<>() { QQMusicDownloadRes qqMusicDownloadRes = null;
}); try {
if (Objects.isNull(qqMusicDownloadRes) || Objects.isNull(qqMusicDownloadRes.getMusicUrlInfo())) {
downloadUrl = String.format(qqMusicProperties.getDownloadBaseUrl2(), id);
qqMusicDownloadRes = OkHttpUtil.get(downloadUrl, null, null, new TypeReference<>() { qqMusicDownloadRes = OkHttpUtil.get(downloadUrl, null, null, new TypeReference<>() {
}); });
} catch (Exception e) {
log.error("download url:{} error:{}", qqMusicProperties.getDownloadBaseUrl1(), e.getMessage());
}
if (Objects.isNull(qqMusicDownloadRes) || Objects.isNull(qqMusicDownloadRes.getMusicUrlInfo())) {
downloadUrl = String.format(qqMusicProperties.getDownloadBaseUrl2(), id);
try {
qqMusicDownloadRes = OkHttpUtil.get(downloadUrl, null, null, new TypeReference<>() {
});
} catch (Exception e) {
log.error("download url:{} error:{}", qqMusicProperties.getDownloadBaseUrl1(), e.getMessage());
}
if (Objects.isNull(qqMusicDownloadRes) || Objects.isNull(qqMusicDownloadRes.getMusicUrlInfo())) { if (Objects.isNull(qqMusicDownloadRes) || Objects.isNull(qqMusicDownloadRes.getMusicUrlInfo())) {
return null; return null;
} }
@ -307,7 +315,7 @@ public class QQMusicClient implements ChannelClient<MusicSearchReq, MusicSearchR
musicDownloadRes.setUrl(musicUrlInfo.getUrlInfo128().getUrl()); musicDownloadRes.setUrl(musicUrlInfo.getUrlInfo128().getUrl());
musicDownloadRes.setLevel(musicUrlInfo.getUrlInfo128().getBitrate()); musicDownloadRes.setLevel(musicUrlInfo.getUrlInfo128().getBitrate());
} }
if(Objects.isNull(musicDownloadRes.getUrl())) { if (Objects.isNull(musicDownloadRes.getUrl())) {
return null; return null;
} }
QQMusicDownloadRes.Lyrics lyric = qqMusicDownloadRes.getLyric(); QQMusicDownloadRes.Lyrics lyric = qqMusicDownloadRes.getLyric();
@ -350,10 +358,15 @@ public class QQMusicClient implements ChannelClient<MusicSearchReq, MusicSearchR
if (Objects.isNull(musicQualityEnum)) { if (Objects.isNull(musicQualityEnum)) {
throw new BusinessException("不支持的文件格式"); throw new BusinessException("不支持的文件格式");
} }
String fileName = res.getName();
String[] split = res.getName().split("/");
if (split.length > 1) {
fileName = split[1];
}
AbstractAudioProcessor audioProcessor = AbstractAudioProcessor.getAudioProcessor(res.getFileType()); AbstractAudioProcessor audioProcessor = AbstractAudioProcessor.getAudioProcessor(res.getFileType());
try (InputStream musicIn = audioProcessor.processAudioTags(new URL(res.getUrl()).openStream(), res)) { try (InputStream musicIn = audioProcessor.processAudioTags(new URL(res.getUrl()).openStream(), res)) {
if (Objects.nonNull(musicIn)) { if (Objects.nonNull(musicIn)) {
Files.copy(musicIn, baseDir.resolve(res.getName() + "." + res.getFileType().name().toLowerCase()), StandardCopyOption.REPLACE_EXISTING); Files.copy(musicIn, baseDir.resolve(fileName + "." + res.getFileType().name().toLowerCase()), StandardCopyOption.REPLACE_EXISTING);
} }
} }
} }

View File

@ -6,13 +6,13 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import top.baogutang.music.domain.Results; import top.baogutang.music.domain.Results;
import top.baogutang.music.schedule.QQDailyMusicHandler; import top.baogutang.music.schedule.QQDailyMusicHandler;
import top.baogutang.music.service.IMusicRecordService;
import javax.annotation.Resource; import javax.annotation.Resource;
/** /**
* *
* @description: * @description:
*
* @author: N1KO * @author: N1KO
* @date: 2024/12/23 : 15:26 * @date: 2024/12/23 : 15:26
*/ */
@ -24,9 +24,19 @@ public class MusicTriggerController {
@Resource @Resource
private QQDailyMusicHandler qqDailyMusicHandler; private QQDailyMusicHandler qqDailyMusicHandler;
@Resource
private IMusicRecordService musicRecordService;
@GetMapping("/daily") @GetMapping("/daily")
public Results<Void> triggerDaily() { public Results<Void> triggerDaily() {
qqDailyMusicHandler.fetchDailyAndSave(); qqDailyMusicHandler.fetchDailyAndSave();
return Results.ok(); return Results.ok();
} }
@GetMapping("/recall")
public Results<Void> triggerRecall() {
musicRecordService.triggerRecall();
return Results.ok();
}
} }

View File

@ -1,6 +1,7 @@
package top.baogutang.music.dao.mapper; package top.baogutang.music.dao.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import top.baogutang.music.dao.entity.MusicRecordEntity; import top.baogutang.music.dao.entity.MusicRecordEntity;
@ -13,4 +14,7 @@ import top.baogutang.music.dao.entity.MusicRecordEntity;
*/ */
@Mapper @Mapper
public interface MusicRecordMapper extends BaseMapper<MusicRecordEntity> { public interface MusicRecordMapper extends BaseMapper<MusicRecordEntity> {
Page<MusicRecordEntity> selectPages(Page<MusicRecordEntity> page);
} }

View File

@ -28,4 +28,6 @@ public class NetEaseMusicProperties {
private String downloadPath; private String downloadPath;
private String cookie;
} }

View File

@ -22,12 +22,16 @@ import javax.servlet.http.HttpServletResponse;
import java.io.File; import java.io.File;
import java.io.FileInputStream; import java.io.FileInputStream;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor; import java.util.concurrent.Executor;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.zip.ZipEntry; import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream; import java.util.zip.ZipOutputStream;
@ -36,7 +40,6 @@ import static top.baogutang.music.constants.BusinessKey.ADMIN_ID_LIST;
/** /**
* *
* @description: * @description:
*
* @author: N1KO * @author: N1KO
* @date: 2024/12/10 : 17:00 * @date: 2024/12/10 : 17:00
*/ */
@ -114,6 +117,46 @@ public abstract class AbstractMusicService<Q extends AbstractMusicReq, S extends
return res; return res;
} }
public void processFile(MusicRecordEntity musicRecordEntity) {
if (Objects.isNull(musicRecordEntity)) {
return;
}
ChannelEnum channelEnum = getChannelEnum();
ChannelClient<Q, S> channelClient = channelClientFactory.getClient(channelEnum);
try {
Path baseDir;
if (StringUtils.isNotBlank(musicRecordEntity.getAlbumName())) {
baseDir = Paths.get(channelClient.getDownloadBasePath(), musicRecordEntity.getArtistName(), musicRecordEntity.getAlbumName());
} else {
baseDir = Paths.get(channelClient.getDownloadBasePath(), musicRecordEntity.getArtistName());
}
if (!Files.exists(baseDir)) {
Files.createDirectories(baseDir);
}
boolean hasAnyFile;
try (Stream<Path> stream = Files.list(baseDir)) {
hasAnyFile = stream.anyMatch(file -> Files.isRegularFile(file) && file.getFileName().toString().contains(musicRecordEntity.getName()));
}
if (hasAnyFile) {
log.warn("path:{} has file:{}", baseDir.toString(), musicRecordEntity.getName());
return;
}
MusicDownloadRes download = channelClient.download(musicRecordEntity.getPlatformId());
if (Objects.isNull(download)) {
log.error("channel:{} music platform id:{} music name:{} download is null!", channelEnum.getDesc(), musicRecordEntity.getPlatformId(), musicRecordEntity.getName());
return;
}
log.info(">>>>>>>>>>download file:{} start<<<<<<<<<<", download.getName());
channelClient.processFile(download);
log.info(">>>>>>>>>>download file:{} success<<<<<<<<<<", download.getName());
} catch (IOException e) {
log.error(">>>>>>>>>>download error:{}<<<<<<<<<<", e.getMessage(), e);
throw new BusinessException("下载异常");
}
}
public String downloads(List<String> idList, Long userId) { public String downloads(List<String> idList, Long userId) {
if (CollectionUtils.isEmpty(idList)) { if (CollectionUtils.isEmpty(idList)) {
throw new BusinessException("请选择下载内容"); throw new BusinessException("请选择下载内容");
@ -148,6 +191,7 @@ public abstract class AbstractMusicService<Q extends AbstractMusicReq, S extends
channelClient.saveMusic(res); channelClient.saveMusic(res);
channelClient.saveDownloadRecord(userId, batchNo, id); channelClient.saveDownloadRecord(userId, batchNo, id);
try { try {
log.info(">>>>>>>>>>download file:{} start<<<<<<<<<<", res.getName());
channelClient.processFile(res); channelClient.processFile(res);
log.info(">>>>>>>>>>download file:{} success<<<<<<<<<<", res.getName()); log.info(">>>>>>>>>>download file:{} success<<<<<<<<<<", res.getName());
} catch (IOException e) { } catch (IOException e) {

View File

@ -23,4 +23,7 @@ public interface IMusicRecordService extends IService<MusicRecordEntity> {
List<MusicRecordEntity> queryByPlatformIdList(List<String> platformIdList); List<MusicRecordEntity> queryByPlatformIdList(List<String> platformIdList);
MusicRecordEntity queryByNameOrAlbumOrArtist(String title, String album, String artist); MusicRecordEntity queryByNameOrAlbumOrArtist(String title, String album, String artist);
void triggerRecall();
} }

View File

@ -0,0 +1,368 @@
package top.baogutang.music.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import okhttp3.*;
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.*;
import org.springframework.stereotype.Service;
import top.baogutang.music.domain.res.download.MusicDownloadRes;
import top.baogutang.music.enums.AudioFileTypeEnum;
/**
* Netease Cloud Music unified downloader:
* - Fetch song detail to get base info and maxBrLevel
* - Use maxBrLevel to request direct URL via eapi
* - Map to MusicDownloadRes
*/
@Service
public class NcmDownloadService {
private static final MediaType FORM = MediaType.parse("application/x-www-form-urlencoded");
private static final ObjectMapper MAPPER = new ObjectMapper();
private final OkHttpClient http = new OkHttpClient();
private static final String UA = "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Safari/537.36 Chrome/91.0.4472.164 NeteaseMusicDesktop/2.10.2.200154";
private static final String REFERER = "https://music.163.com/";
private static final String ORIGIN = "https://music.163.com";
private static final String CONTENT_TYPE = "application/x-www-form-urlencoded";
private static final String SONG_DETAIL_URL = "https://music.163.com/api/v3/song/detail";
private static final String URL_V1_EAPI = "https://interface3.music.163.com/eapi/song/enhance/player/url/v1";
private static final String AES_KEY = "e82ckenh8dichen8";
/**
* Main entry: provide song id and cookie, returns filled MusicDownloadRes.
*/
public MusicDownloadRes getDownloadRes(String id, String cookie) throws Exception {
DetailResult detail = fetchSongDetail(id, cookie);
String level = pickLevelOrFallback(detail.maxBrLevel);
UrlResult urlRes = fetchSongUrl(id, level, cookie);
if ((urlRes.url == null || urlRes.url.isEmpty()) && !"standard".equalsIgnoreCase(level)) {
String fallback = fallbackLevel(level);
if (fallback != null) {
urlRes = fetchSongUrl(id, fallback, cookie);
urlRes.level = fallback;
}
}
MusicDownloadRes out = new MusicDownloadRes();
out.setId(String.valueOf(detail.id));
out.setName(buildDisplayName(detail.name, detail.mainTitle, detail.additionalTitle));
out.setArtistName(String.join(", ", detail.artists));
out.setAlbumName(detail.albumName);
out.setPic(detail.picUrl);
out.setLevel(urlRes.level != null ? urlRes.level : level);
out.setUrl(urlRes.url);
out.setSize(humanSize(urlRes.size));
AudioFileTypeEnum fileType = inferFileType(urlRes.type, urlRes.url);
out.setFileType(fileType);
out.setLyric(null); // 可后续补充歌词接口
return out;
}
// ==================== Detail request & parse ====================
private DetailResult fetchSongDetail(String id, String cookie) throws Exception {
String cJson = "[{\"id\":" + id + ",\"v\":0}]";
String body = "c=" + urlEncode(cJson);
Request request = new Request.Builder()
.url(SONG_DETAIL_URL)
.post(RequestBody.create(FORM, body))
.header("User-Agent", UA)
.header("Referer", REFERER)
.header("Origin", ORIGIN)
.header("Content-Type", CONTENT_TYPE)
.header("Cookie", cookie == null ? "" : cookie)
.build();
try (Response resp = http.newCall(request).execute()) {
if (!resp.isSuccessful()) throw new RuntimeException("HTTP " + resp.code());
String text = resp.body().string();
String firstJson = extractFirstJsonObject(text);
JsonNode root = MAPPER.readTree(firstJson != null ? firstJson : text);
if (root.path("code").asInt() != 200) {
throw new RuntimeException("API error: code=" + root.path("code").asInt());
}
JsonNode songNode = root.path("songs").isArray() && root.path("songs").size() > 0
? root.path("songs").get(0) : MAPPER.createObjectNode();
DetailResult d = new DetailResult();
d.id = songNode.path("id").asLong();
d.name = songNode.path("name").asText();
d.mainTitle = songNode.path("mainTitle").asText(null);
d.additionalTitle = songNode.path("additionalTitle").asText(null);
d.durationMs = songNode.path("dt").asLong(0);
d.artists = new ArrayList<>();
JsonNode ar = songNode.path("ar");
if (ar.isArray()) {
for (JsonNode a : ar) d.artists.add(a.path("name").asText());
}
JsonNode al = songNode.path("al");
d.albumId = al.path("id").asLong();
d.albumName = al.path("name").asText();
d.picUrl = al.path("picUrl").asText();
String maxBrLevel = null;
JsonNode privileges = root.path("privileges");
if (privileges.isArray() && privileges.size() > 0) {
maxBrLevel = privileges.get(0).path("maxBrLevel").asText(null);
}
d.maxBrLevel = maxBrLevel;
return d;
}
}
private static class DetailResult {
long id;
String name;
String mainTitle;
String additionalTitle;
long durationMs;
List<String> artists;
long albumId;
String albumName;
String picUrl;
String maxBrLevel;
}
// ==================== URL request (eapi) ====================
private UrlResult fetchSongUrl(String id, String level, String rawCookie) throws Exception {
Map<String, Object> headerCfg = new LinkedHashMap<>();
headerCfg.put("os", "pc");
headerCfg.put("appver", "");
headerCfg.put("osver", "");
headerCfg.put("deviceId", "pyncm!");
headerCfg.put("requestId", String.valueOf(randomInt(20_000_000, 30_000_000)));
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("ids", Collections.singletonList(id));
payload.put("level", level);
payload.put("encodeType", "flac");
payload.put("header", MAPPER.writeValueAsString(headerCfg));
if ("sky".equalsIgnoreCase(level)) payload.put("immerseType", "c51");
String apiPath = eapiToApiPath(URL_V1_EAPI);
String payloadJson = MAPPER.writeValueAsString(payload);
String digest = md5Hex("nobody" + apiPath + "use" + payloadJson + "md5forencrypt");
String paramsPlain = apiPath + "-36cd479b6b5-" + payloadJson + "-36cd479b6b5-" + digest;
String encryptedHex = aes128EcbHex(paramsPlain, AES_KEY);
String cookieHeader = mergeCookies(rawCookie, headerCfg);
Request request = new Request.Builder()
.url(URL_V1_EAPI)
.post(RequestBody.create(FORM, "params=" + urlEncode(encryptedHex)))
.header("User-Agent", UA)
.header("Content-Type", CONTENT_TYPE)
.header("Cookie", cookieHeader)
.build();
UrlResult out = new UrlResult();
out.level = level;
try (Response resp = http.newCall(request).execute()) {
if (!resp.isSuccessful()) {
out.code = resp.code();
return out;
}
String body = resp.body().string();
JsonNode root = MAPPER.readTree(body);
out.code = root.path("code").asInt();
JsonNode data0 = root.path("data").isArray() && root.path("data").size() > 0
? root.path("data").get(0) : MAPPER.createObjectNode();
out.url = data0.path("url").asText(null);
out.size = data0.path("size").asLong(0);
out.type = data0.path("type").asText(null);
out.time = data0.path("time").asLong(0);
return out;
}
}
private static class UrlResult {
int code;
String url;
long size;
String type;
long time;
String level;
}
// ==================== Mapping & helpers ====================
private static String buildDisplayName(String name, String mainTitle, String additionalTitle) {
if (mainTitle != null && !mainTitle.isEmpty()) {
if (additionalTitle != null && !additionalTitle.isEmpty()) {
return (mainTitle + additionalTitle).trim();
}
return mainTitle.trim();
}
return name != null ? name.trim() : "";
}
private static AudioFileTypeEnum inferFileType(String type, String url) {
String t = (type != null ? type.toLowerCase() : "");
if (t.contains("flac")) return AudioFileTypeEnum.FLAC;
if (t.contains("mp3")) return AudioFileTypeEnum.MP3;
if (t.contains("aac") || t.contains("m4a")) return AudioFileTypeEnum.OTHERS;
if (url != null) {
String lower = url.toLowerCase();
if (lower.contains(".flac")) return AudioFileTypeEnum.FLAC;
if (lower.contains(".mp3")) return AudioFileTypeEnum.MP3;
if (lower.contains(".m4a") || lower.contains(".aac")) return AudioFileTypeEnum.OTHERS;
}
return AudioFileTypeEnum.FLAC; // 兜底
}
private static String humanSize(long bytes) {
if (bytes <= 0) return "0 B";
String[] units = {"B", "KB", "MB", "GB"};
int idx = (int) Math.min(units.length - 1, Math.floor(Math.log10(bytes) / Math.log10(1024)));
double val = bytes / Math.pow(1024, idx);
return String.format(Locale.ROOT, "%.2f %s", val, units[idx]);
}
private static String eapiToApiPath(String eapiUrl) {
HttpUrl httpUrl = HttpUrl.parse(eapiUrl);
String path = httpUrl != null ? httpUrl.encodedPath() : "";
return path.replace("/eapi/", "/api/");
}
private static String md5Hex(String text) throws Exception {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] digest = md.digest(text.getBytes(StandardCharsets.UTF_8));
return toHex(digest);
}
private static String aes128EcbHex(String plain, String key) throws Exception {
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
SecretKeySpec spec = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "AES");
cipher.init(Cipher.ENCRYPT_MODE, spec);
byte[] enc = cipher.doFinal(plain.getBytes(StandardCharsets.UTF_8));
return toHex(enc);
}
private static String toHex(byte[] data) {
StringBuilder sb = new StringBuilder(data.length * 2);
for (byte b : data) sb.append(String.format("%02x", b));
return sb.toString();
}
private static String urlEncode(String s) {
return URLEncoder.encode(s, StandardCharsets.UTF_8);
}
private static int randomInt(int minInclusive, int maxExclusive) {
return new Random().nextInt(maxExclusive - minInclusive) + minInclusive;
}
private static String mergeCookies(String rawCookieString, Map<String, Object> headerCfg) {
Map<String, String> cookies = parseCookie(rawCookieString);
cookies.put("os", String.valueOf(headerCfg.get("os")));
cookies.put("appver", String.valueOf(headerCfg.get("appver")));
cookies.put("osver", String.valueOf(headerCfg.get("osver")));
cookies.put("deviceId", String.valueOf(headerCfg.get("deviceId")));
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, String> e : cookies.entrySet()) {
if (sb.length() > 0) sb.append("; ");
sb.append(e.getKey()).append("=").append(e.getValue() == null ? "" : e.getValue());
}
return sb.toString();
}
private static Map<String, String> parseCookie(String text) {
Map<String, String> map = new LinkedHashMap<>();
if (text == null || text.isEmpty()) return map;
String[] parts = text.split(";");
for (String part : parts) {
String[] kv = part.trim().split("=", 2);
if (kv.length >= 1 && !kv[0].isEmpty()) {
map.put(kv[0], kv.length == 2 ? kv[1] : "");
}
}
return map;
}
private static String extractFirstJsonObject(String text) {
if (text == null) return null;
int start = text.indexOf('{');
if (start < 0) return null;
int depth = 0;
for (int i = start; i < text.length(); i++) {
char ch = text.charAt(i);
if (ch == '{') depth++;
else if (ch == '}') {
depth--;
if (depth == 0) return text.substring(start, i + 1);
}
}
return null;
}
/**
* 选择最佳音质等级当详情未提供或为空时回退到默认值
* 可选枚举通常为standardexhighlosslesshiressky
*/
private static String pickLevelOrFallback(String max) {
if (max == null || max.isEmpty()) {
return "lossless"; // 默认首选可按需改为 "exhigh" "standard"
}
// 规范化为小写并过滤未知值
String lvl = max.toLowerCase(Locale.ROOT);
switch (lvl) {
case "standard":
case "exhigh":
case "lossless":
case "hires":
case "sky":
return lvl;
default:
// 如果返回了未知字符串回退到较稳妥的 "lossless"
return "lossless";
}
}
/**
* 简单降级链sky hires lossless exhigh standard
* 当当前等级无法获取直链时调用该方法获取下一档
* 返回 null 表示已无法继续降级
*/
private static String fallbackLevel(String level) {
if (level == null || level.isEmpty()) return null;
switch (level.toLowerCase(Locale.ROOT)) {
case "sky":
return "hires";
case "hires":
return "lossless";
case "lossless":
return "exhigh";
case "exhigh":
return "standard";
default:
return null; // "standard" 或未知值时不再降级
}
}
}

View File

@ -86,13 +86,7 @@ public class MusicInfoServiceImpl implements IMusicInfoService {
} }
// 获取三方token // 获取三方token
String token = CacheUtil.cacheOrSupply(KEY_MUSIC_TAG_TOKEN_PREFIX, String token = geneToken();
1L,
TimeUnit.DAYS,
redisTemplate,
this::geneToken,
new TypeReference<>() {
});
Map<String, String> headers = new HashMap<>(); Map<String, String> headers = new HashMap<>();
headers.put("authorization", "jwt " + token); headers.put("authorization", "jwt " + token);
headers.put("Content-Type", "application/json"); headers.put("Content-Type", "application/json");

View File

@ -1,25 +1,31 @@
package top.baogutang.music.service.impl; package top.baogutang.music.service.impl;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.conditions.query.LambdaQueryChainWrapper; import com.baomidou.mybatisplus.extension.conditions.query.LambdaQueryChainWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import top.baogutang.music.dao.entity.MusicRecordEntity; import top.baogutang.music.dao.entity.MusicRecordEntity;
import top.baogutang.music.dao.mapper.MusicRecordMapper; import top.baogutang.music.dao.mapper.MusicRecordMapper;
import top.baogutang.music.domain.res.download.MusicDownloadRes; import top.baogutang.music.domain.res.download.MusicDownloadRes;
import top.baogutang.music.enums.ChannelEnum; import top.baogutang.music.enums.ChannelEnum;
import top.baogutang.music.enums.MusicQualityEnum; import top.baogutang.music.enums.MusicQualityEnum;
import top.baogutang.music.service.IMusicRecordService; import top.baogutang.music.service.IMusicRecordService;
import top.baogutang.music.service.IMusicService;
import top.baogutang.music.utils.PageUtil;
import javax.annotation.Resource;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.concurrent.Executor;
import java.util.function.Function;
/** /**
* *
* @description: * @description:
*
* @author: N1KO * @author: N1KO
* @date: 2024/12/11 : 15:38 * @date: 2024/12/11 : 15:38
*/ */
@ -27,6 +33,12 @@ import java.util.Objects;
@Service @Service
public class MusicRecordServiceImpl extends ServiceImpl<MusicRecordMapper, MusicRecordEntity> implements IMusicRecordService { public class MusicRecordServiceImpl extends ServiceImpl<MusicRecordMapper, MusicRecordEntity> implements IMusicRecordService {
@Resource(name = "commonExecutor")
private Executor commonExecutor;
@Resource
private IMusicService musicService;
@Override @Override
public void save(MusicDownloadRes res, ChannelEnum channel) { public void save(MusicDownloadRes res, ChannelEnum channel) {
MusicRecordEntity entity = new LambdaQueryChainWrapper<>(baseMapper) MusicRecordEntity entity = new LambdaQueryChainWrapper<>(baseMapper)
@ -88,4 +100,33 @@ public class MusicRecordServiceImpl extends ServiceImpl<MusicRecordMapper, Music
.one(); .one();
} }
@Override
public void triggerRecall() {
commonExecutor.execute(() -> {
int pageNo = 1;
int pageSize = 50;
while (true) {
IPage<MusicRecordEntity> pages = PageUtil.page(page -> baseMapper.selectPages(page),
pageNo,
pageSize,
Function.identity());
if (CollectionUtils.isEmpty(pages.getRecords())) {
log.info("pageNo:{} has not records", pageNo);
break;
}
pages.getRecords().forEach(entity -> {
musicService.getMusicService(entity.getChannel().getCode())
.processFile(entity);
});
if (pages.getRecords().size() >= pageSize) {
pageNo = pageNo + 1;
} else {
break;
}
}
log.info("process success");
});
}
} }

View File

@ -6,7 +6,6 @@ import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import top.baogutang.music.domain.req.AbstractMusicReq; import top.baogutang.music.domain.req.AbstractMusicReq;
import top.baogutang.music.domain.res.AbstractMusicRes; import top.baogutang.music.domain.res.AbstractMusicRes;
import top.baogutang.music.enums.ChannelEnum;
import top.baogutang.music.exceptions.BusinessException; import top.baogutang.music.exceptions.BusinessException;
import top.baogutang.music.service.AbstractMusicService; import top.baogutang.music.service.AbstractMusicService;
import top.baogutang.music.service.IMusicService; import top.baogutang.music.service.IMusicService;

View File

@ -0,0 +1,33 @@
package top.baogutang.music.utils;
import lombok.Data;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
/**
* @description:
* @author: nikooh
* @date: 2024/03/07 : 20:17
*/
@Data
public abstract class PageRequest {
private Integer pageSize = 20;
private Integer pageNum = 1;
private List<OrderBy> orders = new ArrayList<>();
@Data
public static class OrderBy implements Serializable {
private static final long serialVersionUID = -2936335557980068706L;
private String columnName;
private boolean desc;
}
}

View File

@ -0,0 +1,108 @@
package top.baogutang.music.utils;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.support.SFunction;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.apache.commons.lang3.StringUtils;
import org.springframework.util.CollectionUtils;
import java.util.List;
import java.util.function.Function;
import java.util.function.UnaryOperator;
import java.util.stream.Collectors;
/**
* @description:
* @author: nikooh
* @date: 2024/03/07 : 20:17
*/
public class PageUtil {
private PageUtil() {
// private empty constructor
}
/**
* general method of paging query
*
* @param queryFunction page query function
* @param pageParam page param
* @param beanCastFunction bean cast function
* @param <T> entity class
* @param <U> query parameter
* @param <R> return bean
* @return Page
*/
public static <T, U extends PageRequest, R> IPage<R> page(UnaryOperator<Page<T>> queryFunction, U pageParam, Function<T, R> beanCastFunction) {
return doPage(queryFunction, new Page<>(pageParam.getPageNum(), pageParam.getPageSize()), beanCastFunction);
}
public static <T, R> IPage<R> page(UnaryOperator<Page<T>> queryFunction, Integer pageNum, Integer pageSize, Function<T, R> beanCastFunction) {
return doPage(queryFunction, new Page<>(pageNum, pageSize), beanCastFunction);
}
public static <T, R> IPage<R> doPage(UnaryOperator<Page<T>> queryFunction, Page<T> pageParam, Function<T, R> beanCastFunction) {
Page<T> pageRes = queryFunction.apply(pageParam);
List<R> rPageRes = pageRes.getRecords().stream()
.map(beanCastFunction)
.collect(Collectors.toList());
Page<R> page = new Page<>(pageRes.getCurrent(), pageRes.getSize(), pageRes.getTotal());
page.setRecords(rPageRes);
return page;
}
/**
* default order set
*
* @param orders orders for order
* @param queryWrapper queryWrapper for query
* @param defaultColumn defaultColumn default sort column
* @param <T> T
*/
public static <T, R> void defaultOrder(List<PageRequest.OrderBy> orders, QueryWrapper<T> queryWrapper, SFunction<T, R> defaultColumn) {
if (CollectionUtils.isEmpty(orders)) {
defaultOrderProcess(queryWrapper, defaultColumn);
return;
}
orders.forEach(orderBy -> {
//
if (StringUtils.isBlank(orderBy.getColumnName())) {
defaultOrderProcess(queryWrapper, defaultColumn);
} else {
if (orderBy.isDesc()) {
queryWrapper.orderByDesc(orderBy.getColumnName());
} else {
queryWrapper.orderByAsc(orderBy.getColumnName());
}
}
});
}
/**
* @param queryWrapper queryWrapper for query
* @param defaultColumn defaultColumn default sort column
* @param <T> t
* @param <R> r
*/
private static <T, R> void defaultOrderProcess(QueryWrapper<T> queryWrapper, SFunction<T, R> defaultColumn) {
if (defaultColumn != null) {
queryWrapper.lambda()
.orderByDesc(defaultColumn);
}
}
}

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="top.baogutang.music.dao.mapper.MusicRecordMapper">
<select id="selectPages" resultType="top.baogutang.music.dao.entity.MusicRecordEntity">
select *
from t_music_record
where deleted = false
order by id desc
</select>
</mapper>