This commit is contained in:
N1KO 2024-12-15 16:04:33 +08:00
parent 7ed681b078
commit d55ab34c46
30 changed files with 691 additions and 69 deletions

View File

@ -5,6 +5,7 @@ import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
*
@ -17,6 +18,7 @@ import org.springframework.scheduling.annotation.EnableAsync;
@SpringBootApplication(scanBasePackages = {"top.baogutang.music.*"})
@MapperScan(basePackages = {"top.baogutang.music.dao.mapper"})
@EnableAsync
@EnableScheduling
public class BaogutangMusicApplication {
public static void main(String[] args) {

View File

@ -0,0 +1,19 @@
package top.baogutang.music.annos;
import top.baogutang.music.enums.UserLevel;
import java.lang.annotation.*;
/**
*
* @description:
*
* @author: N1KO
* @date: 2024/12/13 : 18:34
*/
@Documented
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Vip {
UserLevel latestLevel() default UserLevel.NORMAL;
}

View File

@ -22,7 +22,7 @@ import javax.servlet.http.HttpServletRequest;
import java.util.Arrays;
import java.util.Objects;
import static top.baogutang.music.constants.CacheKey.KEY_LOGIN_PREFIX;
import static top.baogutang.music.constants.CacheKey.KEY_USER_LOGIN_PREFIX;
/**
*
@ -106,7 +106,7 @@ public class LoginAspect {
log.info("request url:{},method:{}", request.getRequestURL(), request.getMethod());
UserLoginRes userLoginRes = TokenUtil.verify(token);
Long userId = userLoginRes.getId();
Object val = redisTemplate.opsForValue().get(KEY_LOGIN_PREFIX + userId);
Object val = redisTemplate.opsForValue().get(KEY_USER_LOGIN_PREFIX + userId);
if (Objects.isNull(val)) {
throw new LoginException("登录已失效");
}

View File

@ -0,0 +1,105 @@
package top.baogutang.music.aspect;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import top.baogutang.music.annos.Vip;
import top.baogutang.music.domain.res.user.UserLoginRes;
import top.baogutang.music.enums.UserLevel;
import top.baogutang.music.exceptions.LoginException;
import top.baogutang.music.exceptions.VipException;
import top.baogutang.music.service.IUserService;
import top.baogutang.music.utils.CacheUtil;
import top.baogutang.music.utils.TokenUtil;
import top.baogutang.music.utils.UserLevelThreadLocal;
import top.baogutang.music.utils.UserThreadLocal;
import javax.annotation.Resource;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Objects;
import static top.baogutang.music.aspect.LoginAspect.AUTHORIZATION;
import static top.baogutang.music.constants.CacheKey.KEY_USER_LEVEL_PREFIX;
import static top.baogutang.music.constants.CacheKey.KEY_USER_LOGIN_PREFIX;
/**
*
* @description:
*
* @author: N1KO
* @date: 2024/12/13 : 18:36
*/
@Slf4j
@Aspect
@Component
public class VipAspect {
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Resource
private IUserService userService;
@Pointcut("@annotation(top.baogutang.music.annos.Vip)")
public void vip() {
}
@Around("vip()")
public Object around(ProceedingJoinPoint point) throws Throwable {
HttpServletRequest request = ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();
MethodSignature signature = (MethodSignature) point.getSignature();
Method method = signature.getMethod();
Vip vip = method.getAnnotation(Vip.class);
if (vip != null) {
Cookie[] cookies = request.getCookies();
if (Objects.isNull(cookies) || cookies.length == 0) {
throw new LoginException("请登录后操作");
}
Cookie tokenCookie = Arrays.stream(cookies)
.filter(cookie -> AUTHORIZATION.equalsIgnoreCase(cookie.getName()))
.findFirst()
.orElse(null);
if (Objects.isNull(tokenCookie) || StringUtils.isBlank(tokenCookie.getValue())) {
throw new LoginException("请登录后操作");
}
String token = tokenCookie.getValue();
UserLoginRes userLoginRes = TokenUtil.verify(token);
Long userId = userLoginRes.getId();
Object val = redisTemplate.opsForValue().get(KEY_USER_LOGIN_PREFIX + userId);
if (Objects.isNull(val)) {
throw new LoginException("登录已失效");
}
if (!token.equals(val.toString())) {
throw new LoginException("登录已失效");
}
UserThreadLocal.set(userId);
String userLevelCacheKey = KEY_USER_LEVEL_PREFIX + userId;
UserLevel userLevel = CacheUtil.cacheOrSupply(userLevelCacheKey,
null,
null,
redisTemplate,
() -> userService.queryUserLevel(userId));
UserLevelThreadLocal.set(userLevel);
if (Objects.equals(UserLevel.NORMAL, userLevel)) {
throw new VipException("等级不够,请升级");
}
}
return point.proceed();
}
@After("vip()")
public void after() {
UserLevelThreadLocal.remove();
}
}

View File

@ -1,5 +1,6 @@
package top.baogutang.music.client;
import top.baogutang.music.dao.entity.MusicRecordEntity;
import top.baogutang.music.domain.req.AbstractMusicReq;
import top.baogutang.music.domain.req.search.MusicSearchReq;
import top.baogutang.music.domain.res.AbstractMusicRes;
@ -7,6 +8,7 @@ import top.baogutang.music.domain.res.download.MusicDownloadRes;
import top.baogutang.music.domain.res.search.*;
import java.io.IOException;
import java.io.InputStream;
/**
*
@ -32,4 +34,9 @@ public interface ChannelClient<Q extends AbstractMusicReq, S extends AbstractMus
void saveMusic(MusicDownloadRes res);
void processFile(MusicDownloadRes res) throws IOException;
MusicRecordEntity queryByPlatform(String id);
String getDownloadBasePath();
}

View File

@ -4,9 +4,11 @@ import com.fasterxml.jackson.core.type.TypeReference;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import top.baogutang.music.annos.ChannelInfo;
import top.baogutang.music.dao.entity.MusicRecordEntity;
import top.baogutang.music.domain.req.search.MusicSearchReq;
import top.baogutang.music.domain.res.download.MusicDownloadRes;
import top.baogutang.music.domain.res.search.*;
import top.baogutang.music.enums.AudioFileTypeEnum;
import top.baogutang.music.enums.ChannelEnum;
import top.baogutang.music.processor.AbstractAudioProcessor;
import top.baogutang.music.properties.NetEaseMusicProperties;
@ -84,7 +86,11 @@ public class NetEaseMusicClient implements ChannelClient<MusicSearchReq, MusicSe
});
if (Objects.nonNull(res)) {
res.setId(id);
String[] split = res.getUrl().split("\\.");
String fileType = split[split.length - 1];
res.setFileType(AudioFileTypeEnum.parse(fileType));
}
return res;
}
@ -102,14 +108,22 @@ public class NetEaseMusicClient implements ChannelClient<MusicSearchReq, MusicSe
if (!Files.exists(baseDir)) {
Files.createDirectories(baseDir);
}
String[] split = res.getUrl().split("\\.");
String fileType = split[split.length - 1];
AbstractAudioProcessor audioProcessor = AbstractAudioProcessor.getAudioProcessor(fileType);
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() + "." + fileType), StandardCopyOption.REPLACE_EXISTING);
Files.copy(musicIn, baseDir.resolve(res.getName() + "." + res.getFileType().name().toLowerCase()), StandardCopyOption.REPLACE_EXISTING);
}
}
}
@Override
public MusicRecordEntity queryByPlatform(String id) {
return musicRecordService.queryByChannelAndPlatform(ChannelEnum.NET_EASE_MUSIC, id);
}
@Override
public String getDownloadBasePath() {
return netEaseMusicProperties.getDownloadPath();
}
}

View File

@ -5,6 +5,7 @@ import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import top.baogutang.music.annos.ChannelInfo;
import top.baogutang.music.dao.entity.MusicRecordEntity;
import top.baogutang.music.domain.req.search.MusicSearchReq;
import top.baogutang.music.domain.res.download.MusicDownloadRes;
import top.baogutang.music.domain.res.download.QQMusicDownloadRes;
@ -302,6 +303,10 @@ public class QQMusicClient implements ChannelClient<MusicSearchReq, MusicSearchR
}
}
musicDownloadRes.setId(qqMusicDownloadRes.getSong().getMid());
MusicQualityEnum musicQualityEnum = MusicQualityEnum.parse(musicDownloadRes.getLevel());
if (Objects.nonNull(musicQualityEnum)) {
musicDownloadRes.setFileType(musicQualityEnum.getType());
}
return musicDownloadRes;
}
@ -324,13 +329,22 @@ public class QQMusicClient implements ChannelClient<MusicSearchReq, MusicSearchR
if (Objects.isNull(musicQualityEnum)) {
throw new BusinessException("不支持的文件格式");
}
String fileType = musicQualityEnum.getType();
AbstractAudioProcessor audioProcessor = AbstractAudioProcessor.getAudioProcessor(fileType);
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() + "." + fileType), StandardCopyOption.REPLACE_EXISTING);
Files.copy(musicIn, baseDir.resolve(res.getName() + "." + res.getFileType().name().toLowerCase()), StandardCopyOption.REPLACE_EXISTING);
}
}
}
@Override
public MusicRecordEntity queryByPlatform(String id) {
return musicRecordService.queryByChannelAndPlatform(ChannelEnum.QQ_MUSIC, id);
}
@Override
public String getDownloadBasePath() {
return qqMusicProperties.getDownloadPath();
}
}

View File

@ -4,7 +4,9 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import java.util.concurrent.ThreadPoolExecutor;
@ -46,4 +48,15 @@ public class ExecutorConfig {
return executor;
}
@Bean("taskExecutor")
public TaskScheduler taskScheduler() {
ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler();
taskScheduler.setPoolSize(maxPoolSize);
taskScheduler.setThreadNamePrefix(appName + "-schedule-");
taskScheduler.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
taskScheduler.setWaitForTasksToCompleteOnShutdown(true);
taskScheduler.setPoolSize(10);
return taskScheduler;
}
}

View File

@ -0,0 +1,27 @@
package top.baogutang.music.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.scheduling.annotation.SchedulingConfigurer;
import org.springframework.scheduling.config.ScheduledTaskRegistrar;
import javax.annotation.Resource;
/**
* @description: 定时调度任务线程配置
* @author: nikooh
* @date: 2024/01/09 : 17:56
*/
@Configuration
public class ScheduleConfig implements SchedulingConfigurer {
@Resource(name = "taskExecutor")
private TaskScheduler taskScheduler;
@Override
public void configureTasks(ScheduledTaskRegistrar scheduledTaskRegistrar) {
scheduledTaskRegistrar.setScheduler(taskScheduler);
}
}

View File

@ -15,7 +15,9 @@ public class CacheKey {
public static final String LOCK_REGISTER_PREFIX = "baogutang-music:lock:register:username:";
public static final String KEY_LOGIN_PREFIX = "baogutang-music:user:token:id:";
public static final String KEY_USER_LOGIN_PREFIX = "baogutang-music:user:token:id:";
public static final String KEY_USER_LEVEL_PREFIX = "baogutang-music:user:level:id:";
}

View File

@ -1,17 +1,21 @@
package top.baogutang.music.controller;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import top.baogutang.music.annos.Login;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
import top.baogutang.music.annos.Vip;
import top.baogutang.music.domain.Results;
import top.baogutang.music.domain.res.download.MusicDownloadRes;
import top.baogutang.music.domain.res.search.MusicPlaylistRes;
import top.baogutang.music.domain.ZipMetadata;
import top.baogutang.music.service.IMusicService;
import top.baogutang.music.service.ZipService;
import top.baogutang.music.utils.UserThreadLocal;
import javax.annotation.Resource;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.time.LocalDateTime;
import java.util.List;
/**
*
@ -28,13 +32,59 @@ public class MusicDownloadController {
@Resource
private IMusicService musicService;
@Resource
private ZipService zipService;
@GetMapping
@Login
public Results<MusicDownloadRes> download(@RequestParam(name = "channel") Integer channel,
@RequestParam(name = "id") String id) {
MusicDownloadRes res = musicService.getMusicService(channel).download(id);
@Vip
public Results<String> download(@RequestParam(name = "channel") Integer channel,
@RequestParam(name = "id") List<String> idList) {
String res = musicService.getMusicService(channel).download(UserThreadLocal.get(), idList);
return Results.ok(res);
}
/**
* 提供一个接口让前端可以通过URL下载ZIP文件
*
* @param zipFileName ZIP文件名
* @return ZIP文件流
*/
@GetMapping("/downloads/{zipFileName}")
public ResponseEntity<?> downloadZip(@PathVariable String zipFileName) {
ZipMetadata metadata = zipService.getZipMetadata(zipFileName);
if (metadata == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body("ZIP文件不存在或已过期");
}
// 检查是否过期
if (LocalDateTime.now().isAfter(metadata.getExpirationTime())) {
// 删除过期的ZIP文件
File expiredZip = new File(metadata.getZipFilePath());
if (expiredZip.exists()) {
expiredZip.delete();
}
// 从映射中移除
zipService.removeZip(zipFileName);
return ResponseEntity.status(HttpStatus.GONE).body("下载链接已过期");
}
File zipFile = new File(metadata.getZipFilePath());
if (!zipFile.exists()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body("ZIP文件不存在");
}
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
headers.setContentDisposition(ContentDisposition.builder("attachment").filename(zipFileName).build());
try {
byte[] fileContent = Files.readAllBytes(zipFile.toPath());
return new ResponseEntity<>(fileContent, headers, HttpStatus.OK);
} catch (IOException e) {
log.error(">>>>>>>>>>读取zip文件失败:{}<<<<<<<<<<", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("读取ZIP文件失败");
}
}
}

View File

@ -0,0 +1,20 @@
package top.baogutang.music.controller;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
*
* @description:
*
* @author: N1KO
* @date: 2024/12/15 : 11:52
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/music/order")
public class OrderController {
}

View File

@ -3,6 +3,7 @@ package top.baogutang.music.dao.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import top.baogutang.music.enums.AudioFileTypeEnum;
import top.baogutang.music.enums.ChannelEnum;
import top.baogutang.music.enums.MusicQualityEnum;
@ -26,6 +27,8 @@ public class MusicRecordEntity extends BaseEntity {
private String name;
private AudioFileTypeEnum fileType;
private String albumName;
private String artistName;

View File

@ -0,0 +1,23 @@
package top.baogutang.music.domain;
import java.time.LocalDateTime;
public class ZipMetadata {
private final String zipFilePath;
private final LocalDateTime expirationTime;
public ZipMetadata(String zipFilePath, LocalDateTime expirationTime) {
this.zipFilePath = zipFilePath;
this.expirationTime = expirationTime;
}
public String getZipFilePath() {
return zipFilePath;
}
public LocalDateTime getExpirationTime() {
return expirationTime;
}
}

View File

@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
import top.baogutang.music.domain.res.AbstractMusicRes;
import top.baogutang.music.enums.AudioFileTypeEnum;
import java.io.Serializable;
@ -40,4 +41,5 @@ public class MusicDownloadRes extends AbstractMusicRes implements Serializable {
private String lyric;
private AudioFileTypeEnum fileType;
}

View File

@ -35,20 +35,20 @@ public enum MusicQualityEnum {
SKY("沉浸环绕声", null),
DOLBY("杜比全景声", null),
JYMASTER("超清母带", null),
KBPS_128("128kbps", "mp3"),
KBPS_320("320kbps", "mp3"),
ATMOS_5_1("Atmos 5.1", "flac"),
ATMOS_2("Atmos 2", "flac"),
MASTER("Master", "flac"),
FLAC("flac", "flac"),
KBPS_128("128kbps", AudioFileTypeEnum.MP3),
KBPS_320("320kbps", AudioFileTypeEnum.MP3),
ATMOS_5_1("Atmos 5.1", AudioFileTypeEnum.FLAC),
ATMOS_2("Atmos 2", AudioFileTypeEnum.FLAC),
MASTER("Master", AudioFileTypeEnum.FLAC),
FLAC("flac", AudioFileTypeEnum.FLAC),
;
private final String desc;
private final String type;
private final AudioFileTypeEnum type;
MusicQualityEnum(String desc, String type) {
MusicQualityEnum(String desc, AudioFileTypeEnum type) {
this.desc = desc;
this.type = type;
}

View File

@ -0,0 +1,36 @@
package top.baogutang.music.exceptions;
import top.baogutang.music.domain.Results;
/**
*
* @description:
*
* @author: N1KO
* @date: 2024/12/15 : 11:36
*/
public class VipException extends RuntimeException {
private int code = Results.FAIL_CODE;
public VipException(int code, String message) {
super(message);
this.code = code;
}
public VipException(String message) {
super(message);
this.code = -300;
}
public int getCode() {
return code;
}
public void setCode(int code) {
this.code = code;
}
}

View File

@ -23,9 +23,8 @@ import java.net.URL;
@Slf4j
public abstract class AbstractAudioProcessor {
public static AbstractAudioProcessor getAudioProcessor(String fileType) {
AudioFileTypeEnum fileTypeEnum = AudioFileTypeEnum.parse(fileType);
switch (fileTypeEnum) {
public static AbstractAudioProcessor getAudioProcessor(AudioFileTypeEnum fileType) {
switch (fileType) {
case FLAC:
return new FlacAudioProcessor();
case MP3:

View File

@ -0,0 +1,49 @@
package top.baogutang.music.schedules;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import top.baogutang.music.domain.ZipMetadata;
import top.baogutang.music.service.ZipService;
import javax.annotation.Resource;
import java.io.File;
import java.time.LocalDateTime;
import java.util.Iterator;
import java.util.Map;
@Slf4j
@Component
public class ZipCleanupScheduler {
@Resource
private ZipService zipService;
// 每小时执行一次
@Scheduled(cron = "0 0 * * * ?")
public void cleanupOldZips() {
Map<String, ZipMetadata> zipStore = zipService.getZipStore();
Iterator<Map.Entry<String, ZipMetadata>> iterator = zipStore.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, ZipMetadata> entry = iterator.next();
ZipMetadata metadata = entry.getValue();
if (LocalDateTime.now().isAfter(metadata.getExpirationTime())) {
// 删除ZIP文件
File zipFile = new File(metadata.getZipFilePath());
if (zipFile.exists()) {
boolean deleted = zipFile.delete();
if (deleted) {
log.info(">>>>>>>>>>Deleted expired ZIP{}<<<<<<<<<<", metadata.getZipFilePath());
} else {
log.error(">>>>>>>>>>Failed to delete ZIP:{}<<<<<<<<<<", metadata.getZipFilePath());
}
}
// 从映射中移除
iterator.remove();
}
}
}
}

View File

@ -2,7 +2,12 @@ package top.baogutang.music.service;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.tuple.Pair;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.util.CollectionUtils;
import top.baogutang.music.client.ChannelClient;
import top.baogutang.music.dao.entity.MusicRecordEntity;
import top.baogutang.music.domain.req.AbstractMusicReq;
import top.baogutang.music.domain.req.search.MusicSearchReq;
import top.baogutang.music.domain.res.AbstractMusicRes;
@ -13,10 +18,14 @@ import top.baogutang.music.exceptions.BusinessException;
import top.baogutang.music.factory.ChannelClientFactory;
import javax.annotation.Resource;
import java.io.File;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import java.util.stream.Collectors;
/**
*
@ -34,6 +43,9 @@ public abstract class AbstractMusicService<Q extends AbstractMusicReq, S extends
@Resource(name = "commonExecutor")
private Executor commonExecutor;
@Resource
private ZipService zipService;
public abstract ChannelEnum getChannelEnum();
public MusicSearchRes search(MusicSearchReq req) {
@ -66,36 +78,65 @@ public abstract class AbstractMusicService<Q extends AbstractMusicReq, S extends
return channelClient.artist(id);
}
public MusicDownloadRes download(String id) {
public String download(Long userId, List<String> idList) {
if (CollectionUtils.isEmpty(idList)) {
throw new BusinessException("请选择文件进行下载");
}
ChannelEnum channelEnum = getChannelEnum();
ChannelClient<Q, S> channelClient = channelClientFactory.getClient(channelEnum);
MusicDownloadRes res = channelClient.download(id);
if (Objects.isNull(res)) {
log.error(">>>>>>>>>>query detail error! channel:{},song id:{}<<<<<<<<<<", channelEnum.getDesc(), id);
return null;
}
if (StringUtils.isNotBlank(res.getArtistName())) {
String[] split = res.getArtistName().split("/");
String artistName = String.join(",", split);
res.setArtistName(artistName);
Map<String, String> filesWithPaths = idList.stream()
.map(id ->
CompletableFuture.supplyAsync(() -> {
MusicRecordEntity musicRecord = channelClient.queryByPlatform(id);
String filePath;
String zipEntryPath;
if (Objects.nonNull(musicRecord)) {
// 当前歌曲已经下载过读取本地文件
zipEntryPath = musicRecord.getArtistName() + "/" + musicRecord.getAlbumName() + "/" + musicRecord.getName() + "." + musicRecord.getFileType().name().toLowerCase();
filePath = channelClient.getDownloadBasePath() + File.separator + musicRecord.getArtistName()
+ File.separator + musicRecord.getAlbumName()
+ File.separator + musicRecord.getName() +
"." + musicRecord.getFileType().name().toLowerCase();
return Pair.of(filePath, zipEntryPath);
}
// 不存在先下载
MusicDownloadRes res = channelClient.download(id);
if (Objects.isNull(res)) {
log.error(">>>>>>>>>>query detail error! channel:{},song id:{}<<<<<<<<<<", channelEnum.getDesc(), id);
return null;
}
if (StringUtils.isNotBlank(res.getArtistName())) {
String[] split = res.getArtistName().split("/");
String artistName = String.join(",", split);
res.setArtistName(artistName);
}
channelClient.saveMusic(res);
try {
channelClient.processFile(res);
log.info(">>>>>>>>>>download file:{} success<<<<<<<<<<", res.getName());
} catch (IOException e) {
log.error(">>>>>>>>>>download error:{}<<<<<<<<<<", e.getMessage(), e);
return null;
}
filePath = channelClient.getDownloadBasePath() + File.separator + res.getArtistName()
+ File.separator + res.getAlbumName()
+ File.separator + res.getName() +
"." + res.getFileType().name().toLowerCase();
zipEntryPath = res.getArtistName() + "/" + res.getAlbumName() + "/" + res.getName() + "." + res.getFileType().name().toLowerCase();
return Pair.of(filePath, zipEntryPath);
}, commonExecutor))
.map(CompletableFuture::join)
.filter(pair -> Objects.nonNull(pair.getKey()))
.collect(Collectors.toMap(Pair::getKey, Pair::getValue));
try {
return zipService.createZip(filesWithPaths,userId);
} catch (IOException e) {
log.error(">>>>>>>>>>gene zip error:{}<<<<<<<<<<", e.getMessage(), e);
throw new BusinessException("请稍后重试");
}
channelClient.saveMusic(res);
CompletableFuture.runAsync(() -> {
try {
channelClient.processFile(res);
log.info(">>>>>>>>>>download file:{} success<<<<<<<<<<", res.getName());
} catch (IOException e) {
log.error(">>>>>>>>>>download error:{}<<<<<<<<<<", e.getMessage(), e);
throw new BusinessException("下载异常");
}
}, commonExecutor);
// try {
// channelClient.processFile(res);
// } catch (IOException e) {
// log.error(">>>>>>>>>>download error:{}<<<<<<<<<<", e.getMessage(), e);
// throw new BusinessException("下载异常");
// }
return res;
}
}

View File

@ -15,4 +15,6 @@ import top.baogutang.music.enums.ChannelEnum;
public interface IMusicRecordService extends IService<MusicRecordEntity> {
void save(MusicDownloadRes res, ChannelEnum channel);
MusicRecordEntity queryByChannelAndPlatform(ChannelEnum channel, String platformId);
}

View File

@ -1,6 +1,5 @@
package top.baogutang.music.service;
import top.baogutang.music.enums.ChannelEnum;
/**
*

View File

@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.extension.service.IService;
import top.baogutang.music.dao.entity.UserEntity;
import top.baogutang.music.domain.req.user.UserRegisterAndLoginReq;
import top.baogutang.music.domain.res.user.UserLoginRes;
import top.baogutang.music.enums.UserLevel;
/**
*
@ -19,4 +20,6 @@ public interface IUserService extends IService<UserEntity> {
UserLoginRes login(UserRegisterAndLoginReq req);
void logout(Long id);
UserLevel queryUserLevel(Long userId);
}

View File

@ -0,0 +1,72 @@
package top.baogutang.music.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import top.baogutang.music.domain.ZipMetadata;
import top.baogutang.music.utils.ZipUtil;
import javax.annotation.PostConstruct;
import java.io.File;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
@Slf4j
@Service
public class ZipService {
@Value("${baogutang.music.zip.base-path}")
private String zipPath;
// 存储ZIP文件的元数据键为ZIP文件名
private static final ConcurrentMap<String, ZipMetadata> zipStore = new ConcurrentHashMap<>();
// 定义ZIP文件的有效时长例如1小时
private static final long ZIP_VALID_DURATION_HOURS = 1;
@PostConstruct
public void init() {
File zipDir = new File(zipPath);
if (!zipDir.exists()) {
boolean created = zipDir.mkdirs();
if (created) {
log.info(">>>>>>>>>>ZIP目录创建成功:{}<<<<<<<<<<", zipPath);
} else {
log.info(">>>>>>>>>>无法创建ZIP目录:{}<<<<<<<<<<", zipPath);
}
}
}
public String createZip(Map<String, String> filesWithPaths, Long userId) throws IOException {
String zipFileName = "music_download_" + UUID.randomUUID() + ".zip";
String zipFilePath = zipPath + File.separator + userId + File.separator + zipFileName;
// 创建ZIP文件
ZipUtil.createZip(filesWithPaths, zipFilePath);
// 计算过期时间
LocalDateTime expirationTime = LocalDateTime.now().plusHours(ZIP_VALID_DURATION_HOURS);
// 存储ZIP元数据
zipStore.put(zipFileName, new ZipMetadata(zipFilePath, expirationTime));
return zipFileName;
}
public ZipMetadata getZipMetadata(String zipFileName) {
return zipStore.get(zipFileName);
}
public void removeZip(String zipFileName) {
zipStore.remove(zipFileName);
}
public ConcurrentMap<String, ZipMetadata> getZipStore() {
return zipStore;
}
}

View File

@ -40,6 +40,7 @@ public class MusicRecordServiceImpl extends ServiceImpl<MusicRecordMapper, Music
entity = new MusicRecordEntity();
entity.setPlatformId(String.valueOf(res.getId()));
entity.setName(res.getName());
entity.setFileType(res.getFileType());
entity.setAlbumName(res.getAlbumName());
entity.setArtistName(res.getArtistName());
MusicQualityEnum musicQualityEnum = MusicQualityEnum.parse(res.getLevel());
@ -55,4 +56,14 @@ public class MusicRecordServiceImpl extends ServiceImpl<MusicRecordMapper, Music
entity.setCreateTime(LocalDateTime.now());
baseMapper.insert(entity);
}
@Override
public MusicRecordEntity queryByChannelAndPlatform(ChannelEnum channel, String platformId) {
return new LambdaQueryChainWrapper<>(baseMapper)
.eq(MusicRecordEntity::getChannel, channel)
.eq(MusicRecordEntity::getPlatformId, platformId)
.eq(MusicRecordEntity::getDeleted, Boolean.FALSE)
.last(" limit 1 ")
.one();
}
}

View File

@ -25,8 +25,7 @@ import java.time.LocalDateTime;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import static top.baogutang.music.constants.CacheKey.KEY_LOGIN_PREFIX;
import static top.baogutang.music.constants.CacheKey.LOCK_REGISTER_PREFIX;
import static top.baogutang.music.constants.CacheKey.KEY_USER_LOGIN_PREFIX;
/**
*
@ -76,14 +75,30 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, UserEntity> impleme
loginRes.setRole(existsUser.getRole());
// 生成token
String token = TokenUtil.token(existsUser.getId(), loginRes.getUsername());
redisTemplate.opsForValue().set(KEY_LOGIN_PREFIX + existsUser.getId(), token, 86400000, TimeUnit.MILLISECONDS);
redisTemplate.opsForValue().set(KEY_USER_LOGIN_PREFIX + existsUser.getId(), token, 86400000, TimeUnit.MILLISECONDS);
loginRes.setToken(token);
return loginRes;
}
@Override
public void logout(Long id) {
redisTemplate.delete(KEY_LOGIN_PREFIX + id);
redisTemplate.delete(KEY_USER_LOGIN_PREFIX + id);
}
@Override
public UserLevel queryUserLevel(Long userId) {
if (Objects.isNull(userId)) {
return null;
}
UserEntity user = new LambdaQueryChainWrapper<>(baseMapper)
.eq(UserEntity::getId, userId)
.eq(UserEntity::getDeleted, Boolean.FALSE)
.last(" limit 1 ")
.one();
if (Objects.nonNull(user)) {
return user.getLevel();
}
return null;
}
private UserEntity validate(UserRegisterAndLoginReq req) {

View File

@ -0,0 +1,28 @@
package top.baogutang.music.utils;
import top.baogutang.music.enums.UserLevel;
/**
*
* @description:
*
* @author: N1KO
* @date: 2024/12/15 : 11:20
*/
public class UserLevelThreadLocal {
private static final ThreadLocal<UserLevel> levelThread = new ThreadLocal<>();
public static void set(UserLevel userLevel) {
levelThread.set(userLevel);
}
public static UserLevel get() {
return levelThread.get();
}
public static void remove() {
levelThread.remove();
}
}

View File

@ -0,0 +1,56 @@
package top.baogutang.music.utils;
import java.io.*;
import java.util.Map;
import java.util.zip.*;
/**
* @description: JacksonUtil
* @author: nikooh
* @date: 2024/08/06 : 10:50
*/
public class ZipUtil {
/**
* 将多个文件按照指定的目录结构打包成一个ZIP文件
*
* @param filesWithPaths Map每个键是文件的绝对路径值是ZIP中的相对路径歌手/专辑/歌曲
* @param zipFilePath 输出ZIP文件的路径
* @throws IOException
*/
public static void createZip(Map<String, String> filesWithPaths, String zipFilePath) throws IOException {
try (
FileOutputStream fos = new FileOutputStream(zipFilePath);
BufferedOutputStream bos = new BufferedOutputStream(fos);
ZipOutputStream zos = new ZipOutputStream(bos)
) {
for (Map.Entry<String, String> entry : filesWithPaths.entrySet()) {
String filePath = entry.getKey();
// 确保使用正斜杠
String zipEntryName = entry.getValue().replace("\\", "/");
File file = new File(filePath);
if (!file.exists()) {
// 跳过不存在的文件
continue;
}
try (FileInputStream fis = new FileInputStream(file);
BufferedInputStream bis = new BufferedInputStream(fis)
) {
ZipEntry zipEntry = new ZipEntry(zipEntryName);
zos.putNextEntry(zipEntry);
byte[] buffer = new byte[4096];
int len;
while ((len = bis.read(buffer)) != -1) {
zos.write(buffer, 0, len);
}
zos.closeEntry();
}
}
}
}
}

View File

@ -39,6 +39,10 @@ knife4j:
baogutang:
music:
zip:
# base-path: /downloads/music/zips
base-path: /Users/nikooh/Desktop/downloads/music/zips
net-ease-music:
query-base-url: http://117.72.78.133:5173/search?keywords=%s&limit=%d&offset=%d&type=%d
playlist-base-url: http://117.72.78.133:5173/playlist/track/all?id=%d

View File

@ -39,19 +39,22 @@
color: #333;
cursor: pointer;
position: relative;
padding: 5px 10px;
border-radius: 5px;
}
/* Dropdown Menu Styles */
.dropdown {
position: absolute;
top: 100%;
right: 0;
background-color: #fff;
border: 1px solid #ddd;
background-color: #f9f9f9; /* 修改为浅灰色 */
border: 1px solid #ccc; /* 较深的边框 */
border-radius: 5px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
display: none;
min-width: 150px;
z-index: 1000;
transition: opacity 0.3s ease; /* 可选:添加过渡效果 */
}
.dropdown.visible {
display: block;
@ -59,14 +62,16 @@
.dropdown button {
width: 100%;
padding: 10px;
background: none;
background: #f9f9f9; /* 与下拉菜单背景一致 */
border: none;
text-align: left;
cursor: pointer;
font-size: 1rem;
color: #333; /* 深色文本 */
transition: background-color 0.3s ease; /* 可选:添加过渡效果 */
}
.dropdown button:hover {
background-color: #f0f0f0;
background-color: #e0e0e0; /* 悬停时颜色变化 */
}
.form-group {
margin-bottom: 20px;
@ -204,6 +209,8 @@
}
}
/* 其他样式保持不变 */
.album-list {
display: flex;
flex-wrap: wrap;
@ -937,7 +944,6 @@
if (data.code === 200) {
// 清除可访问的cookie如username
deleteCookie('username');
deleteCookie('token');
// 由于token是HttpOnly无法通过JavaScript清除后端已通过响应头清除
// 跳转回登录页面
window.location.href = '/login.html';