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.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync; 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.*"}) @SpringBootApplication(scanBasePackages = {"top.baogutang.music.*"})
@MapperScan(basePackages = {"top.baogutang.music.dao.mapper"}) @MapperScan(basePackages = {"top.baogutang.music.dao.mapper"})
@EnableAsync @EnableAsync
@EnableScheduling
public class BaogutangMusicApplication { public class BaogutangMusicApplication {
public static void main(String[] args) { 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.Arrays;
import java.util.Objects; 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()); log.info("request url:{},method:{}", request.getRequestURL(), request.getMethod());
UserLoginRes userLoginRes = TokenUtil.verify(token); UserLoginRes userLoginRes = TokenUtil.verify(token);
Long userId = userLoginRes.getId(); 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)) { if (Objects.isNull(val)) {
throw new LoginException("登录已失效"); 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; 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.AbstractMusicReq;
import top.baogutang.music.domain.req.search.MusicSearchReq; import top.baogutang.music.domain.req.search.MusicSearchReq;
import top.baogutang.music.domain.res.AbstractMusicRes; 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 top.baogutang.music.domain.res.search.*;
import java.io.IOException; 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 saveMusic(MusicDownloadRes res);
void processFile(MusicDownloadRes res) throws IOException; 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 lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import top.baogutang.music.annos.ChannelInfo; 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.req.search.MusicSearchReq;
import top.baogutang.music.domain.res.download.MusicDownloadRes; import top.baogutang.music.domain.res.download.MusicDownloadRes;
import top.baogutang.music.domain.res.search.*; import top.baogutang.music.domain.res.search.*;
import top.baogutang.music.enums.AudioFileTypeEnum;
import top.baogutang.music.enums.ChannelEnum; import top.baogutang.music.enums.ChannelEnum;
import top.baogutang.music.processor.AbstractAudioProcessor; import top.baogutang.music.processor.AbstractAudioProcessor;
import top.baogutang.music.properties.NetEaseMusicProperties; import top.baogutang.music.properties.NetEaseMusicProperties;
@ -84,7 +86,11 @@ public class NetEaseMusicClient implements ChannelClient<MusicSearchReq, MusicSe
}); });
if (Objects.nonNull(res)) { if (Objects.nonNull(res)) {
res.setId(id); res.setId(id);
String[] split = res.getUrl().split("\\.");
String fileType = split[split.length - 1];
res.setFileType(AudioFileTypeEnum.parse(fileType));
} }
return res; return res;
} }
@ -102,14 +108,22 @@ public class NetEaseMusicClient implements ChannelClient<MusicSearchReq, MusicSe
if (!Files.exists(baseDir)) { if (!Files.exists(baseDir)) {
Files.createDirectories(baseDir); Files.createDirectories(baseDir);
} }
String[] split = res.getUrl().split("\\."); AbstractAudioProcessor audioProcessor = AbstractAudioProcessor.getAudioProcessor(res.getFileType());
String fileType = split[split.length - 1];
AbstractAudioProcessor audioProcessor = AbstractAudioProcessor.getAudioProcessor(fileType);
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() + "." + 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.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import top.baogutang.music.annos.ChannelInfo; 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.req.search.MusicSearchReq;
import top.baogutang.music.domain.res.download.MusicDownloadRes; import top.baogutang.music.domain.res.download.MusicDownloadRes;
import top.baogutang.music.domain.res.download.QQMusicDownloadRes; import top.baogutang.music.domain.res.download.QQMusicDownloadRes;
@ -302,6 +303,10 @@ public class QQMusicClient implements ChannelClient<MusicSearchReq, MusicSearchR
} }
} }
musicDownloadRes.setId(qqMusicDownloadRes.getSong().getMid()); musicDownloadRes.setId(qqMusicDownloadRes.getSong().getMid());
MusicQualityEnum musicQualityEnum = MusicQualityEnum.parse(musicDownloadRes.getLevel());
if (Objects.nonNull(musicQualityEnum)) {
musicDownloadRes.setFileType(musicQualityEnum.getType());
}
return musicDownloadRes; return musicDownloadRes;
} }
@ -324,13 +329,22 @@ public class QQMusicClient implements ChannelClient<MusicSearchReq, MusicSearchR
if (Objects.isNull(musicQualityEnum)) { if (Objects.isNull(musicQualityEnum)) {
throw new BusinessException("不支持的文件格式"); throw new BusinessException("不支持的文件格式");
} }
String fileType = musicQualityEnum.getType(); AbstractAudioProcessor audioProcessor = AbstractAudioProcessor.getAudioProcessor(res.getFileType());
AbstractAudioProcessor audioProcessor = AbstractAudioProcessor.getAudioProcessor(fileType);
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() + "." + 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.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.ThreadPoolExecutor;
@ -46,4 +48,15 @@ public class ExecutorConfig {
return executor; 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 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; package top.baogutang.music.controller;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.http.*;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.RequestParam; import top.baogutang.music.annos.Vip;
import org.springframework.web.bind.annotation.RestController;
import top.baogutang.music.annos.Login;
import top.baogutang.music.domain.Results; import top.baogutang.music.domain.Results;
import top.baogutang.music.domain.res.download.MusicDownloadRes; import top.baogutang.music.domain.ZipMetadata;
import top.baogutang.music.domain.res.search.MusicPlaylistRes;
import top.baogutang.music.service.IMusicService; import top.baogutang.music.service.IMusicService;
import top.baogutang.music.service.ZipService;
import top.baogutang.music.utils.UserThreadLocal;
import javax.annotation.Resource; 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 @Resource
private IMusicService musicService; private IMusicService musicService;
@Resource
private ZipService zipService;
@GetMapping @GetMapping
@Login @Vip
public Results<MusicDownloadRes> download(@RequestParam(name = "channel") Integer channel, public Results<String> download(@RequestParam(name = "channel") Integer channel,
@RequestParam(name = "id") String id) { @RequestParam(name = "id") List<String> idList) {
MusicDownloadRes res = musicService.getMusicService(channel).download(id); String res = musicService.getMusicService(channel).download(UserThreadLocal.get(), idList);
return Results.ok(res); 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 com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;
import top.baogutang.music.enums.AudioFileTypeEnum;
import top.baogutang.music.enums.ChannelEnum; import top.baogutang.music.enums.ChannelEnum;
import top.baogutang.music.enums.MusicQualityEnum; import top.baogutang.music.enums.MusicQualityEnum;
@ -26,6 +27,8 @@ public class MusicRecordEntity extends BaseEntity {
private String name; private String name;
private AudioFileTypeEnum fileType;
private String albumName; private String albumName;
private String artistName; 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.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import top.baogutang.music.domain.res.AbstractMusicRes; import top.baogutang.music.domain.res.AbstractMusicRes;
import top.baogutang.music.enums.AudioFileTypeEnum;
import java.io.Serializable; import java.io.Serializable;
@ -40,4 +41,5 @@ public class MusicDownloadRes extends AbstractMusicRes implements Serializable {
private String lyric; private String lyric;
private AudioFileTypeEnum fileType;
} }

View File

@ -35,20 +35,20 @@ public enum MusicQualityEnum {
SKY("沉浸环绕声", null), SKY("沉浸环绕声", null),
DOLBY("杜比全景声", null), DOLBY("杜比全景声", null),
JYMASTER("超清母带", null), JYMASTER("超清母带", null),
KBPS_128("128kbps", "mp3"), KBPS_128("128kbps", AudioFileTypeEnum.MP3),
KBPS_320("320kbps", "mp3"), KBPS_320("320kbps", AudioFileTypeEnum.MP3),
ATMOS_5_1("Atmos 5.1", "flac"), ATMOS_5_1("Atmos 5.1", AudioFileTypeEnum.FLAC),
ATMOS_2("Atmos 2", "flac"), ATMOS_2("Atmos 2", AudioFileTypeEnum.FLAC),
MASTER("Master", "flac"), MASTER("Master", AudioFileTypeEnum.FLAC),
FLAC("flac", "flac"), FLAC("flac", AudioFileTypeEnum.FLAC),
; ;
private final String desc; 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.desc = desc;
this.type = type; 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 @Slf4j
public abstract class AbstractAudioProcessor { public abstract class AbstractAudioProcessor {
public static AbstractAudioProcessor getAudioProcessor(String fileType) { public static AbstractAudioProcessor getAudioProcessor(AudioFileTypeEnum fileType) {
AudioFileTypeEnum fileTypeEnum = AudioFileTypeEnum.parse(fileType); switch (fileType) {
switch (fileTypeEnum) {
case FLAC: case FLAC:
return new FlacAudioProcessor(); return new FlacAudioProcessor();
case MP3: 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 lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils; 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.client.ChannelClient;
import top.baogutang.music.dao.entity.MusicRecordEntity;
import top.baogutang.music.domain.req.AbstractMusicReq; import top.baogutang.music.domain.req.AbstractMusicReq;
import top.baogutang.music.domain.req.search.MusicSearchReq; import top.baogutang.music.domain.req.search.MusicSearchReq;
import top.baogutang.music.domain.res.AbstractMusicRes; import top.baogutang.music.domain.res.AbstractMusicRes;
@ -13,10 +18,14 @@ import top.baogutang.music.exceptions.BusinessException;
import top.baogutang.music.factory.ChannelClientFactory; import top.baogutang.music.factory.ChannelClientFactory;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.Objects; import java.util.Objects;
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;
/** /**
* *
@ -34,6 +43,9 @@ public abstract class AbstractMusicService<Q extends AbstractMusicReq, S extends
@Resource(name = "commonExecutor") @Resource(name = "commonExecutor")
private Executor commonExecutor; private Executor commonExecutor;
@Resource
private ZipService zipService;
public abstract ChannelEnum getChannelEnum(); public abstract ChannelEnum getChannelEnum();
public MusicSearchRes search(MusicSearchReq req) { public MusicSearchRes search(MusicSearchReq req) {
@ -66,36 +78,65 @@ public abstract class AbstractMusicService<Q extends AbstractMusicReq, S extends
return channelClient.artist(id); 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(); ChannelEnum channelEnum = getChannelEnum();
ChannelClient<Q, S> channelClient = channelClientFactory.getClient(channelEnum); ChannelClient<Q, S> channelClient = channelClientFactory.getClient(channelEnum);
MusicDownloadRes res = channelClient.download(id); Map<String, String> filesWithPaths = idList.stream()
if (Objects.isNull(res)) { .map(id ->
log.error(">>>>>>>>>>query detail error! channel:{},song id:{}<<<<<<<<<<", channelEnum.getDesc(), id); CompletableFuture.supplyAsync(() -> {
return null; MusicRecordEntity musicRecord = channelClient.queryByPlatform(id);
} String filePath;
if (StringUtils.isNotBlank(res.getArtistName())) { String zipEntryPath;
String[] split = res.getArtistName().split("/"); if (Objects.nonNull(musicRecord)) {
String artistName = String.join(",", split); // 当前歌曲已经下载过读取本地文件
res.setArtistName(artistName); 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> { public interface IMusicRecordService extends IService<MusicRecordEntity> {
void save(MusicDownloadRes res, ChannelEnum channel); void save(MusicDownloadRes res, ChannelEnum channel);
MusicRecordEntity queryByChannelAndPlatform(ChannelEnum channel, String platformId);
} }

View File

@ -1,6 +1,5 @@
package top.baogutang.music.service; 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.dao.entity.UserEntity;
import top.baogutang.music.domain.req.user.UserRegisterAndLoginReq; import top.baogutang.music.domain.req.user.UserRegisterAndLoginReq;
import top.baogutang.music.domain.res.user.UserLoginRes; 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); UserLoginRes login(UserRegisterAndLoginReq req);
void logout(Long id); 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 = new MusicRecordEntity();
entity.setPlatformId(String.valueOf(res.getId())); entity.setPlatformId(String.valueOf(res.getId()));
entity.setName(res.getName()); entity.setName(res.getName());
entity.setFileType(res.getFileType());
entity.setAlbumName(res.getAlbumName()); entity.setAlbumName(res.getAlbumName());
entity.setArtistName(res.getArtistName()); entity.setArtistName(res.getArtistName());
MusicQualityEnum musicQualityEnum = MusicQualityEnum.parse(res.getLevel()); MusicQualityEnum musicQualityEnum = MusicQualityEnum.parse(res.getLevel());
@ -55,4 +56,14 @@ public class MusicRecordServiceImpl extends ServiceImpl<MusicRecordMapper, Music
entity.setCreateTime(LocalDateTime.now()); entity.setCreateTime(LocalDateTime.now());
baseMapper.insert(entity); 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.Objects;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import static top.baogutang.music.constants.CacheKey.KEY_LOGIN_PREFIX; import static top.baogutang.music.constants.CacheKey.KEY_USER_LOGIN_PREFIX;
import static top.baogutang.music.constants.CacheKey.LOCK_REGISTER_PREFIX;
/** /**
* *
@ -76,14 +75,30 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, UserEntity> impleme
loginRes.setRole(existsUser.getRole()); loginRes.setRole(existsUser.getRole());
// 生成token // 生成token
String token = TokenUtil.token(existsUser.getId(), loginRes.getUsername()); 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); loginRes.setToken(token);
return loginRes; return loginRes;
} }
@Override @Override
public void logout(Long id) { 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) { 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: baogutang:
music:
zip:
# base-path: /downloads/music/zips
base-path: /Users/nikooh/Desktop/downloads/music/zips
net-ease-music: net-ease-music:
query-base-url: http://117.72.78.133:5173/search?keywords=%s&limit=%d&offset=%d&type=%d 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 playlist-base-url: http://117.72.78.133:5173/playlist/track/all?id=%d

View File

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