main
This commit is contained in:
parent
ba272e4b1b
commit
8c557fe65f
@ -328,4 +328,5 @@ public class KuGouMusicClient implements ChannelClient<MusicSearchReq, MusicSear
|
||||
public List<MusicRecordEntity> queryMusicByPlatformIdList(List<String> platformIdList) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@ import top.baogutang.music.processor.AbstractAudioProcessor;
|
||||
import top.baogutang.music.properties.NetEaseMusicProperties;
|
||||
import top.baogutang.music.service.IMusicDownloadRecordService;
|
||||
import top.baogutang.music.service.IMusicRecordService;
|
||||
import top.baogutang.music.service.NcmDownloadService;
|
||||
import top.baogutang.music.utils.OkHttpUtil;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
@ -28,11 +29,11 @@ import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
*
|
||||
* @description:
|
||||
*
|
||||
* @author: N1KO
|
||||
* @date: 2024/12/10 : 17:57
|
||||
*/
|
||||
@ -51,6 +52,9 @@ public class NetEaseMusicClient implements ChannelClient<MusicSearchReq, MusicSe
|
||||
@Resource
|
||||
private IMusicDownloadRecordService musicDownloadRecordService;
|
||||
|
||||
@Resource
|
||||
private NcmDownloadService ncmDownloadService;
|
||||
|
||||
@Override
|
||||
public MusicSearchRes search(MusicSearchReq req) {
|
||||
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
|
||||
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
|
||||
@ -121,6 +121,14 @@ public class NetEaseMusicClient implements ChannelClient<MusicSearchReq, MusicSe
|
||||
if (!Files.exists(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());
|
||||
try (InputStream musicIn = audioProcessor.processAudioTags(new URL(res.getUrl()).openStream(), res)) {
|
||||
if (Objects.nonNull(musicIn)) {
|
||||
|
||||
@ -36,7 +36,6 @@ import java.util.stream.Collectors;
|
||||
/**
|
||||
*
|
||||
* @description:
|
||||
*
|
||||
* @author: N1KO
|
||||
* @date: 2024/12/12 : 15:00
|
||||
*/
|
||||
@ -268,12 +267,21 @@ public class QQMusicClient implements ChannelClient<MusicSearchReq, MusicSearchR
|
||||
@Override
|
||||
public MusicDownloadRes download(String id) {
|
||||
String downloadUrl = String.format(qqMusicProperties.getDownloadBaseUrl1(), id);
|
||||
QQMusicDownloadRes qqMusicDownloadRes = OkHttpUtil.get(downloadUrl, null, null, new TypeReference<>() {
|
||||
});
|
||||
if (Objects.isNull(qqMusicDownloadRes) || Objects.isNull(qqMusicDownloadRes.getMusicUrlInfo())) {
|
||||
downloadUrl = String.format(qqMusicProperties.getDownloadBaseUrl2(), id);
|
||||
QQMusicDownloadRes qqMusicDownloadRes = null;
|
||||
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())) {
|
||||
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())) {
|
||||
return null;
|
||||
}
|
||||
@ -307,7 +315,7 @@ public class QQMusicClient implements ChannelClient<MusicSearchReq, MusicSearchR
|
||||
musicDownloadRes.setUrl(musicUrlInfo.getUrlInfo128().getUrl());
|
||||
musicDownloadRes.setLevel(musicUrlInfo.getUrlInfo128().getBitrate());
|
||||
}
|
||||
if(Objects.isNull(musicDownloadRes.getUrl())) {
|
||||
if (Objects.isNull(musicDownloadRes.getUrl())) {
|
||||
return null;
|
||||
}
|
||||
QQMusicDownloadRes.Lyrics lyric = qqMusicDownloadRes.getLyric();
|
||||
@ -350,10 +358,15 @@ public class QQMusicClient implements ChannelClient<MusicSearchReq, MusicSearchR
|
||||
if (Objects.isNull(musicQualityEnum)) {
|
||||
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());
|
||||
try (InputStream musicIn = audioProcessor.processAudioTags(new URL(res.getUrl()).openStream(), res)) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,13 +6,13 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import top.baogutang.music.domain.Results;
|
||||
import top.baogutang.music.schedule.QQDailyMusicHandler;
|
||||
import top.baogutang.music.service.IMusicRecordService;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
|
||||
/**
|
||||
*
|
||||
* @description:
|
||||
*
|
||||
* @author: N1KO
|
||||
* @date: 2024/12/23 : 15:26
|
||||
*/
|
||||
@ -24,9 +24,19 @@ public class MusicTriggerController {
|
||||
@Resource
|
||||
private QQDailyMusicHandler qqDailyMusicHandler;
|
||||
|
||||
@Resource
|
||||
private IMusicRecordService musicRecordService;
|
||||
|
||||
@GetMapping("/daily")
|
||||
public Results<Void> triggerDaily() {
|
||||
qqDailyMusicHandler.fetchDailyAndSave();
|
||||
return Results.ok();
|
||||
}
|
||||
|
||||
@GetMapping("/recall")
|
||||
public Results<Void> triggerRecall() {
|
||||
musicRecordService.triggerRecall();
|
||||
return Results.ok();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package top.baogutang.music.dao.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import top.baogutang.music.dao.entity.MusicRecordEntity;
|
||||
|
||||
@ -13,4 +14,7 @@ import top.baogutang.music.dao.entity.MusicRecordEntity;
|
||||
*/
|
||||
@Mapper
|
||||
public interface MusicRecordMapper extends BaseMapper<MusicRecordEntity> {
|
||||
|
||||
Page<MusicRecordEntity> selectPages(Page<MusicRecordEntity> page);
|
||||
|
||||
}
|
||||
|
||||
@ -28,4 +28,6 @@ public class NetEaseMusicProperties {
|
||||
|
||||
private String downloadPath;
|
||||
|
||||
private String cookie;
|
||||
|
||||
}
|
||||
|
||||
@ -22,12 +22,16 @@ import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
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.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
@ -36,7 +40,6 @@ import static top.baogutang.music.constants.BusinessKey.ADMIN_ID_LIST;
|
||||
/**
|
||||
*
|
||||
* @description:
|
||||
*
|
||||
* @author: N1KO
|
||||
* @date: 2024/12/10 : 17:00
|
||||
*/
|
||||
@ -114,6 +117,46 @@ public abstract class AbstractMusicService<Q extends AbstractMusicReq, S extends
|
||||
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) {
|
||||
if (CollectionUtils.isEmpty(idList)) {
|
||||
throw new BusinessException("请选择下载内容");
|
||||
@ -148,6 +191,7 @@ public abstract class AbstractMusicService<Q extends AbstractMusicReq, S extends
|
||||
channelClient.saveMusic(res);
|
||||
channelClient.saveDownloadRecord(userId, batchNo, id);
|
||||
try {
|
||||
log.info(">>>>>>>>>>download file:{} start<<<<<<<<<<", res.getName());
|
||||
channelClient.processFile(res);
|
||||
log.info(">>>>>>>>>>download file:{} success<<<<<<<<<<", res.getName());
|
||||
} catch (IOException e) {
|
||||
|
||||
@ -23,4 +23,7 @@ public interface IMusicRecordService extends IService<MusicRecordEntity> {
|
||||
List<MusicRecordEntity> queryByPlatformIdList(List<String> platformIdList);
|
||||
|
||||
MusicRecordEntity queryByNameOrAlbumOrArtist(String title, String album, String artist);
|
||||
|
||||
void triggerRecall();
|
||||
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择最佳音质等级;当详情未提供或为空时回退到默认值。
|
||||
* 可选枚举通常为:standard、exhigh、lossless、hires、sky。
|
||||
*/
|
||||
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" 或未知值时不再降级
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -86,13 +86,7 @@ public class MusicInfoServiceImpl implements IMusicInfoService {
|
||||
}
|
||||
|
||||
// 获取三方token
|
||||
String token = CacheUtil.cacheOrSupply(KEY_MUSIC_TAG_TOKEN_PREFIX,
|
||||
1L,
|
||||
TimeUnit.DAYS,
|
||||
redisTemplate,
|
||||
this::geneToken,
|
||||
new TypeReference<>() {
|
||||
});
|
||||
String token = geneToken();
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
headers.put("authorization", "jwt " + token);
|
||||
headers.put("Content-Type", "application/json");
|
||||
|
||||
@ -1,25 +1,31 @@
|
||||
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.service.impl.ServiceImpl;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import top.baogutang.music.dao.entity.MusicRecordEntity;
|
||||
import top.baogutang.music.dao.mapper.MusicRecordMapper;
|
||||
import top.baogutang.music.domain.res.download.MusicDownloadRes;
|
||||
import top.baogutang.music.enums.ChannelEnum;
|
||||
import top.baogutang.music.enums.MusicQualityEnum;
|
||||
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.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
*
|
||||
* @description:
|
||||
*
|
||||
* @author: N1KO
|
||||
* @date: 2024/12/11 : 15:38
|
||||
*/
|
||||
@ -27,6 +33,12 @@ import java.util.Objects;
|
||||
@Service
|
||||
public class MusicRecordServiceImpl extends ServiceImpl<MusicRecordMapper, MusicRecordEntity> implements IMusicRecordService {
|
||||
|
||||
@Resource(name = "commonExecutor")
|
||||
private Executor commonExecutor;
|
||||
|
||||
@Resource
|
||||
private IMusicService musicService;
|
||||
|
||||
@Override
|
||||
public void save(MusicDownloadRes res, ChannelEnum channel) {
|
||||
MusicRecordEntity entity = new LambdaQueryChainWrapper<>(baseMapper)
|
||||
@ -88,4 +100,33 @@ public class MusicRecordServiceImpl extends ServiceImpl<MusicRecordMapper, Music
|
||||
.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");
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -6,7 +6,6 @@ import org.springframework.beans.factory.config.BeanPostProcessor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import top.baogutang.music.domain.req.AbstractMusicReq;
|
||||
import top.baogutang.music.domain.res.AbstractMusicRes;
|
||||
import top.baogutang.music.enums.ChannelEnum;
|
||||
import top.baogutang.music.exceptions.BusinessException;
|
||||
import top.baogutang.music.service.AbstractMusicService;
|
||||
import top.baogutang.music.service.IMusicService;
|
||||
|
||||
33
src/main/java/top/baogutang/music/utils/PageRequest.java
Normal file
33
src/main/java/top/baogutang/music/utils/PageRequest.java
Normal 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;
|
||||
|
||||
}
|
||||
}
|
||||
108
src/main/java/top/baogutang/music/utils/PageUtil.java
Normal file
108
src/main/java/top/baogutang/music/utils/PageUtil.java
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
11
src/main/resources/mapper/MusicRecordMapper.xml
Normal file
11
src/main/resources/mapper/MusicRecordMapper.xml
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user