Compare commits
10 Commits
ef72a590d2
...
a3c43ce5a3
| Author | SHA1 | Date | |
|---|---|---|---|
|
a3c43ce5a3
|
|||
|
2821635462
|
|||
|
b6cd60a041
|
|||
|
b6d88b1666
|
|||
| c143fe66ac | |||
| a281b92425 | |||
| 04d99760e2 | |||
| 902c223e69 | |||
| 3021adfa7d | |||
| d908c1e64b |
36
.dockerignore
Normal file
36
.dockerignore
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
HELP.md
|
||||||
|
.mvn/wrapper/maven-wrapper.jar
|
||||||
|
!**/src/main/**/target/
|
||||||
|
!**/src/test/**/target/
|
||||||
|
|
||||||
|
### STS ###
|
||||||
|
.apt_generated
|
||||||
|
.classpath
|
||||||
|
.factorypath
|
||||||
|
.project
|
||||||
|
.settings
|
||||||
|
.springBeans
|
||||||
|
.sts4-cache
|
||||||
|
|
||||||
|
### IntelliJ IDEA ###
|
||||||
|
.idea
|
||||||
|
*.iws
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
|
||||||
|
### NetBeans ###
|
||||||
|
/nbproject/private/
|
||||||
|
/nbbuild/
|
||||||
|
/dist/
|
||||||
|
/nbdist/
|
||||||
|
/.nb-gradle/
|
||||||
|
build/
|
||||||
|
!**/src/main/**/build/
|
||||||
|
!**/src/test/**/build/
|
||||||
|
|
||||||
|
### VS Code ###
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
|
||||||
|
.env
|
||||||
|
|
||||||
@ -6,3 +6,5 @@ S3_ENDPOINT=http://s3:9000
|
|||||||
S3_ACCESS_KEY=minioadmin
|
S3_ACCESS_KEY=minioadmin
|
||||||
S3_SECRET_KEY=minioadmin
|
S3_SECRET_KEY=minioadmin
|
||||||
S3_BUCKET=composer-dev
|
S3_BUCKET=composer-dev
|
||||||
|
|
||||||
|
REDIS_HOST_NAME=redis
|
||||||
|
|||||||
35
Dockerfile.dev
Normal file
35
Dockerfile.dev
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
FROM eclipse-temurin:21-jdk-jammy
|
||||||
|
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
curl \
|
||||||
|
vim \
|
||||||
|
git \
|
||||||
|
ca-certificates \
|
||||||
|
ffmpeg && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN groupadd --gid 1000 spring-app && \
|
||||||
|
useradd --uid 1000 --gid spring-app --shell /bin/bash --create-home spring-app
|
||||||
|
|
||||||
|
RUN mkdir -p /home/spring-app/.m2 && \
|
||||||
|
chown -R spring-app:spring-app /home/spring-app/.m2
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY mvnw .
|
||||||
|
COPY .mvn .mvn
|
||||||
|
COPY pom.xml .
|
||||||
|
COPY target target
|
||||||
|
|
||||||
|
RUN chmod +x mvnw && \
|
||||||
|
chown -R spring-app:spring-app /app
|
||||||
|
|
||||||
|
USER spring-app:spring-app
|
||||||
|
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
ENTRYPOINT ["./mvnw", "spring-boot:run"]
|
||||||
20
compose.yml
20
compose.yml
@ -1,4 +1,17 @@
|
|||||||
services:
|
services:
|
||||||
|
hls-proxy:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.dev
|
||||||
|
env_file: .env
|
||||||
|
ports:
|
||||||
|
- 8080:8080
|
||||||
|
networks:
|
||||||
|
- mp3_composer
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- maven-repo:/home/spring-app/.m2/
|
||||||
|
|
||||||
s3:
|
s3:
|
||||||
image: minio/minio:latest
|
image: minio/minio:latest
|
||||||
container_name: composer_s3
|
container_name: composer_s3
|
||||||
@ -6,7 +19,7 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- mp3_composer
|
- mp3_composer
|
||||||
ports:
|
ports:
|
||||||
- 9000:9000
|
- 0:9001
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:alpine
|
image: postgres:alpine
|
||||||
container_name: composer_postgres
|
container_name: composer_postgres
|
||||||
@ -39,6 +52,9 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- mp3_composer
|
- mp3_composer
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
maven-repo:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
mp3_composer:
|
mp3_composer:
|
||||||
driver: bridge
|
external: true
|
||||||
|
|||||||
33
pom.xml
33
pom.xml
@ -33,7 +33,15 @@
|
|||||||
<modelmapper.version>3.2.4</modelmapper.version>
|
<modelmapper.version>3.2.4</modelmapper.version>
|
||||||
<spring-dotenv.version>4.0.0</spring-dotenv.version>
|
<spring-dotenv.version>4.0.0</spring-dotenv.version>
|
||||||
<apache-tika.version>3.2.3</apache-tika.version>
|
<apache-tika.version>3.2.3</apache-tika.version>
|
||||||
|
<springdoc-openapi.version>2.8.5</springdoc-openapi.version>
|
||||||
|
<jaffree.version>2024.08.29</jaffree.version>
|
||||||
</properties>
|
</properties>
|
||||||
|
<repositories>
|
||||||
|
<repository>
|
||||||
|
<id>jitpack.io</id>
|
||||||
|
<url>https://jitpack.io</url>
|
||||||
|
</repository>
|
||||||
|
</repositories>
|
||||||
<dependencyManagement>
|
<dependencyManagement>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<dependency>
|
<dependency>
|
||||||
@ -82,6 +90,14 @@
|
|||||||
<groupId>software.amazon.awssdk</groupId>
|
<groupId>software.amazon.awssdk</groupId>
|
||||||
<artifactId>s3</artifactId>
|
<artifactId>s3</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-webflux</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>me.paulschwarz</groupId>
|
<groupId>me.paulschwarz</groupId>
|
||||||
@ -98,12 +114,29 @@
|
|||||||
<artifactId>tika-parsers-standard-package</artifactId>
|
<artifactId>tika-parsers-standard-package</artifactId>
|
||||||
<version>${apache-tika.version}</version>
|
<version>${apache-tika.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springdoc</groupId>
|
||||||
|
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||||
|
<version>${springdoc-openapi.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.github.kokorin.jaffree</groupId>
|
||||||
|
<artifactId>jaffree</artifactId>
|
||||||
|
<version>${jaffree.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.postgresql</groupId>
|
<groupId>org.postgresql</groupId>
|
||||||
<artifactId>postgresql</artifactId>
|
<artifactId>postgresql</artifactId>
|
||||||
<scope>runtime</scope>
|
<scope>runtime</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-devtools</artifactId>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-test</artifactId>
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
|||||||
@ -0,0 +1,36 @@
|
|||||||
|
package com.bivashy.backend.composer.config;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
|
||||||
|
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
|
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class RedisConfig {
|
||||||
|
@Value("${spring.redis.host-name}")
|
||||||
|
private String hostName;
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public LettuceConnectionFactory lettuceConnectionFactory() {
|
||||||
|
RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(hostName);
|
||||||
|
return new LettuceConnectionFactory(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public RedisTemplate<String, Object> redisTemplate() {
|
||||||
|
RedisTemplate<String, Object> template = new RedisTemplate<>();
|
||||||
|
template.setConnectionFactory(lettuceConnectionFactory());
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public RedisMessageListenerContainer redisContainer() {
|
||||||
|
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
|
||||||
|
container.setConnectionFactory(lettuceConnectionFactory());
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -25,19 +25,24 @@ public class SecurityConfig {
|
|||||||
http.authorizeHttpRequests(auth -> auth
|
http.authorizeHttpRequests(auth -> auth
|
||||||
.anyRequest().authenticated())
|
.anyRequest().authenticated())
|
||||||
.httpBasic(Customizer.withDefaults())
|
.httpBasic(Customizer.withDefaults())
|
||||||
// TODO: Temporary to test API, remove after testing
|
|
||||||
.csrf(c -> c.disable());
|
.csrf(c -> c.disable());
|
||||||
return http.build();
|
return http.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public CustomUserDetailsService customUserDetailsService(UserRepository repository) {
|
public CustomUserDetailsService customUserDetailsService(UserRepository repository) {
|
||||||
CustomUserDetails defaultUser = new CustomUserDetails(1, "user", passwordEncoder().encode("password"));
|
CustomUserDetails defaultUser1 = create(repository, 1, "user", passwordEncoder().encode("password"));
|
||||||
Optional<User> user = repository.findById(defaultUser.getId());
|
CustomUserDetails defaultUser2 = create(repository, 2, "user1", passwordEncoder().encode("password"));
|
||||||
|
return new CustomUserDetailsService(defaultUser1, defaultUser2);
|
||||||
|
}
|
||||||
|
|
||||||
|
private CustomUserDetails create(UserRepository repository, long id, String username, String password) {
|
||||||
|
CustomUserDetails userDetails = new CustomUserDetails(id, username, password);
|
||||||
|
Optional<User> user = repository.findById(userDetails.getId());
|
||||||
if (user.isEmpty()) {
|
if (user.isEmpty()) {
|
||||||
repository.save(new User(defaultUser.getUsername()));
|
repository.save(new User(userDetails.getUsername()));
|
||||||
}
|
}
|
||||||
return new CustomUserDetailsService(defaultUser);
|
return userDetails;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
|
|||||||
@ -9,8 +9,8 @@ import org.springframework.web.bind.annotation.RequestBody;
|
|||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
import com.bivashy.backend.composer.auth.CustomUserDetails;
|
import com.bivashy.backend.composer.auth.CustomUserDetails;
|
||||||
import com.bivashy.backend.composer.dto.playlist.PlaylistCreateDTO;
|
import com.bivashy.backend.composer.dto.playlist.PlaylistCreateRequest;
|
||||||
import com.bivashy.backend.composer.dto.playlist.PlaylistReadDTO;
|
import com.bivashy.backend.composer.dto.playlist.PlaylistReadResponse;
|
||||||
import com.bivashy.backend.composer.service.PlaylistService;
|
import com.bivashy.backend.composer.service.PlaylistService;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@ -22,13 +22,14 @@ public class PlaylistController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/playlists")
|
@GetMapping("/playlists")
|
||||||
public List<PlaylistReadDTO> playlists(@AuthenticationPrincipal CustomUserDetails user) {
|
public List<PlaylistReadResponse> playlists(@AuthenticationPrincipal CustomUserDetails user) {
|
||||||
return service.findPlaylists(user.getId());
|
return service.findPlaylists(user.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/playlist")
|
@PostMapping("/playlist")
|
||||||
public PlaylistReadDTO createPlaylist(@AuthenticationPrincipal CustomUserDetails user,
|
public PlaylistReadResponse createPlaylist(@AuthenticationPrincipal CustomUserDetails user,
|
||||||
@RequestBody PlaylistCreateDTO playlist) {
|
@RequestBody PlaylistCreateRequest playlist) {
|
||||||
return service.createPlaylist(user.getId(), playlist);
|
return service.createPlaylist(user.getId(), playlist);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,55 @@
|
|||||||
|
package com.bivashy.backend.composer.controller;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import com.bivashy.backend.composer.dto.track.AddLocalTrackRequest;
|
||||||
|
import com.bivashy.backend.composer.dto.track.PlaylistTrackResponse;
|
||||||
|
import com.bivashy.backend.composer.dto.track.TrackBulkReorderRequest;
|
||||||
|
import com.bivashy.backend.composer.dto.track.TrackReorderAfterRequest;
|
||||||
|
import com.bivashy.backend.composer.dto.track.TrackResponse;
|
||||||
|
import com.bivashy.backend.composer.model.User;
|
||||||
|
import com.bivashy.backend.composer.service.TrackService;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
public class TrackController {
|
||||||
|
private final TrackService trackService;
|
||||||
|
|
||||||
|
public TrackController(TrackService trackService) {
|
||||||
|
this.trackService = trackService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping(path = "/playlist/{playlistId}/track/local", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
|
public ResponseEntity<TrackResponse> addLocalTrack(
|
||||||
|
@AuthenticationPrincipal User user,
|
||||||
|
@PathVariable Long playlistId,
|
||||||
|
@ModelAttribute AddLocalTrackRequest request) throws IOException {
|
||||||
|
TrackResponse response = trackService.addLocalTrack(user, playlistId, request);
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/playlist/{playlistId}/tracks")
|
||||||
|
public ResponseEntity<List<PlaylistTrackResponse>> getPlaylistTracks(
|
||||||
|
@AuthenticationPrincipal User user,
|
||||||
|
@PathVariable Long playlistId) {
|
||||||
|
List<PlaylistTrackResponse> tracks = trackService.getPlaylistTracks(user, playlistId);
|
||||||
|
return ResponseEntity.ok(tracks);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/playlist/{playlistId}/bulk-reorder")
|
||||||
|
public void bulkReorder(@AuthenticationPrincipal User user,
|
||||||
|
@RequestBody TrackBulkReorderRequest request,
|
||||||
|
@PathVariable Long playlistId) {
|
||||||
|
trackService.bulkReorder(user, playlistId, request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,113 @@
|
|||||||
|
package com.bivashy.backend.composer.controller.importing;
|
||||||
|
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.codec.ServerSentEvent;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import com.bivashy.backend.composer.auth.CustomUserDetails;
|
||||||
|
import com.bivashy.backend.composer.dto.importing.ImportTrackKey;
|
||||||
|
import com.bivashy.backend.composer.dto.importing.TrackProgressDTO;
|
||||||
|
import com.bivashy.backend.composer.service.importing.RedisMessageSubscriber;
|
||||||
|
import com.bivashy.backend.composer.service.importing.RedisProgressService;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Sinks;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
public class ProgressSSEController {
|
||||||
|
|
||||||
|
private final RedisProgressService redisProgressService;
|
||||||
|
private final RedisMessageSubscriber redisSubscriber;
|
||||||
|
private final Map<String, Sinks.Many<String>> sinks = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public ProgressSSEController(RedisProgressService redisProgressService,
|
||||||
|
RedisMessageSubscriber redisSubscriber) {
|
||||||
|
this.redisProgressService = redisProgressService;
|
||||||
|
this.redisSubscriber = redisSubscriber;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/importing/test/{playlistId}")
|
||||||
|
public void test(@PathVariable long playlistId, @AuthenticationPrincipal CustomUserDetails user) {
|
||||||
|
var userId = user.getId();
|
||||||
|
redisProgressService.saveProgress(new TrackProgressDTO(
|
||||||
|
playlistId,
|
||||||
|
"test",
|
||||||
|
userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping(value = "/importing/stream/{playlistId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||||
|
public Flux<ServerSentEvent<String>> streamProgress(
|
||||||
|
@PathVariable long playlistId,
|
||||||
|
@AuthenticationPrincipal CustomUserDetails user,
|
||||||
|
HttpServletResponse response) {
|
||||||
|
var userId = user.getId();
|
||||||
|
|
||||||
|
response.setHeader("Cache-Control", "no-cache");
|
||||||
|
response.setHeader("Connection", "keep-alive");
|
||||||
|
response.setCharacterEncoding("UTF-8");
|
||||||
|
|
||||||
|
String connectionKey = ImportTrackKey.subscriptionKey(playlistId, userId);
|
||||||
|
|
||||||
|
Sinks.Many<String> sink = sinks.computeIfAbsent(connectionKey, k -> {
|
||||||
|
Sinks.Many<String> newSink = Sinks.many().replay().latest();
|
||||||
|
|
||||||
|
redisSubscriber.subscribeToPlaylist(playlistId, userId, message -> {
|
||||||
|
newSink.tryEmitNext(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
return newSink;
|
||||||
|
});
|
||||||
|
|
||||||
|
redisProgressService.addActiveConnection(playlistId, userId);
|
||||||
|
|
||||||
|
return sink.asFlux()
|
||||||
|
.map(data -> ServerSentEvent.<String>builder()
|
||||||
|
.data(data)
|
||||||
|
.event("progress-update")
|
||||||
|
.build())
|
||||||
|
.doFirst(() -> {
|
||||||
|
try {
|
||||||
|
List<TrackProgressDTO> existingProgresses = redisProgressService.getPlaylistProgress(playlistId,
|
||||||
|
userId);
|
||||||
|
System.out.println(existingProgresses);
|
||||||
|
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
for (TrackProgressDTO progress : existingProgresses) {
|
||||||
|
sink.tryEmitNext(mapper.writeValueAsString(progress));
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.doOnCancel(() -> {
|
||||||
|
cleanupConnection(playlistId, userId, sink, connectionKey);
|
||||||
|
})
|
||||||
|
.doOnTerminate(() -> {
|
||||||
|
cleanupConnection(playlistId, userId, sink, connectionKey);
|
||||||
|
})
|
||||||
|
.timeout(Duration.ofHours(2))
|
||||||
|
.onErrorResume(e -> {
|
||||||
|
cleanupConnection(playlistId, userId, sink, connectionKey);
|
||||||
|
return Flux.empty();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void cleanupConnection(Long playlistId, long userId,
|
||||||
|
Sinks.Many<String> sink, String connectionKey) {
|
||||||
|
try {
|
||||||
|
redisProgressService.removeActiveConnection(playlistId, userId);
|
||||||
|
redisSubscriber.unsubscribeFromPlaylist(playlistId, userId);
|
||||||
|
sinks.remove(connectionKey);
|
||||||
|
sink.tryEmitComplete();
|
||||||
|
} catch (Exception e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,20 +2,20 @@ package com.bivashy.backend.composer.converter;
|
|||||||
|
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import com.bivashy.backend.composer.dto.playlist.PlaylistCreateDTO;
|
import com.bivashy.backend.composer.dto.playlist.PlaylistCreateRequest;
|
||||||
import com.bivashy.backend.composer.dto.playlist.PlaylistReadDTO;
|
import com.bivashy.backend.composer.dto.playlist.PlaylistReadResponse;
|
||||||
import com.bivashy.backend.composer.model.Playlist;
|
import com.bivashy.backend.composer.model.Playlist;
|
||||||
import com.bivashy.backend.composer.model.User;
|
import com.bivashy.backend.composer.model.User;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class PlaylistConverter {
|
public class PlaylistConverter {
|
||||||
public PlaylistReadDTO convertToRead(Playlist playlist) {
|
public PlaylistReadResponse convertToRead(Playlist playlist) {
|
||||||
return new PlaylistReadDTO(playlist.getId(), playlist.getOwner().getId(), playlist.getTitle(),
|
return new PlaylistReadResponse(playlist.getId(), playlist.getOwner().getId(), playlist.getTitle(),
|
||||||
playlist.getCreatedAt(),
|
playlist.getCreatedAt(),
|
||||||
playlist.getUpdatedAt());
|
playlist.getUpdatedAt());
|
||||||
}
|
}
|
||||||
|
|
||||||
public Playlist convertFromCreate(long userId, PlaylistCreateDTO playlist) {
|
public Playlist convertFromCreate(long userId, PlaylistCreateRequest playlist) {
|
||||||
return new Playlist(new User(userId), playlist.title());
|
return new Playlist(new User(userId), playlist.title());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,27 @@
|
|||||||
|
package com.bivashy.backend.composer.dto;
|
||||||
|
|
||||||
|
public enum SourceType {
|
||||||
|
AUDIO("AUDIO"),
|
||||||
|
PLAYLIST("PLAYLIST"),
|
||||||
|
FILE("FILE"),
|
||||||
|
URL("URL");
|
||||||
|
|
||||||
|
private final String value;
|
||||||
|
|
||||||
|
SourceType(String value) {
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getValue() {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static SourceType fromValue(String value) {
|
||||||
|
for (SourceType type : values()) {
|
||||||
|
if (type.value.equalsIgnoreCase(value)) {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("Unknown source type: " + value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
package com.bivashy.backend.composer.dto.importing;
|
||||||
|
|
||||||
|
public class ImportTrackKey {
|
||||||
|
public static String progressKey(long playlistId, long userId) {
|
||||||
|
return String.format("progress:%d:%d", userId, playlistId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String trackKey(long playlistId, String trackId, long userId) {
|
||||||
|
return String.format("track:%d:%d:%s", userId, playlistId, trackId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String redisChannelKey(long playlistId, long userId) {
|
||||||
|
return String.format("progress_updates:%d:%d", userId, playlistId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String subscriptionKey(long playlistId, long userId) {
|
||||||
|
return String.format("%d:%d", playlistId, userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,116 @@
|
|||||||
|
package com.bivashy.backend.composer.dto.importing;
|
||||||
|
|
||||||
|
public class TrackProgressDTO {
|
||||||
|
private long playlistId;
|
||||||
|
private String trackId;
|
||||||
|
private String trackTitle;
|
||||||
|
private String format;
|
||||||
|
private String sourceType;
|
||||||
|
private int progress;
|
||||||
|
private String metadata;
|
||||||
|
private Long timestamp;
|
||||||
|
private long userId;
|
||||||
|
|
||||||
|
public TrackProgressDTO() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public TrackProgressDTO(long playlistId, String trackId, long userId) {
|
||||||
|
this.playlistId = playlistId;
|
||||||
|
this.trackId = trackId;
|
||||||
|
this.userId = userId;
|
||||||
|
this.timestamp = System.currentTimeMillis();
|
||||||
|
}
|
||||||
|
|
||||||
|
public TrackProgressDTO(long playlistId,
|
||||||
|
String trackId,
|
||||||
|
String trackTitle,
|
||||||
|
String format,
|
||||||
|
String sourceType,
|
||||||
|
int progress,
|
||||||
|
String metadata,
|
||||||
|
Long timestamp,
|
||||||
|
long userId) {
|
||||||
|
this.playlistId = playlistId;
|
||||||
|
this.trackId = trackId;
|
||||||
|
this.trackTitle = trackTitle;
|
||||||
|
this.format = format;
|
||||||
|
this.sourceType = sourceType;
|
||||||
|
this.progress = progress;
|
||||||
|
this.metadata = metadata;
|
||||||
|
this.timestamp = timestamp;
|
||||||
|
this.userId = userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getPlaylistId() {
|
||||||
|
return playlistId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPlaylistId(long playlistId) {
|
||||||
|
this.playlistId = playlistId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTrackId() {
|
||||||
|
return trackId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTrackId(String trackId) {
|
||||||
|
this.trackId = trackId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTrackTitle() {
|
||||||
|
return trackTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTrackTitle(String trackTitle) {
|
||||||
|
this.trackTitle = trackTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getFormat() {
|
||||||
|
return format;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFormat(String format) {
|
||||||
|
this.format = format;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSourceType() {
|
||||||
|
return sourceType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSourceType(String sourceType) {
|
||||||
|
this.sourceType = sourceType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getProgress() {
|
||||||
|
return progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setProgress(int progress) {
|
||||||
|
this.progress = progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getMetadata() {
|
||||||
|
return metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMetadata(String metadata) {
|
||||||
|
this.metadata = metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getTimestamp() {
|
||||||
|
return timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTimestamp(Long timestamp) {
|
||||||
|
this.timestamp = timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getUserId() {
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUserId(long userId) {
|
||||||
|
this.userId = userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package com.bivashy.backend.composer.dto.playlist;
|
package com.bivashy.backend.composer.dto.playlist;
|
||||||
|
|
||||||
public record PlaylistCreateDTO(String title) {
|
public record PlaylistCreateRequest(String title) {
|
||||||
}
|
}
|
||||||
@ -2,13 +2,13 @@ package com.bivashy.backend.composer.dto.playlist;
|
|||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
public record PlaylistReadDTO(
|
public record PlaylistReadResponse(
|
||||||
long id,
|
long id,
|
||||||
long ownerId,
|
long ownerId,
|
||||||
String title,
|
String title,
|
||||||
LocalDateTime createdAt,
|
LocalDateTime createdAt,
|
||||||
LocalDateTime updatedAt) {
|
LocalDateTime updatedAt) {
|
||||||
public PlaylistReadDTO withUserId(long userId) {
|
public PlaylistReadResponse withUserId(long userId) {
|
||||||
return new PlaylistReadDTO(this.id, userId, this.title, this.createdAt, this.updatedAt);
|
return new PlaylistReadResponse(this.id, userId, this.title, this.createdAt, this.updatedAt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
package com.bivashy.backend.composer.dto.track;
|
||||||
|
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
public record AddLocalTrackRequest(
|
||||||
|
@NotNull MultipartFile source) {
|
||||||
|
}
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
package com.bivashy.backend.composer.dto.track;
|
||||||
|
|
||||||
|
public record PlaylistTrackResponse(
|
||||||
|
Long trackId,
|
||||||
|
String title,
|
||||||
|
String artist,
|
||||||
|
String audioPath,
|
||||||
|
Integer durationSeconds,
|
||||||
|
String fileName) {
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
package com.bivashy.backend.composer.dto.track;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record TrackBulkReorderRequest(List<Long> trackIds) {
|
||||||
|
}
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
package com.bivashy.backend.composer.dto.track;
|
||||||
|
|
||||||
|
public record TrackReorderAfterRequest(long moveTrackId, long targetTrackId) {
|
||||||
|
}
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
package com.bivashy.backend.composer.dto.track;
|
||||||
|
|
||||||
|
public record TrackResponse(
|
||||||
|
Long trackId,
|
||||||
|
String title,
|
||||||
|
String artist,
|
||||||
|
String audioPath,
|
||||||
|
Integer durationSeconds,
|
||||||
|
String fileName) {
|
||||||
|
}
|
||||||
@ -1,47 +0,0 @@
|
|||||||
package com.bivashy.backend.composer.model;
|
|
||||||
|
|
||||||
import java.util.HashSet;
|
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
import jakarta.persistence.CascadeType;
|
|
||||||
import jakarta.persistence.Column;
|
|
||||||
import jakarta.persistence.Entity;
|
|
||||||
import jakarta.persistence.GeneratedValue;
|
|
||||||
import jakarta.persistence.GenerationType;
|
|
||||||
import jakarta.persistence.Id;
|
|
||||||
import jakarta.persistence.OneToMany;
|
|
||||||
import jakarta.persistence.Table;
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
@Table(name = "source_provider")
|
|
||||||
public class SourceProvider {
|
|
||||||
@Id
|
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
|
||||||
private Long id;
|
|
||||||
|
|
||||||
@Column(nullable = false, length = 500)
|
|
||||||
private String name;
|
|
||||||
|
|
||||||
@OneToMany(mappedBy = "provider", cascade = CascadeType.ALL, orphanRemoval = true)
|
|
||||||
private Set<SourceType> sourceTypes = new HashSet<>();
|
|
||||||
|
|
||||||
SourceProvider() {
|
|
||||||
}
|
|
||||||
|
|
||||||
public SourceProvider(String name) {
|
|
||||||
this.name = name;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Long getId() {
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getName() {
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Set<SourceType> getSourceTypes() {
|
|
||||||
return sourceTypes;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
package com.bivashy.backend.composer.model;
|
|
||||||
|
|
||||||
public class SourceProviders {
|
|
||||||
public static final String YOUTUBE = "YOUTUBE";
|
|
||||||
public static final String LOCAL = "SOUNDCLOUD";
|
|
||||||
public static final String EXTERNAL = "EXTERNAL";
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -6,12 +6,9 @@ import java.util.Set;
|
|||||||
import jakarta.persistence.CascadeType;
|
import jakarta.persistence.CascadeType;
|
||||||
import jakarta.persistence.Column;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Entity;
|
import jakarta.persistence.Entity;
|
||||||
import jakarta.persistence.FetchType;
|
|
||||||
import jakarta.persistence.GeneratedValue;
|
import jakarta.persistence.GeneratedValue;
|
||||||
import jakarta.persistence.GenerationType;
|
import jakarta.persistence.GenerationType;
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.JoinColumn;
|
|
||||||
import jakarta.persistence.ManyToOne;
|
|
||||||
import jakarta.persistence.OneToMany;
|
import jakarta.persistence.OneToMany;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
@ -22,10 +19,6 @@ public class SourceType {
|
|||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
private Long id;
|
private Long id;
|
||||||
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
|
||||||
@JoinColumn(name = "provider_id", nullable = false)
|
|
||||||
private SourceProvider provider;
|
|
||||||
|
|
||||||
@Column(nullable = false, length = 500)
|
@Column(nullable = false, length = 500)
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
@ -43,10 +36,6 @@ public class SourceType {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
public SourceProvider getProvider() {
|
|
||||||
return provider;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getName() {
|
public String getName() {
|
||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,6 +28,9 @@ public class TrackMetadata {
|
|||||||
@Column(nullable = false, length = 500)
|
@Column(nullable = false, length = 500)
|
||||||
private String title;
|
private String title;
|
||||||
|
|
||||||
|
@Column(length = 500)
|
||||||
|
private String fileName;
|
||||||
|
|
||||||
@Column(name = "audio_path", nullable = false, length = 500)
|
@Column(name = "audio_path", nullable = false, length = 500)
|
||||||
private String audioPath;
|
private String audioPath;
|
||||||
|
|
||||||
@ -46,10 +49,12 @@ public class TrackMetadata {
|
|||||||
TrackMetadata() {
|
TrackMetadata() {
|
||||||
}
|
}
|
||||||
|
|
||||||
public TrackMetadata(Track track, String title, String audioPath, String artist, String thumbnailPath,
|
public TrackMetadata(Track track, String title, String fileName, String audioPath, String artist,
|
||||||
|
String thumbnailPath,
|
||||||
Integer durationSeconds) {
|
Integer durationSeconds) {
|
||||||
this.track = track;
|
this.track = track;
|
||||||
this.title = title;
|
this.title = title;
|
||||||
|
this.fileName = fileName;
|
||||||
this.audioPath = audioPath;
|
this.audioPath = audioPath;
|
||||||
this.artist = artist;
|
this.artist = artist;
|
||||||
this.thumbnailPath = thumbnailPath;
|
this.thumbnailPath = thumbnailPath;
|
||||||
@ -68,6 +73,10 @@ public class TrackMetadata {
|
|||||||
return title;
|
return title;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getFileName() {
|
||||||
|
return fileName;
|
||||||
|
}
|
||||||
|
|
||||||
public String getAudioPath() {
|
public String getAudioPath() {
|
||||||
return audioPath;
|
return audioPath;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,73 @@
|
|||||||
|
package com.bivashy.backend.composer.model;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
import com.bivashy.backend.composer.model.key.PlaylistTrackId;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.FetchType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.IdClass;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "playlist_track")
|
||||||
|
@IdClass(PlaylistTrackId.class)
|
||||||
|
public class TrackPlaylist {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "playlist_id", nullable = false)
|
||||||
|
private Long playlistId;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "track_id", nullable = false)
|
||||||
|
private Long trackId;
|
||||||
|
|
||||||
|
@Column(name = "order_index", nullable = false, precision = 20, scale = 15)
|
||||||
|
private BigDecimal order;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "playlist_id", insertable = false, updatable = false)
|
||||||
|
private Playlist playlist;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "track_id", insertable = false, updatable = false)
|
||||||
|
private Track track;
|
||||||
|
|
||||||
|
TrackPlaylist() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public TrackPlaylist(Long playlistId, Long trackId, BigDecimal order) {
|
||||||
|
this.playlistId = playlistId;
|
||||||
|
this.trackId = trackId;
|
||||||
|
this.order = order;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getPlaylistId() {
|
||||||
|
return playlistId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getTrackId() {
|
||||||
|
return trackId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getOrder() {
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOrder(BigDecimal order) {
|
||||||
|
this.order = order;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Playlist getPlaylist() {
|
||||||
|
return playlist;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Track getTrack() {
|
||||||
|
return track;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
package com.bivashy.backend.composer.model.key;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
public class PlaylistTrackId implements Serializable {
|
||||||
|
private Long playlistId;
|
||||||
|
private Long trackId;
|
||||||
|
|
||||||
|
PlaylistTrackId() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public PlaylistTrackId(Long playlistId, Long trackId) {
|
||||||
|
this.playlistId = playlistId;
|
||||||
|
this.trackId = trackId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getPlaylistId() {
|
||||||
|
return playlistId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPlaylistId(Long playlistId) {
|
||||||
|
this.playlistId = playlistId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getTrackId() {
|
||||||
|
return trackId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTrackId(Long trackId) {
|
||||||
|
this.trackId = trackId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
final int prime = 31;
|
||||||
|
int result = 1;
|
||||||
|
result = prime * result + ((playlistId == null) ? 0 : playlistId.hashCode());
|
||||||
|
result = prime * result + ((trackId == null) ? 0 : trackId.hashCode());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object obj) {
|
||||||
|
if (this == obj)
|
||||||
|
return true;
|
||||||
|
if (obj == null)
|
||||||
|
return false;
|
||||||
|
if (getClass() != obj.getClass())
|
||||||
|
return false;
|
||||||
|
PlaylistTrackId other = (PlaylistTrackId) obj;
|
||||||
|
if (playlistId == null) {
|
||||||
|
if (other.playlistId != null)
|
||||||
|
return false;
|
||||||
|
} else if (!playlistId.equals(other.playlistId))
|
||||||
|
return false;
|
||||||
|
if (trackId == null) {
|
||||||
|
if (other.trackId != null)
|
||||||
|
return false;
|
||||||
|
} else if (!trackId.equals(other.trackId))
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
package com.bivashy.backend.composer.repository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import com.bivashy.backend.composer.model.SourceType;
|
||||||
|
|
||||||
|
public interface SourceTypeRepository extends JpaRepository<SourceType, Long> {
|
||||||
|
Optional<SourceType> findByName(String name);
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
package com.bivashy.backend.composer.repository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import com.bivashy.backend.composer.model.TrackMetadata;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface TrackMetadataRepository extends JpaRepository<TrackMetadata, Long> {
|
||||||
|
Optional<TrackMetadata> findByTrackId(Long trackId);
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
package com.bivashy.backend.composer.repository;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
import com.bivashy.backend.composer.model.TrackPlaylist;
|
||||||
|
import com.bivashy.backend.composer.model.key.PlaylistTrackId;
|
||||||
|
|
||||||
|
public interface TrackPlaylistRepository extends JpaRepository<TrackPlaylist, PlaylistTrackId> {
|
||||||
|
List<TrackPlaylist> findByPlaylistIdOrderByOrderAsc(Long playlistId);
|
||||||
|
|
||||||
|
@Query("SELECT COALESCE(MAX(tp.order), 0) FROM TrackPlaylist tp WHERE tp.playlistId = :playlistId")
|
||||||
|
BigDecimal findMaxOrderByPlaylistId(@Param("playlistId") Long playlistId);
|
||||||
|
|
||||||
|
@Query("SELECT MIN(tp.order) FROM TrackPlaylist tp WHERE tp.playlistId = :playlistId AND tp.order > :order")
|
||||||
|
BigDecimal findNextOrderByPlaylistIdAndOrderGreaterThan(@Param("playlistId") Long playlistId,
|
||||||
|
@Param("order") BigDecimal order);
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
package com.bivashy.backend.composer.repository;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import com.bivashy.backend.composer.model.Track;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface TrackRepository extends JpaRepository<Track, Long> {
|
||||||
|
}
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
package com.bivashy.backend.composer.repository;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import com.bivashy.backend.composer.model.TrackSource;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface TrackSourceRepository extends JpaRepository<TrackSource, Long> {
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ package com.bivashy.backend.composer.service;
|
|||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
|
|
||||||
@ -10,10 +11,14 @@ public interface AudioBlobStorageService {
|
|||||||
|
|
||||||
String store(byte[] data);
|
String store(byte[] data);
|
||||||
|
|
||||||
|
String store(InputStream inputStream, Map<String, String> metadata);
|
||||||
|
|
||||||
|
String store(byte[] data, Map<String, String> metadata);
|
||||||
|
|
||||||
byte[] readRaw(String path) throws IOException;
|
byte[] readRaw(String path) throws IOException;
|
||||||
|
|
||||||
Blob read(String path);
|
Blob read(String path);
|
||||||
|
|
||||||
public record Blob(InputStream stream, MediaType contentType) {
|
public record Blob(InputStream stream, MediaType contentType, Map<String, String> metadata) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package com.bivashy.backend.composer.service;
|
|||||||
import java.io.ByteArrayInputStream;
|
import java.io.ByteArrayInputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import org.apache.tika.Tika;
|
import org.apache.tika.Tika;
|
||||||
@ -37,17 +38,28 @@ public class AudioS3StorageService implements AudioBlobStorageService {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String store(InputStream inputStream) {
|
public String store(InputStream inputStream) {
|
||||||
return store(new ByteArrayInputStream(DEFAULT_BUFFER).readAllBytes());
|
return store(inputStream, Map.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String store(byte[] data) {
|
public String store(byte[] data) {
|
||||||
|
return store(data, Map.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String store(InputStream inputStream, Map<String, String> metadata) {
|
||||||
|
return store(new ByteArrayInputStream(DEFAULT_BUFFER).readAllBytes(), metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String store(byte[] data, Map<String, String> metadata) {
|
||||||
String objectKey = newObjectName();
|
String objectKey = newObjectName();
|
||||||
String contentType = detectContentType(data);
|
String contentType = detectContentType(data);
|
||||||
s3Client.putObject(PutObjectRequest.builder()
|
s3Client.putObject(PutObjectRequest.builder()
|
||||||
.bucket(bucket)
|
.bucket(bucket)
|
||||||
.key(objectKey)
|
.key(objectKey)
|
||||||
.contentType(contentType)
|
.contentType(contentType)
|
||||||
|
.metadata(metadata)
|
||||||
.build(), RequestBody.fromBytes(data));
|
.build(), RequestBody.fromBytes(data));
|
||||||
return String.join("/", bucket, objectKey);
|
return String.join("/", bucket, objectKey);
|
||||||
}
|
}
|
||||||
@ -73,7 +85,7 @@ public class AudioS3StorageService implements AudioBlobStorageService {
|
|||||||
} catch (InvalidMediaTypeException e) {
|
} catch (InvalidMediaTypeException e) {
|
||||||
logger.error("invalid media type", e);
|
logger.error("invalid media type", e);
|
||||||
}
|
}
|
||||||
return new Blob(response, mediaType);
|
return new Blob(response, mediaType, response.response().metadata());
|
||||||
}
|
}
|
||||||
|
|
||||||
private String detectContentType(byte[] data) {
|
private String detectContentType(byte[] data) {
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
package com.bivashy.backend.composer.service;
|
package com.bivashy.backend.composer.service;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
import org.springframework.security.core.userdetails.UserDetails;
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||||
@ -9,15 +12,18 @@ import com.bivashy.backend.composer.auth.CustomUserDetails;
|
|||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class CustomUserDetailsService implements UserDetailsService {
|
public class CustomUserDetailsService implements UserDetailsService {
|
||||||
private final CustomUserDetails defaultUser;
|
private final List<CustomUserDetails> users;
|
||||||
|
|
||||||
public CustomUserDetailsService(CustomUserDetails defaultUser) {
|
public CustomUserDetailsService(CustomUserDetails... users) {
|
||||||
this.defaultUser = defaultUser;
|
this.users = Arrays.asList(users);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
|
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
|
||||||
return defaultUser;
|
return users.stream()
|
||||||
|
.filter(user -> user.getUsername().equals(username))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,69 @@
|
|||||||
|
package com.bivashy.backend.composer.service;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||||
|
import com.github.kokorin.jaffree.ffprobe.FFprobe;
|
||||||
|
import com.github.kokorin.jaffree.ffprobe.FFprobeResult;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class MetadataParseService {
|
||||||
|
private final ObjectMapper ffprobeObjectMapper;
|
||||||
|
|
||||||
|
public MetadataParseService() {
|
||||||
|
var result = new ObjectMapper();
|
||||||
|
result.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false);
|
||||||
|
result.setSerializationInclusion(JsonInclude.Include.NON_NULL);
|
||||||
|
this.ffprobeObjectMapper = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<Metadata> extractMetadata(InputStream input) throws IOException {
|
||||||
|
Path tempFile = Files.createTempFile("metadata-file", "");
|
||||||
|
Files.copy(input, tempFile, StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
FFprobeResult result = FFprobe.atPath()
|
||||||
|
.setShowFormat(true)
|
||||||
|
.setShowStreams(true)
|
||||||
|
.setInput(tempFile)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
Files.deleteIfExists(tempFile);
|
||||||
|
|
||||||
|
var format = Optional.ofNullable(result.getFormat());
|
||||||
|
|
||||||
|
Optional<Float> formatDuration = format.map(f -> f.getDuration());
|
||||||
|
List<Optional<Float>> streamDuration = Optional.ofNullable(result.getStreams())
|
||||||
|
.map(streams -> streams.stream()
|
||||||
|
.map(s -> s.getDuration())
|
||||||
|
.map(Optional::ofNullable).toList())
|
||||||
|
.orElse(List.of());
|
||||||
|
|
||||||
|
var foundDuration = Stream.of(Collections.singletonList(formatDuration), streamDuration)
|
||||||
|
.flatMap(Collection::stream)
|
||||||
|
.filter(Optional::isPresent)
|
||||||
|
.map(Optional::get)
|
||||||
|
.findFirst();
|
||||||
|
|
||||||
|
var jsonResult = ffprobeObjectMapper.writeValueAsString(result);
|
||||||
|
|
||||||
|
var title = format.map(f -> f.getTag("title")).orElse(null);
|
||||||
|
var artist = format.map(f -> f.getTag("artist")).orElse(null);
|
||||||
|
|
||||||
|
return Optional.of(new Metadata(title, artist, foundDuration.orElse(0f), jsonResult));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static record Metadata(String title, String artist, Float durationSeconds, String rawJson) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,8 +5,8 @@ import java.util.List;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import com.bivashy.backend.composer.converter.PlaylistConverter;
|
import com.bivashy.backend.composer.converter.PlaylistConverter;
|
||||||
import com.bivashy.backend.composer.dto.playlist.PlaylistCreateDTO;
|
import com.bivashy.backend.composer.dto.playlist.PlaylistCreateRequest;
|
||||||
import com.bivashy.backend.composer.dto.playlist.PlaylistReadDTO;
|
import com.bivashy.backend.composer.dto.playlist.PlaylistReadResponse;
|
||||||
import com.bivashy.backend.composer.model.Playlist;
|
import com.bivashy.backend.composer.model.Playlist;
|
||||||
import com.bivashy.backend.composer.repository.PlaylistRepository;
|
import com.bivashy.backend.composer.repository.PlaylistRepository;
|
||||||
|
|
||||||
@ -20,12 +20,12 @@ public class PlaylistService {
|
|||||||
this.converter = converter;
|
this.converter = converter;
|
||||||
}
|
}
|
||||||
|
|
||||||
public PlaylistReadDTO createPlaylist(long userId, PlaylistCreateDTO playlist) {
|
public PlaylistReadResponse createPlaylist(long userId, PlaylistCreateRequest playlist) {
|
||||||
Playlist result = repository.save(converter.convertFromCreate(userId, playlist));
|
Playlist result = repository.save(converter.convertFromCreate(userId, playlist));
|
||||||
return converter.convertToRead(result);
|
return converter.convertToRead(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<PlaylistReadDTO> findPlaylists(long userId) {
|
public List<PlaylistReadResponse> findPlaylists(long userId) {
|
||||||
return repository.findAllByOwnerId(userId)
|
return repository.findAllByOwnerId(userId)
|
||||||
.stream()
|
.stream()
|
||||||
.map(converter::convertToRead)
|
.map(converter::convertToRead)
|
||||||
|
|||||||
@ -0,0 +1,29 @@
|
|||||||
|
package com.bivashy.backend.composer.service;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import com.bivashy.backend.composer.model.Track;
|
||||||
|
import com.bivashy.backend.composer.model.TrackMetadata;
|
||||||
|
import com.bivashy.backend.composer.repository.TrackMetadataRepository;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class TrackMetadataService {
|
||||||
|
private final TrackMetadataRepository trackMetadataRepository;
|
||||||
|
|
||||||
|
public TrackMetadataService(TrackMetadataRepository trackMetadataRepository) {
|
||||||
|
this.trackMetadataRepository = trackMetadataRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TrackMetadata createTrackMetadata(Track track, String title, String fileName, String audioPath,
|
||||||
|
String artist, String thumbnailPath, int durationSeconds) {
|
||||||
|
return trackMetadataRepository.save(
|
||||||
|
new TrackMetadata(track, title, fileName, audioPath, artist, thumbnailPath, durationSeconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
public TrackMetadata getTrackMetadata(Long trackId) {
|
||||||
|
return trackMetadataRepository.findByTrackId(trackId)
|
||||||
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Metadata not found"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,93 @@
|
|||||||
|
package com.bivashy.backend.composer.service;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import com.bivashy.backend.composer.model.TrackPlaylist;
|
||||||
|
import com.bivashy.backend.composer.model.key.PlaylistTrackId;
|
||||||
|
import com.bivashy.backend.composer.repository.TrackPlaylistRepository;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class TrackPlaylistService {
|
||||||
|
private final TrackPlaylistRepository repository;
|
||||||
|
|
||||||
|
public TrackPlaylistService(TrackPlaylistRepository repository) {
|
||||||
|
this.repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<TrackPlaylist> getPlaylistTracks(Long playlistId) {
|
||||||
|
return repository.findByPlaylistIdOrderByOrderAsc(playlistId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void insertTrackAtEnd(Long playlistId, Long trackId) {
|
||||||
|
addTrack(playlistId, trackId, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigDecimal findIntermediateOrder(BigDecimal prev, BigDecimal next) {
|
||||||
|
if (prev == null && next == null) {
|
||||||
|
return BigDecimal.ONE;
|
||||||
|
} else if (prev == null) {
|
||||||
|
return next.divide(BigDecimal.valueOf(2), 1000, RoundingMode.HALF_UP);
|
||||||
|
} else if (next == null) {
|
||||||
|
return prev.add(BigDecimal.ONE);
|
||||||
|
} else {
|
||||||
|
return prev.add(next).divide(BigDecimal.valueOf(2), 1000, RoundingMode.HALF_UP);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addTrack(Long playlistId, Long trackId, BigDecimal afterOrder) {
|
||||||
|
BigDecimal newOrder;
|
||||||
|
if (afterOrder == null) {
|
||||||
|
BigDecimal maxOrder = repository.findMaxOrderByPlaylistId(playlistId);
|
||||||
|
newOrder = findIntermediateOrder(maxOrder, null);
|
||||||
|
} else {
|
||||||
|
BigDecimal nextOrder = repository.findNextOrderByPlaylistIdAndOrderGreaterThan(playlistId, afterOrder);
|
||||||
|
newOrder = findIntermediateOrder(afterOrder, nextOrder);
|
||||||
|
}
|
||||||
|
TrackPlaylist trackPlaylist = new TrackPlaylist(playlistId, trackId, newOrder);
|
||||||
|
repository.save(trackPlaylist);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void bulkReorder(Long playlistId, List<Long> orderedTrackIds) {
|
||||||
|
List<TrackPlaylist> existingTracks = repository.findByPlaylistIdOrderByOrderAsc(playlistId);
|
||||||
|
Set<Long> existingTrackIds = existingTracks.stream()
|
||||||
|
.map(TrackPlaylist::getTrackId)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
|
||||||
|
if (!existingTrackIds.containsAll(orderedTrackIds) ||
|
||||||
|
orderedTrackIds.size() != existingTrackIds.size()) {
|
||||||
|
throw new IllegalArgumentException("Invalid track IDs provided for reordering");
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Optimize me
|
||||||
|
List<BigDecimal> newOrders = calculateNewOrders(orderedTrackIds.size());
|
||||||
|
|
||||||
|
for (int i = 0; i < orderedTrackIds.size(); i++) {
|
||||||
|
Long trackId = orderedTrackIds.get(i);
|
||||||
|
BigDecimal newOrder = newOrders.get(i);
|
||||||
|
|
||||||
|
TrackPlaylist trackPlaylist = repository.findById(new PlaylistTrackId(playlistId, trackId))
|
||||||
|
.orElseThrow(() -> new RuntimeException("Track not found in playlist"));
|
||||||
|
|
||||||
|
trackPlaylist.setOrder(newOrder);
|
||||||
|
repository.save(trackPlaylist);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<BigDecimal> calculateNewOrders(int count) {
|
||||||
|
List<BigDecimal> orders = new ArrayList<>();
|
||||||
|
BigDecimal step = BigDecimal.valueOf(1000);
|
||||||
|
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
orders.add(step.multiply(BigDecimal.valueOf(i + 1)));
|
||||||
|
}
|
||||||
|
return orders;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,98 @@
|
|||||||
|
package com.bivashy.backend.composer.service;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import com.bivashy.backend.composer.dto.track.AddLocalTrackRequest;
|
||||||
|
import com.bivashy.backend.composer.dto.track.PlaylistTrackResponse;
|
||||||
|
import com.bivashy.backend.composer.dto.track.TrackBulkReorderRequest;
|
||||||
|
import com.bivashy.backend.composer.dto.track.TrackReorderAfterRequest;
|
||||||
|
import com.bivashy.backend.composer.dto.track.TrackResponse;
|
||||||
|
import com.bivashy.backend.composer.model.SourceTypes;
|
||||||
|
import com.bivashy.backend.composer.model.Track;
|
||||||
|
import com.bivashy.backend.composer.model.TrackMetadata;
|
||||||
|
import com.bivashy.backend.composer.model.TrackSource;
|
||||||
|
import com.bivashy.backend.composer.model.User;
|
||||||
|
import com.bivashy.backend.composer.repository.TrackRepository;
|
||||||
|
import com.bivashy.backend.composer.service.MetadataParseService.Metadata;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class TrackService {
|
||||||
|
private final TrackRepository trackRepository;
|
||||||
|
private final TrackSourceService trackSourceService;
|
||||||
|
private final TrackMetadataService trackMetadataService;
|
||||||
|
private final TrackPlaylistService trackPlaylistService;
|
||||||
|
private final MetadataParseService metadataParseService;
|
||||||
|
|
||||||
|
public TrackService(TrackRepository trackRepository,
|
||||||
|
TrackSourceService trackSourceService,
|
||||||
|
TrackMetadataService trackMetadataService,
|
||||||
|
TrackPlaylistService trackPlaylistService,
|
||||||
|
MetadataParseService metadataParseService) {
|
||||||
|
this.trackRepository = trackRepository;
|
||||||
|
this.trackSourceService = trackSourceService;
|
||||||
|
this.trackMetadataService = trackMetadataService;
|
||||||
|
this.trackPlaylistService = trackPlaylistService;
|
||||||
|
this.metadataParseService = metadataParseService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TrackResponse addLocalTrack(User user, Long playlistId, AddLocalTrackRequest request) throws IOException {
|
||||||
|
Optional<Metadata> metadata = metadataParseService.extractMetadata(request.source().getInputStream());
|
||||||
|
String ffprobeJson = metadata.map(Metadata::rawJson).orElse("{}");
|
||||||
|
|
||||||
|
TrackSource trackSource = trackSourceService.createTrackSource(
|
||||||
|
request.source().getBytes(), ffprobeJson, SourceTypes.FILE);
|
||||||
|
|
||||||
|
Track track = trackRepository.save(new Track(trackSource));
|
||||||
|
|
||||||
|
String fileName = fileNameWithoutExtension(request.source().getOriginalFilename());
|
||||||
|
String title = metadata.map(Metadata::title).orElse(fileName);
|
||||||
|
String artist = metadata.map(Metadata::artist).orElse(null);
|
||||||
|
int durationSeconds = metadata.map(Metadata::durationSeconds).map(Float::intValue).orElse(0);
|
||||||
|
// TODO: thumbnail
|
||||||
|
// TODO: Recognize music if the duration is less than five minutes
|
||||||
|
// (configurable), and if not, it is a playlist and should be marked as is
|
||||||
|
trackMetadataService.createTrackMetadata(
|
||||||
|
track, title, fileName, trackSource.getSourceUrl(), artist, null, durationSeconds);
|
||||||
|
|
||||||
|
trackPlaylistService.insertTrackAtEnd(playlistId, track.getId());
|
||||||
|
return new TrackResponse(
|
||||||
|
track.getId(),
|
||||||
|
title,
|
||||||
|
artist,
|
||||||
|
trackSource.getSourceUrl(),
|
||||||
|
durationSeconds,
|
||||||
|
fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<PlaylistTrackResponse> getPlaylistTracks(User user, Long playlistId) {
|
||||||
|
return trackPlaylistService.getPlaylistTracks(playlistId).stream()
|
||||||
|
.map(pt -> {
|
||||||
|
Track track = trackRepository.findById(pt.getTrackId())
|
||||||
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Track not found"));
|
||||||
|
TrackMetadata metadata = trackMetadataService.getTrackMetadata(track.getId());
|
||||||
|
return new PlaylistTrackResponse(
|
||||||
|
track.getId(),
|
||||||
|
metadata.getTitle(),
|
||||||
|
metadata.getArtist(),
|
||||||
|
metadata.getAudioPath(),
|
||||||
|
metadata.getDurationSeconds(),
|
||||||
|
metadata.getFileName());
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void bulkReorder(User user, Long playlistId, TrackBulkReorderRequest request) {
|
||||||
|
trackPlaylistService.bulkReorder(playlistId, request.trackIds());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String fileNameWithoutExtension(String fileName) {
|
||||||
|
return fileName.replaceFirst("[.][^.]+$", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
package com.bivashy.backend.composer.service;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import com.bivashy.backend.composer.model.SourceType;
|
||||||
|
import com.bivashy.backend.composer.model.TrackSource;
|
||||||
|
import com.bivashy.backend.composer.repository.SourceTypeRepository;
|
||||||
|
import com.bivashy.backend.composer.repository.TrackSourceRepository;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class TrackSourceService {
|
||||||
|
private final TrackSourceRepository trackSourceRepository;
|
||||||
|
private final SourceTypeRepository sourceTypeRepository;
|
||||||
|
private final AudioBlobStorageService s3Service;
|
||||||
|
|
||||||
|
public TrackSourceService(TrackSourceRepository trackSourceRepository,
|
||||||
|
SourceTypeRepository sourceTypeRepository,
|
||||||
|
AudioBlobStorageService s3Service) {
|
||||||
|
this.trackSourceRepository = trackSourceRepository;
|
||||||
|
this.sourceTypeRepository = sourceTypeRepository;
|
||||||
|
this.s3Service = s3Service;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TrackSource createTrackSource(byte[] audioBytes, String ffprobeJson, String sourceType) {
|
||||||
|
String audioPath = s3Service.store(audioBytes, Map.of("ffprobe", ffprobeJson));
|
||||||
|
SourceType type = sourceTypeRepository.findByName(sourceType)
|
||||||
|
.orElseThrow(() -> new IllegalStateException("Source type not found: " + sourceType));
|
||||||
|
return trackSourceRepository.save(new TrackSource(audioPath, type, LocalDateTime.now()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
package com.bivashy.backend.composer.service.importing;
|
||||||
|
|
||||||
|
import org.springframework.data.redis.connection.Message;
|
||||||
|
import org.springframework.data.redis.connection.MessageListener;
|
||||||
|
import org.springframework.data.redis.listener.ChannelTopic;
|
||||||
|
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import com.bivashy.backend.composer.dto.importing.ImportTrackKey;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class RedisMessageSubscriber {
|
||||||
|
|
||||||
|
private final RedisMessageListenerContainer container;
|
||||||
|
private final Map<String, Consumer<String>> subscriptions = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public RedisMessageSubscriber(RedisMessageListenerContainer container) {
|
||||||
|
this.container = container;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void subscribeToPlaylist(long playlistId, long userId, Consumer<String> messageHandler) {
|
||||||
|
String channel = ImportTrackKey.redisChannelKey(playlistId, userId);
|
||||||
|
String subscriptionKey = ImportTrackKey.subscriptionKey(playlistId, userId);
|
||||||
|
|
||||||
|
if (!subscriptions.containsKey(subscriptionKey)) {
|
||||||
|
container.addMessageListener(new MessageListener() {
|
||||||
|
@Override
|
||||||
|
public void onMessage(Message message, byte[] pattern) {
|
||||||
|
String receivedMessage = new String(message.getBody());
|
||||||
|
if (subscriptions.containsKey(subscriptionKey)) {
|
||||||
|
messageHandler.accept(receivedMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, new ChannelTopic(channel));
|
||||||
|
|
||||||
|
subscriptions.put(subscriptionKey, messageHandler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void unsubscribeFromPlaylist(long playlistId, long userId) {
|
||||||
|
String subscriptionKey = ImportTrackKey.subscriptionKey(playlistId, userId);
|
||||||
|
subscriptions.remove(subscriptionKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,106 @@
|
|||||||
|
package com.bivashy.backend.composer.service.importing;
|
||||||
|
|
||||||
|
import com.bivashy.backend.composer.dto.importing.ImportTrackKey;
|
||||||
|
import com.bivashy.backend.composer.dto.importing.TrackProgressDTO;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class RedisProgressService {
|
||||||
|
private final StringRedisTemplate redisTemplate;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final Map<String, Set<String>> activeConnections = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public RedisProgressService(StringRedisTemplate redisTemplate,
|
||||||
|
ObjectMapper objectMapper) {
|
||||||
|
this.redisTemplate = redisTemplate;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void saveProgress(TrackProgressDTO progress) {
|
||||||
|
try {
|
||||||
|
String key = ImportTrackKey.progressKey(progress.getPlaylistId(), progress.getUserId());
|
||||||
|
String trackKey = ImportTrackKey.trackKey(
|
||||||
|
progress.getPlaylistId(),
|
||||||
|
progress.getTrackId(),
|
||||||
|
progress.getUserId());
|
||||||
|
|
||||||
|
String progressJson = objectMapper.writeValueAsString(progress);
|
||||||
|
redisTemplate.opsForHash().put(key, progress.getTrackId(), progressJson);
|
||||||
|
|
||||||
|
redisTemplate.opsForValue().set(trackKey, progressJson);
|
||||||
|
|
||||||
|
redisTemplate.expire(key, 24, java.util.concurrent.TimeUnit.HOURS);
|
||||||
|
redisTemplate.expire(trackKey, 24, java.util.concurrent.TimeUnit.HOURS);
|
||||||
|
|
||||||
|
publishProgressUpdate(progress);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("Failed to save progress to Redis", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<TrackProgressDTO> getPlaylistProgress(long playlistId, long userId) {
|
||||||
|
try {
|
||||||
|
String key = ImportTrackKey.progressKey(playlistId, userId);
|
||||||
|
Map<Object, Object> progressMap = redisTemplate.opsForHash().entries(key);
|
||||||
|
|
||||||
|
List<TrackProgressDTO> progressList = new ArrayList<>();
|
||||||
|
for (Object value : progressMap.values()) {
|
||||||
|
TrackProgressDTO progress = objectMapper.readValue(
|
||||||
|
(String) value,
|
||||||
|
TrackProgressDTO.class);
|
||||||
|
progressList.add(progress);
|
||||||
|
}
|
||||||
|
|
||||||
|
progressList.sort(Comparator.comparingLong(TrackProgressDTO::getTimestamp));
|
||||||
|
|
||||||
|
return progressList;
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("Failed to get progress from Redis", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public TrackProgressDTO getTrackProgress(long playlistId, String trackId, long userId) {
|
||||||
|
try {
|
||||||
|
String key = ImportTrackKey.trackKey(playlistId, trackId, userId);
|
||||||
|
String progressJson = redisTemplate.opsForValue().get(key);
|
||||||
|
|
||||||
|
if (progressJson != null) {
|
||||||
|
return objectMapper.readValue(progressJson, TrackProgressDTO.class);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("Failed to get track progress", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void publishProgressUpdate(TrackProgressDTO progress) {
|
||||||
|
try {
|
||||||
|
String channel = ImportTrackKey.redisChannelKey(progress.getPlaylistId(), progress.getUserId());
|
||||||
|
String message = objectMapper.writeValueAsString(progress);
|
||||||
|
redisTemplate.convertAndSend(channel, message);
|
||||||
|
} catch (Exception e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addActiveConnection(long playlistId, long userId) {
|
||||||
|
String connectionKey = ImportTrackKey.subscriptionKey(playlistId, userId);
|
||||||
|
activeConnections.computeIfAbsent(connectionKey, k -> ConcurrentHashMap.newKeySet()).add(connectionKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void removeActiveConnection(long playlistId, long userId) {
|
||||||
|
String connectionKey = ImportTrackKey.subscriptionKey(playlistId, userId);
|
||||||
|
activeConnections.remove(connectionKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasActiveConnections(long playlistId, long userId) {
|
||||||
|
String connectionKey = ImportTrackKey.subscriptionKey(playlistId, userId);
|
||||||
|
return activeConnections.containsKey(connectionKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -11,6 +11,12 @@ spring:
|
|||||||
access-key: ${S3_ACCESS_KEY}
|
access-key: ${S3_ACCESS_KEY}
|
||||||
secret-key: ${S3_SECRET_KEY}
|
secret-key: ${S3_SECRET_KEY}
|
||||||
bucket: ${S3_BUCKET}
|
bucket: ${S3_BUCKET}
|
||||||
|
redis:
|
||||||
|
host-name: ${REDIS_HOST_NAME}
|
||||||
|
servlet:
|
||||||
|
multipart:
|
||||||
|
max-file-size: 8096MB
|
||||||
|
max-request-size: 8096MB
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
|
|||||||
@ -5,17 +5,9 @@ CREATE TABLE IF NOT EXISTS "users" (
|
|||||||
"updated_at" timestamp NOT NULL DEFAULT NOW()
|
"updated_at" timestamp NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS "source_provider" (
|
|
||||||
"id" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
||||||
"name" varchar(500) NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS "source_type" (
|
CREATE TABLE IF NOT EXISTS "source_type" (
|
||||||
"id" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
"id" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
"provider_id" bigint NOT NULL,
|
"name" varchar(500) NOT NULL
|
||||||
"name" varchar(500) NOT NULL,
|
|
||||||
CONSTRAINT "fk_source_type_provider_id"
|
|
||||||
FOREIGN KEY ("provider_id") REFERENCES "source_provider" ("id")
|
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS "track_source" (
|
CREATE TABLE IF NOT EXISTS "track_source" (
|
||||||
@ -40,6 +32,7 @@ CREATE TABLE IF NOT EXISTS "track_metadata" (
|
|||||||
"id" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
"id" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
"track_id" bigint NOT NULL,
|
"track_id" bigint NOT NULL,
|
||||||
"title" varchar(500) NOT NULL,
|
"title" varchar(500) NOT NULL,
|
||||||
|
"file_name" varchar(500) NOT NULL,
|
||||||
"audio_path" varchar(500) NOT NULL,
|
"audio_path" varchar(500) NOT NULL,
|
||||||
"artist" varchar(500),
|
"artist" varchar(500),
|
||||||
"thumbnail_path" varchar(500),
|
"thumbnail_path" varchar(500),
|
||||||
@ -51,21 +44,23 @@ CREATE TABLE IF NOT EXISTS "track_metadata" (
|
|||||||
CREATE TABLE IF NOT EXISTS "playlist" (
|
CREATE TABLE IF NOT EXISTS "playlist" (
|
||||||
"id" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
"id" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
"owner_id" bigint NOT NULL,
|
"owner_id" bigint NOT NULL,
|
||||||
"title" varchar(500) NOT NULL UNIQUE,
|
"title" varchar(500) NOT NULL,
|
||||||
"created_at" timestamp NOT NULL DEFAULT NOW(),
|
"created_at" timestamp NOT NULL DEFAULT NOW(),
|
||||||
"updated_at" timestamp NOT NULL DEFAULT NOW(),
|
"updated_at" timestamp NOT NULL DEFAULT NOW(),
|
||||||
CONSTRAINT "fk_playlist_owner_id"
|
CONSTRAINT "fk_playlist_owner_id"
|
||||||
FOREIGN KEY ("owner_id") REFERENCES "users" ("id")
|
FOREIGN KEY ("owner_id") REFERENCES "users" ("id"),
|
||||||
|
CONSTRAINT "uq_playlist_owner_title"
|
||||||
|
UNIQUE ("owner_id", "title")
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS "playlist_track" (
|
CREATE TABLE IF NOT EXISTS "playlist_track" (
|
||||||
"playlist_id" bigint NOT NULL,
|
"playlist_id" bigint NOT NULL,
|
||||||
"track_id" bigint NOT NULL,
|
"track_id" bigint NOT NULL,
|
||||||
"order" bigint NOT NULL,
|
"order_index" numeric NOT NULL,
|
||||||
CONSTRAINT "pk_playlist_track" PRIMARY KEY ("playlist_id", "track_id"),
|
CONSTRAINT "pk_playlist_track_new" PRIMARY KEY ("playlist_id", "track_id"),
|
||||||
CONSTRAINT "fk_playlist_track_playlist_id"
|
CONSTRAINT "fk_playlist_track_playlist_id_new"
|
||||||
FOREIGN KEY ("playlist_id") REFERENCES "playlist" ("id"),
|
FOREIGN KEY ("playlist_id") REFERENCES "playlist" ("id"),
|
||||||
CONSTRAINT "fk_playlist_track_track_id"
|
CONSTRAINT "fk_playlist_track_track_id_new"
|
||||||
FOREIGN KEY ("track_id") REFERENCES "track" ("id")
|
FOREIGN KEY ("track_id") REFERENCES "track" ("id")
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -1,17 +1,9 @@
|
|||||||
INSERT INTO "source_provider" ("id", "name")
|
INSERT INTO "source_type" ("id", "name")
|
||||||
OVERRIDING SYSTEM VALUE
|
OVERRIDING SYSTEM VALUE
|
||||||
VALUES
|
VALUES
|
||||||
(1, 'YOUTUBE'),
|
(1, 'VIDEO'),
|
||||||
(2, 'LOCAL'),
|
(2, 'PLAYLIST'),
|
||||||
(3, 'EXTERNAL')
|
(3, 'FILE'),
|
||||||
ON CONFLICT ("id") DO NOTHING;
|
(4, 'URL')
|
||||||
|
|
||||||
INSERT INTO "source_type" ("id", "provider_id", "name")
|
|
||||||
OVERRIDING SYSTEM VALUE
|
|
||||||
VALUES
|
|
||||||
(1, 1, 'VIDEO'),
|
|
||||||
(2, 1, 'PLAYLIST'),
|
|
||||||
(3, 2, 'FILE'),
|
|
||||||
(4, 3, 'URL')
|
|
||||||
ON CONFLICT ("id") DO NOTHING;
|
ON CONFLICT ("id") DO NOTHING;
|
||||||
|
|
||||||
|
|||||||
3
src/test/resources/application-test.yml
Normal file
3
src/test/resources/application-test.yml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
spring:
|
||||||
|
flyway:
|
||||||
|
enabled: false
|
||||||
Reference in New Issue
Block a user