Compare commits
17 Commits
ef72a590d2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
0b669c2a23
|
|||
|
9fb0ab3513
|
|||
|
f6b545d46d
|
|||
| 3334d51e96 | |||
| 802e968522 | |||
|
0385c3d3e3
|
|||
|
5a68f652ed
|
|||
|
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_SECRET_KEY=minioadmin
|
||||
S3_BUCKET=composer-dev
|
||||
|
||||
REDIS_HOST_NAME=redis
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -33,3 +33,5 @@ build/
|
||||
.vscode/
|
||||
|
||||
.env
|
||||
|
||||
.sqruff
|
||||
|
||||
48
Dockerfile.dev
Normal file
48
Dockerfile.dev
Normal file
@ -0,0 +1,48 @@
|
||||
FROM eclipse-temurin:21-jdk-jammy
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
vim \
|
||||
git \
|
||||
unzip \
|
||||
python3.11 \
|
||||
ca-certificates \
|
||||
ffmpeg && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN ln -sf /usr/bin/python3.11 /usr/bin/python3
|
||||
|
||||
RUN curl -fsSL https://bun.sh/install > install.sh && \
|
||||
chmod +x install.sh && \
|
||||
./install.sh && \
|
||||
rm install.sh
|
||||
|
||||
RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp && \
|
||||
chmod a+rx /usr/local/bin/yt-dlp
|
||||
|
||||
|
||||
# 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:
|
||||
composer_backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.dev
|
||||
env_file: .env
|
||||
ports:
|
||||
- 8080:8080
|
||||
networks:
|
||||
- mp3_composer
|
||||
volumes:
|
||||
- .:/app
|
||||
- maven-repo:/home/spring-app/.m2/
|
||||
|
||||
s3:
|
||||
image: minio/minio:latest
|
||||
container_name: composer_s3
|
||||
@ -6,7 +19,7 @@ services:
|
||||
networks:
|
||||
- mp3_composer
|
||||
ports:
|
||||
- 9000:9000
|
||||
- 0:9001
|
||||
postgres:
|
||||
image: postgres:alpine
|
||||
container_name: composer_postgres
|
||||
@ -39,6 +52,9 @@ services:
|
||||
networks:
|
||||
- mp3_composer
|
||||
|
||||
volumes:
|
||||
maven-repo:
|
||||
|
||||
networks:
|
||||
mp3_composer:
|
||||
driver: bridge
|
||||
external: true
|
||||
|
||||
64
pom.xml
64
pom.xml
@ -33,7 +33,17 @@
|
||||
<modelmapper.version>3.2.4</modelmapper.version>
|
||||
<spring-dotenv.version>4.0.0</spring-dotenv.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>
|
||||
<yt-dlp-java.version>2.0.8</yt-dlp-java.version>
|
||||
<record-builder.version>51</record-builder.version>
|
||||
</properties>
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>jitpack.io</id>
|
||||
<url>https://jitpack.io</url>
|
||||
</repository>
|
||||
</repositories>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
@ -82,6 +92,14 @@
|
||||
<groupId>software.amazon.awssdk</groupId>
|
||||
<artifactId>s3</artifactId>
|
||||
</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>
|
||||
<groupId>me.paulschwarz</groupId>
|
||||
@ -98,12 +116,45 @@
|
||||
<artifactId>tika-parsers-standard-package</artifactId>
|
||||
<version>${apache-tika.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||
<version>${springdoc-openapi.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
|
||||
<version>${springdoc-openapi.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.github.kokorin.jaffree</groupId>
|
||||
<artifactId>jaffree</artifactId>
|
||||
<version>${jaffree.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.github.bivashy</groupId>
|
||||
<artifactId>yt-dlp-java</artifactId>
|
||||
<version>${yt-dlp-java.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.soabase.record-builder</groupId>
|
||||
<artifactId>record-builder-core</artifactId>
|
||||
<version>${record-builder.version}</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-devtools</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
@ -127,6 +178,19 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<configuration>
|
||||
<annotationProcessorPaths>
|
||||
<path>
|
||||
<groupId>io.soabase.record-builder</groupId>
|
||||
<artifactId>record-builder-processor</artifactId>
|
||||
<version>${record-builder.version}</version>
|
||||
</path>
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
|
||||
@ -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
|
||||
.anyRequest().authenticated())
|
||||
.httpBasic(Customizer.withDefaults())
|
||||
// TODO: Temporary to test API, remove after testing
|
||||
.csrf(c -> c.disable());
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CustomUserDetailsService customUserDetailsService(UserRepository repository) {
|
||||
CustomUserDetails defaultUser = new CustomUserDetails(1, "user", passwordEncoder().encode("password"));
|
||||
Optional<User> user = repository.findById(defaultUser.getId());
|
||||
if (user.isEmpty()) {
|
||||
repository.save(new User(defaultUser.getUsername()));
|
||||
CustomUserDetails defaultUser1 = create(repository, 1, "user", passwordEncoder().encode("password"));
|
||||
CustomUserDetails defaultUser2 = create(repository, 2, "user1", passwordEncoder().encode("password"));
|
||||
return new CustomUserDetailsService(defaultUser1, defaultUser2);
|
||||
}
|
||||
return new CustomUserDetailsService(defaultUser);
|
||||
|
||||
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()) {
|
||||
repository.save(new User(userDetails.getUsername()));
|
||||
}
|
||||
return userDetails;
|
||||
}
|
||||
|
||||
@Bean
|
||||
|
||||
@ -9,8 +9,8 @@ import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import com.bivashy.backend.composer.auth.CustomUserDetails;
|
||||
import com.bivashy.backend.composer.dto.playlist.PlaylistCreateDTO;
|
||||
import com.bivashy.backend.composer.dto.playlist.PlaylistReadDTO;
|
||||
import com.bivashy.backend.composer.dto.playlist.PlaylistCreateRequest;
|
||||
import com.bivashy.backend.composer.dto.playlist.PlaylistReadResponse;
|
||||
import com.bivashy.backend.composer.service.PlaylistService;
|
||||
|
||||
@RestController
|
||||
@ -22,13 +22,14 @@ public class PlaylistController {
|
||||
}
|
||||
|
||||
@GetMapping("/playlists")
|
||||
public List<PlaylistReadDTO> playlists(@AuthenticationPrincipal CustomUserDetails user) {
|
||||
public List<PlaylistReadResponse> playlists(@AuthenticationPrincipal CustomUserDetails user) {
|
||||
return service.findPlaylists(user.getId());
|
||||
}
|
||||
|
||||
@PostMapping("/playlist")
|
||||
public PlaylistReadDTO createPlaylist(@AuthenticationPrincipal CustomUserDetails user,
|
||||
@RequestBody PlaylistCreateDTO playlist) {
|
||||
public PlaylistReadResponse createPlaylist(@AuthenticationPrincipal CustomUserDetails user,
|
||||
@RequestBody PlaylistCreateRequest playlist) {
|
||||
return service.createPlaylist(user.getId(), playlist);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,79 @@
|
||||
package com.bivashy.backend.composer.controller;
|
||||
|
||||
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.auth.CustomUserDetails;
|
||||
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.TrackResponse;
|
||||
import com.bivashy.backend.composer.dto.track.YoutubeTrackRequest;
|
||||
import com.bivashy.backend.composer.dto.track.service.AddLocalTrackParamsBuilder;
|
||||
import com.bivashy.backend.composer.exception.ImportTrackException;
|
||||
import com.bivashy.backend.composer.service.TrackService;
|
||||
import com.bivashy.backend.composer.util.SimpleBlob.MultipartBlob;
|
||||
|
||||
@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 CustomUserDetails user,
|
||||
@PathVariable long playlistId,
|
||||
@ModelAttribute AddLocalTrackRequest request) throws ImportTrackException {
|
||||
var params = AddLocalTrackParamsBuilder.builder()
|
||||
.blob(new MultipartBlob(request.source()))
|
||||
.includeProgressHistory(true)
|
||||
.build();
|
||||
TrackResponse response = trackService.addLocalTrack(user, playlistId, params);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@PostMapping(path = "/playlist/{playlistId}/track/youtube/refresh/{sourceId}")
|
||||
public ResponseEntity<List<TrackResponse>> addYoutubeTrack(
|
||||
@AuthenticationPrincipal CustomUserDetails user,
|
||||
@PathVariable long playlistId,
|
||||
@PathVariable long sourceId) throws ImportTrackException {
|
||||
List<TrackResponse> response = trackService.refreshYoutubePlaylist(user, playlistId, sourceId);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@PostMapping(path = "/playlist/{playlistId}/track/youtube")
|
||||
public ResponseEntity<List<TrackResponse>> addYoutubeTrack(
|
||||
@AuthenticationPrincipal CustomUserDetails user,
|
||||
@PathVariable long playlistId,
|
||||
@RequestBody YoutubeTrackRequest request) throws ImportTrackException {
|
||||
List<TrackResponse> response = trackService.addYoutubeTrack(user, playlistId, request);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
@GetMapping("/playlist/{playlistId}/tracks")
|
||||
public ResponseEntity<List<PlaylistTrackResponse>> getPlaylistTracks(
|
||||
@AuthenticationPrincipal CustomUserDetails user,
|
||||
@PathVariable Long playlistId) {
|
||||
List<PlaylistTrackResponse> tracks = trackService.getPlaylistTracks(user, playlistId);
|
||||
return ResponseEntity.ok(tracks);
|
||||
}
|
||||
|
||||
@PostMapping("/playlist/{playlistId}/bulk-reorder")
|
||||
public void bulkReorder(@AuthenticationPrincipal CustomUserDetails user,
|
||||
@RequestBody TrackBulkReorderRequest request,
|
||||
@PathVariable Long playlistId) {
|
||||
trackService.bulkReorder(user, playlistId, request);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,101 @@
|
||||
package com.bivashy.backend.composer.controller.importing;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import com.bivashy.backend.composer.auth.CustomUserDetails;
|
||||
import com.bivashy.backend.composer.dto.importing.BaseTrackProgress;
|
||||
import com.bivashy.backend.composer.dto.importing.ImportTrackKey;
|
||||
import com.bivashy.backend.composer.service.importing.RedisMessageSubscriber;
|
||||
import com.bivashy.backend.composer.service.importing.RedisProgressService;
|
||||
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Sinks;
|
||||
|
||||
@RestController
|
||||
public class ProgressSSEController {
|
||||
private static final Logger logger = LoggerFactory.getLogger(ProgressSSEController.class);
|
||||
|
||||
private final RedisProgressService redisProgressService;
|
||||
private final RedisMessageSubscriber redisSubscriber;
|
||||
private final Map<String, Sinks.Many<BaseTrackProgress>> sinks = new ConcurrentHashMap<>();
|
||||
|
||||
public ProgressSSEController(RedisProgressService redisProgressService,
|
||||
RedisMessageSubscriber redisSubscriber) {
|
||||
this.redisProgressService = redisProgressService;
|
||||
this.redisSubscriber = redisSubscriber;
|
||||
}
|
||||
|
||||
@GetMapping(value = "/importing/stream/{playlistId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||
public Flux<BaseTrackProgress> 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<BaseTrackProgress> sink = sinks.computeIfAbsent(connectionKey, k -> {
|
||||
Sinks.Many<BaseTrackProgress> newSink = Sinks.many().replay().latest();
|
||||
|
||||
redisSubscriber.subscribeToPlaylist(playlistId, userId, message -> {
|
||||
newSink.tryEmitNext(message);
|
||||
});
|
||||
|
||||
return newSink;
|
||||
});
|
||||
|
||||
redisProgressService.addActiveConnection(playlistId, userId);
|
||||
|
||||
return sink.asFlux()
|
||||
.doFirst(() -> {
|
||||
try {
|
||||
List<BaseTrackProgress> existingProgresses = redisProgressService.getPlaylistProgress(
|
||||
playlistId,
|
||||
userId);
|
||||
|
||||
for (BaseTrackProgress progress : existingProgresses) {
|
||||
sink.tryEmitNext(progress);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
})
|
||||
.doOnCancel(() -> {
|
||||
cleanupConnection(playlistId, userId, sink, connectionKey);
|
||||
})
|
||||
.doOnTerminate(() -> {
|
||||
cleanupConnection(playlistId, userId, sink, connectionKey);
|
||||
})
|
||||
.onErrorResume(e -> {
|
||||
cleanupConnection(playlistId, userId, sink, connectionKey);
|
||||
return Flux.empty();
|
||||
});
|
||||
}
|
||||
|
||||
private void cleanupConnection(Long playlistId, long userId,
|
||||
Sinks.Many<BaseTrackProgress> 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 com.bivashy.backend.composer.dto.playlist.PlaylistCreateDTO;
|
||||
import com.bivashy.backend.composer.dto.playlist.PlaylistReadDTO;
|
||||
import com.bivashy.backend.composer.dto.playlist.PlaylistCreateRequest;
|
||||
import com.bivashy.backend.composer.dto.playlist.PlaylistReadResponse;
|
||||
import com.bivashy.backend.composer.model.Playlist;
|
||||
import com.bivashy.backend.composer.model.User;
|
||||
|
||||
@Component
|
||||
public class PlaylistConverter {
|
||||
public PlaylistReadDTO convertToRead(Playlist playlist) {
|
||||
return new PlaylistReadDTO(playlist.getId(), playlist.getOwner().getId(), playlist.getTitle(),
|
||||
public PlaylistReadResponse convertToRead(Playlist playlist) {
|
||||
return new PlaylistReadResponse(playlist.getId(), playlist.getOwner().getId(), playlist.getTitle(),
|
||||
playlist.getCreatedAt(),
|
||||
playlist.getUpdatedAt());
|
||||
}
|
||||
|
||||
public Playlist convertFromCreate(long userId, PlaylistCreateDTO playlist) {
|
||||
public Playlist convertFromCreate(long userId, PlaylistCreateRequest playlist) {
|
||||
return new Playlist(new User(userId), playlist.title());
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,58 @@
|
||||
package com.bivashy.backend.composer.dto.importing;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonSubTypes;
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||
|
||||
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type")
|
||||
@JsonSubTypes({
|
||||
@JsonSubTypes.Type(value = PlaylistProgress.class, name = "PLAYLIST"),
|
||||
@JsonSubTypes.Type(value = SingleTrackProgress.class, name = "TRACK"),
|
||||
})
|
||||
public abstract class BaseTrackProgress {
|
||||
protected UUID id;
|
||||
protected long playlistId;
|
||||
protected long trackSourceId;
|
||||
protected long userId;
|
||||
|
||||
protected long timestamp;
|
||||
private ProgressEntryType type;
|
||||
|
||||
public BaseTrackProgress(long playlistId, long trackSourceId, long userId) {
|
||||
this.id = UUID.randomUUID();
|
||||
this.playlistId = playlistId;
|
||||
this.trackSourceId = trackSourceId;
|
||||
this.userId = userId;
|
||||
this.timestamp = System.currentTimeMillis();
|
||||
}
|
||||
|
||||
public UUID getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public long getTimestamp() {
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
public Long getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public ProgressEntryType getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public long getPlaylistId() {
|
||||
return playlistId;
|
||||
}
|
||||
|
||||
public long getTrackSourceId() {
|
||||
return trackSourceId;
|
||||
}
|
||||
|
||||
protected void setType(ProgressEntryType type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
}
|
||||
@ -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, long trackSourceId, long userId) {
|
||||
return String.format("track:%d:%d:%d", userId, playlistId, trackSourceId);
|
||||
}
|
||||
|
||||
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,54 @@
|
||||
package com.bivashy.backend.composer.dto.importing;
|
||||
|
||||
public class PlaylistProgress extends BaseTrackProgress {
|
||||
private String ytdlnStdout;
|
||||
private int overallProgress;
|
||||
private int trackCount;
|
||||
private String playlistTitle;
|
||||
private ProgressStatus status;
|
||||
|
||||
PlaylistProgress() {
|
||||
super(0, 0, 0);
|
||||
}
|
||||
|
||||
public PlaylistProgress(long playlistId, long trackSourceId, long userId, int trackCount, String playlistTitle) {
|
||||
super(playlistId, trackSourceId, userId);
|
||||
this.setType(ProgressEntryType.PLAYLIST);
|
||||
this.status = ProgressStatus.LOADING;
|
||||
this.trackCount = trackCount;
|
||||
this.playlistTitle = playlistTitle;
|
||||
}
|
||||
|
||||
public String getYtdlnStdout() {
|
||||
return ytdlnStdout;
|
||||
}
|
||||
|
||||
public void setYtdlnStdout(String ytdlnStdout) {
|
||||
this.ytdlnStdout = ytdlnStdout;
|
||||
}
|
||||
|
||||
public int getOverallProgress() {
|
||||
return overallProgress;
|
||||
}
|
||||
|
||||
public void setOverallProgress(int overallProgress) {
|
||||
this.overallProgress = overallProgress;
|
||||
}
|
||||
|
||||
public ProgressStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(ProgressStatus status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public int getTrackCount() {
|
||||
return trackCount;
|
||||
}
|
||||
|
||||
public String getPlaylistTitle() {
|
||||
return playlistTitle;
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
package com.bivashy.backend.composer.dto.importing;
|
||||
|
||||
public enum ProgressEntryType {
|
||||
PLAYLIST,
|
||||
TRACK,
|
||||
EXTERNAL_TRACK
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
package com.bivashy.backend.composer.dto.importing;
|
||||
|
||||
public enum ProgressStatus {
|
||||
LOADING, FINISHED
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
package com.bivashy.backend.composer.dto.importing;
|
||||
|
||||
public class SingleTrackProgress extends BaseTrackProgress {
|
||||
private String title;
|
||||
private String format;
|
||||
|
||||
SingleTrackProgress() {
|
||||
super(0, 0, 0);
|
||||
}
|
||||
|
||||
public SingleTrackProgress(long playlistId, long trackSourceId, long userId, String title, String format) {
|
||||
super(playlistId, trackSourceId, userId);
|
||||
this.setType(ProgressEntryType.TRACK);
|
||||
this.title = title;
|
||||
this.format = format;
|
||||
}
|
||||
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
public String getFormat() {
|
||||
return format;
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
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;
|
||||
|
||||
public record PlaylistReadDTO(
|
||||
public record PlaylistReadResponse(
|
||||
long id,
|
||||
long ownerId,
|
||||
String title,
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime updatedAt) {
|
||||
public PlaylistReadDTO withUserId(long userId) {
|
||||
return new PlaylistReadDTO(this.id, userId, this.title, this.createdAt, this.updatedAt);
|
||||
public PlaylistReadResponse withUserId(long userId) {
|
||||
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,11 @@
|
||||
package com.bivashy.backend.composer.dto.track;
|
||||
|
||||
public record TrackResponse(
|
||||
Long trackId,
|
||||
String title,
|
||||
String artist,
|
||||
String audioPath,
|
||||
String fileFormat,
|
||||
Integer durationSeconds,
|
||||
String fileName) {
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
package com.bivashy.backend.composer.dto.track;
|
||||
|
||||
public record YoutubeTrackRequest(String youtubeUrl) {
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
package com.bivashy.backend.composer.dto.track.service;
|
||||
|
||||
import com.bivashy.backend.composer.util.SimpleBlob;
|
||||
|
||||
import io.soabase.recordbuilder.core.RecordBuilder;
|
||||
|
||||
@RecordBuilder
|
||||
public record AddLocalTrackParams(SimpleBlob blob, String ytdlpMetadata, boolean includeProgressHistory) {
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
package com.bivashy.backend.composer.exception;
|
||||
|
||||
public class ImportTrackException extends Exception {
|
||||
public ImportTrackException(String message, Exception cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
|
||||
public ImportTrackException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@ -28,7 +28,7 @@ public class Playlist {
|
||||
@JoinColumn(name = "owner_id", nullable = false)
|
||||
private User owner;
|
||||
|
||||
@Column(unique = true, nullable = false, length = 500)
|
||||
@Column(unique = true, nullable = false)
|
||||
private String title;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
package com.bivashy.backend.composer.model;
|
||||
|
||||
public enum SourceMetadataType {
|
||||
YOUTUBE
|
||||
}
|
||||
@ -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";
|
||||
|
||||
}
|
||||
@ -1,58 +1,5 @@
|
||||
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.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.OneToMany;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
@Entity
|
||||
@Table(name = "source_type")
|
||||
public class SourceType {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "provider_id", nullable = false)
|
||||
private SourceProvider provider;
|
||||
|
||||
@Column(nullable = false, length = 500)
|
||||
private String name;
|
||||
|
||||
@OneToMany(mappedBy = "sourceType", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
private Set<TrackSource> trackSources = new HashSet<>();
|
||||
|
||||
SourceType() {
|
||||
}
|
||||
|
||||
public SourceType(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public SourceProvider getProvider() {
|
||||
return provider;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public Set<TrackSource> getTrackSources() {
|
||||
return trackSources;
|
||||
}
|
||||
|
||||
public enum SourceType {
|
||||
VIDEO, PLAYLIST, PLAYLIST_ITEM, FILE, URL
|
||||
}
|
||||
|
||||
@ -1,8 +0,0 @@
|
||||
package com.bivashy.backend.composer.model;
|
||||
|
||||
public class SourceTypes {
|
||||
public static final String AUDIO = "VIDEO";
|
||||
public static final String PLAYLIST = "PLAYLIST";
|
||||
public static final String FILE = "FILE";
|
||||
public static final String URL = "URL";
|
||||
}
|
||||
@ -28,6 +28,9 @@ public class TrackMetadata {
|
||||
@Column(nullable = false, length = 500)
|
||||
private String title;
|
||||
|
||||
@Column(length = 500)
|
||||
private String fileName;
|
||||
|
||||
@Column(name = "audio_path", nullable = false, length = 500)
|
||||
private String audioPath;
|
||||
|
||||
@ -46,10 +49,12 @@ public class 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) {
|
||||
this.track = track;
|
||||
this.title = title;
|
||||
this.fileName = fileName;
|
||||
this.audioPath = audioPath;
|
||||
this.artist = artist;
|
||||
this.thumbnailPath = thumbnailPath;
|
||||
@ -68,6 +73,10 @@ public class TrackMetadata {
|
||||
return title;
|
||||
}
|
||||
|
||||
public String getFileName() {
|
||||
return fileName;
|
||||
}
|
||||
|
||||
public String getAudioPath() {
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -4,15 +4,16 @@ import java.time.LocalDateTime;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
import org.hibernate.annotations.JdbcType;
|
||||
import org.hibernate.dialect.PostgreSQLEnumJdbcType;
|
||||
|
||||
import jakarta.persistence.CascadeType;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.OneToMany;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
@ -23,11 +24,12 @@ public class TrackSource {
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "source_url", nullable = false, length = 500)
|
||||
@Column(name = "source_url", nullable = false)
|
||||
private String sourceUrl;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "source_type_id", nullable = false)
|
||||
@Enumerated
|
||||
@Column(name = "source_type", nullable = false)
|
||||
@JdbcType(PostgreSQLEnumJdbcType.class)
|
||||
private SourceType sourceType;
|
||||
|
||||
@Column(name = "last_fetched_at", nullable = false)
|
||||
|
||||
@ -0,0 +1,47 @@
|
||||
package com.bivashy.backend.composer.model;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
@Entity
|
||||
@Table(name = "track_source_metadata")
|
||||
public class TrackSourceMetadata {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "source_id", nullable = false, unique = true)
|
||||
private TrackSource source;
|
||||
|
||||
@Column(name = "url", nullable = false)
|
||||
private String url;
|
||||
|
||||
TrackSourceMetadata() {
|
||||
}
|
||||
|
||||
public TrackSourceMetadata(TrackSource source, String url) {
|
||||
this.source = source;
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public TrackSource getSource() {
|
||||
return source;
|
||||
}
|
||||
|
||||
public String getUrl() {
|
||||
return url;
|
||||
}
|
||||
|
||||
}
|
||||
@ -20,7 +20,7 @@ public class User {
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, length = 500)
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
|
||||
@ -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,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,18 @@
|
||||
package com.bivashy.backend.composer.repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import com.bivashy.backend.composer.model.TrackSourceMetadata;
|
||||
|
||||
@Repository
|
||||
public interface TrackSourceMetadataRepository extends JpaRepository<TrackSourceMetadata, Long> {
|
||||
@Query("SELECT tsm FROM TrackSourceMetadata tsm " +
|
||||
"JOIN FETCH tsm.source " +
|
||||
"WHERE tsm.source.id = :sourceId")
|
||||
Optional<TrackSourceMetadata> findBySourceIdWithSource(@Param("sourceId") Long sourceId);
|
||||
}
|
||||
@ -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,18 +2,27 @@ package com.bivashy.backend.composer.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.http.MediaType;
|
||||
|
||||
public interface AudioBlobStorageService {
|
||||
String storeFolder();
|
||||
|
||||
String store(InputStream inputStream);
|
||||
|
||||
String store(byte[] data);
|
||||
|
||||
String store(InputStream inputStream, Map<String, String> metadata);
|
||||
|
||||
String store(byte[] data, Map<String, String> metadata);
|
||||
|
||||
String store(String key, byte[] data, Map<String, String> metadata);
|
||||
|
||||
byte[] readRaw(String path) throws IOException;
|
||||
|
||||
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.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.apache.tika.Tika;
|
||||
@ -18,6 +19,7 @@ import software.amazon.awssdk.core.sync.RequestBody;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.GetObjectResponse;
|
||||
import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
|
||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
||||
|
||||
@Service
|
||||
@ -35,21 +37,71 @@ public class AudioS3StorageService implements AudioBlobStorageService {
|
||||
this.tika = tika;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String storeFolder() {
|
||||
String objectKey = newObjectName();
|
||||
if (!objectKey.endsWith("/"))
|
||||
objectKey = objectKey + "/";
|
||||
s3Client.putObject(PutObjectRequest.builder()
|
||||
.bucket(bucket)
|
||||
.key(objectKey)
|
||||
.build(), RequestBody.empty());
|
||||
return objectKey;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String store(InputStream inputStream) {
|
||||
return store(new ByteArrayInputStream(DEFAULT_BUFFER).readAllBytes());
|
||||
return store(inputStream, Map.of());
|
||||
}
|
||||
|
||||
@Override
|
||||
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 contentType = detectContentType(data);
|
||||
s3Client.putObject(PutObjectRequest.builder()
|
||||
.bucket(bucket)
|
||||
.key(objectKey)
|
||||
.contentType(contentType)
|
||||
.metadata(metadata)
|
||||
.build(), RequestBody.fromBytes(data));
|
||||
return String.join("/", bucket, objectKey);
|
||||
return objectKey;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String store(String key, byte[] data, Map<String, String> metadata) {
|
||||
try {
|
||||
ResponseInputStream<GetObjectResponse> response = s3Client.getObject(GetObjectRequest.builder()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.build());
|
||||
|
||||
s3Client.putObject(PutObjectRequest.builder()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.contentType(response.response().contentType())
|
||||
.metadata(metadata)
|
||||
.build(), RequestBody.fromBytes(data));
|
||||
|
||||
return key;
|
||||
} catch (NoSuchKeyException e) {
|
||||
System.out.println("no existing found");
|
||||
s3Client.putObject(PutObjectRequest.builder()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.metadata(metadata)
|
||||
.build(), RequestBody.fromBytes(data));
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -59,9 +111,6 @@ public class AudioS3StorageService implements AudioBlobStorageService {
|
||||
|
||||
@Override
|
||||
public Blob read(String path) {
|
||||
if (path.startsWith(bucket + "/")) {
|
||||
path = path.substring(bucket.length());
|
||||
}
|
||||
ResponseInputStream<GetObjectResponse> response = s3Client.getObject(GetObjectRequest.builder()
|
||||
.bucket(bucket)
|
||||
.key(path)
|
||||
@ -73,7 +122,7 @@ public class AudioS3StorageService implements AudioBlobStorageService {
|
||||
} catch (InvalidMediaTypeException 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) {
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
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.UserDetailsService;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
@ -9,15 +12,18 @@ import com.bivashy.backend.composer.auth.CustomUserDetails;
|
||||
|
||||
@Service
|
||||
public class CustomUserDetailsService implements UserDetailsService {
|
||||
private final CustomUserDetails defaultUser;
|
||||
private final List<CustomUserDetails> users;
|
||||
|
||||
public CustomUserDetailsService(CustomUserDetails defaultUser) {
|
||||
this.defaultUser = defaultUser;
|
||||
public CustomUserDetailsService(CustomUserDetails... users) {
|
||||
this.users = Arrays.asList(users);
|
||||
}
|
||||
|
||||
@Override
|
||||
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
|
||||
return defaultUser;
|
||||
return users.stream()
|
||||
.filter(user -> user.getUsername().equals(username))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,72 @@
|
||||
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());
|
||||
|
||||
var formatNameOpt = format.map(f -> f.getFormatName());
|
||||
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 formatName = formatNameOpt.orElse("unknown");
|
||||
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, formatName, foundDuration.orElse(0f), jsonResult));
|
||||
}
|
||||
|
||||
public static record Metadata(String title, String artist, String formatName, Float durationSeconds,
|
||||
String rawJson) {
|
||||
}
|
||||
}
|
||||
@ -5,8 +5,8 @@ import java.util.List;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.bivashy.backend.composer.converter.PlaylistConverter;
|
||||
import com.bivashy.backend.composer.dto.playlist.PlaylistCreateDTO;
|
||||
import com.bivashy.backend.composer.dto.playlist.PlaylistReadDTO;
|
||||
import com.bivashy.backend.composer.dto.playlist.PlaylistCreateRequest;
|
||||
import com.bivashy.backend.composer.dto.playlist.PlaylistReadResponse;
|
||||
import com.bivashy.backend.composer.model.Playlist;
|
||||
import com.bivashy.backend.composer.repository.PlaylistRepository;
|
||||
|
||||
@ -20,12 +20,12 @@ public class PlaylistService {
|
||||
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));
|
||||
return converter.convertToRead(result);
|
||||
}
|
||||
|
||||
public List<PlaylistReadDTO> findPlaylists(long userId) {
|
||||
public List<PlaylistReadResponse> findPlaylists(long userId) {
|
||||
return repository.findAllByOwnerId(userId)
|
||||
.stream()
|
||||
.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,196 @@
|
||||
package com.bivashy.backend.composer.service;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import com.bivashy.backend.composer.auth.CustomUserDetails;
|
||||
import com.bivashy.backend.composer.dto.importing.SingleTrackProgress;
|
||||
import com.bivashy.backend.composer.dto.track.PlaylistTrackResponse;
|
||||
import com.bivashy.backend.composer.dto.track.TrackBulkReorderRequest;
|
||||
import com.bivashy.backend.composer.dto.track.TrackResponse;
|
||||
import com.bivashy.backend.composer.dto.track.YoutubeTrackRequest;
|
||||
import com.bivashy.backend.composer.dto.track.service.AddLocalTrackParams;
|
||||
import com.bivashy.backend.composer.exception.ImportTrackException;
|
||||
import com.bivashy.backend.composer.model.SourceType;
|
||||
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.repository.TrackRepository;
|
||||
import com.bivashy.backend.composer.service.MetadataParseService.Metadata;
|
||||
import com.bivashy.backend.composer.service.importing.RedisProgressService;
|
||||
import com.jfposton.ytdlp.YtDlp;
|
||||
import com.jfposton.ytdlp.YtDlpException;
|
||||
import com.jfposton.ytdlp.mapper.VideoInfo;
|
||||
|
||||
@Service
|
||||
public class TrackService {
|
||||
private static final Logger logger = LoggerFactory.getLogger(TrackService.class);
|
||||
public static final String DOWNLOADED_METADATA_FILE = "downloaded";
|
||||
|
||||
private final TrackRepository trackRepository;
|
||||
private final TrackSourceService trackSourceService;
|
||||
private final TrackMetadataService trackMetadataService;
|
||||
private final TrackPlaylistService trackPlaylistService;
|
||||
private final MetadataParseService metadataParseService;
|
||||
private final RedisProgressService redisProgressService;
|
||||
private final YoutubeTrackService youtubeTrackService;
|
||||
|
||||
public TrackService(TrackRepository trackRepository, TrackSourceService trackSourceService,
|
||||
TrackMetadataService trackMetadataService, TrackPlaylistService trackPlaylistService,
|
||||
MetadataParseService metadataParseService, RedisProgressService redisProgressService,
|
||||
YoutubeTrackService youtubeTrackService) {
|
||||
this.trackRepository = trackRepository;
|
||||
this.trackSourceService = trackSourceService;
|
||||
this.trackMetadataService = trackMetadataService;
|
||||
this.trackPlaylistService = trackPlaylistService;
|
||||
this.metadataParseService = metadataParseService;
|
||||
this.redisProgressService = redisProgressService;
|
||||
this.youtubeTrackService = youtubeTrackService;
|
||||
}
|
||||
|
||||
public TrackResponse addLocalTrack(CustomUserDetails user,
|
||||
long playlistId,
|
||||
AddLocalTrackParams params)
|
||||
throws ImportTrackException {
|
||||
var request = params.blob();
|
||||
Optional<Metadata> metadata = Optional.empty();
|
||||
try (var inputStream = request.inputStream()) {
|
||||
metadata = metadataParseService.extractMetadata(inputStream);
|
||||
} catch (IOException e) {
|
||||
throw new ImportTrackException("cannot extract metadata from " + request.fileName());
|
||||
}
|
||||
String ffprobeJson = metadata.map(Metadata::rawJson).orElse("{}");
|
||||
|
||||
TrackSource trackSource;
|
||||
try {
|
||||
trackSource = trackSourceService.createLocalTrackSource(
|
||||
request.body(), ffprobeJson, params.ytdlpMetadata(), SourceType.FILE);
|
||||
} catch (IOException e) {
|
||||
throw new ImportTrackException("cannot read blob body", e);
|
||||
}
|
||||
|
||||
Track track = trackRepository.save(new Track(trackSource));
|
||||
|
||||
String fileName = fileNameWithoutExtension(request.fileName());
|
||||
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());
|
||||
|
||||
String fileFormat = "unknown";
|
||||
if (metadata.isPresent()) {
|
||||
fileFormat = metadata.map(m -> m.formatName()).get();
|
||||
}
|
||||
|
||||
if (params.includeProgressHistory()) {
|
||||
redisProgressService
|
||||
.saveProgress(
|
||||
new SingleTrackProgress(playlistId, trackSource.getId(), user.getId(), title, fileFormat));
|
||||
}
|
||||
|
||||
return new TrackResponse(
|
||||
track.getId(),
|
||||
title,
|
||||
artist,
|
||||
trackSource.getSourceUrl(),
|
||||
fileFormat,
|
||||
durationSeconds,
|
||||
fileName);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public List<TrackResponse> refreshYoutubePlaylist(CustomUserDetails user, long playlistId, long sourceId)
|
||||
throws ImportTrackException {
|
||||
return youtubeTrackService.refreshYoutubePlaylist(user, playlistId, sourceId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public List<TrackResponse> addYoutubeTrack(CustomUserDetails user, long playlistId,
|
||||
YoutubeTrackRequest request) throws ImportTrackException {
|
||||
List<VideoInfo> videoInfos = Collections.emptyList();
|
||||
try {
|
||||
videoInfos = YtDlp.getVideoInfo(request.youtubeUrl());
|
||||
} catch (YtDlpException e) {
|
||||
throw new ImportTrackException("cannot `yt-dlp --dump-json` from " + request.youtubeUrl(), e);
|
||||
}
|
||||
|
||||
logger.info("videoinfos count {}", videoInfos.size());
|
||||
|
||||
if (videoInfos.size() == 0) {
|
||||
throw new ImportTrackException("cannot find videoInfos");
|
||||
}
|
||||
|
||||
if (videoInfos.size() == 1) {
|
||||
try {
|
||||
VideoInfo videoInfo = videoInfos.get(0);
|
||||
Path temporaryFolder = Files.createTempDirectory("yt-dlp-tmp");
|
||||
|
||||
var params = youtubeTrackService.downloadYoutubeTrack(temporaryFolder, videoInfo,
|
||||
request.youtubeUrl());
|
||||
TrackResponse result = addLocalTrack(user, playlistId, params);
|
||||
|
||||
try (Stream<Path> pathStream = Files.walk(temporaryFolder)) {
|
||||
pathStream.sorted(Comparator.reverseOrder())
|
||||
.map(Path::toFile)
|
||||
.forEach(File::delete);
|
||||
}
|
||||
return List.of(result);
|
||||
} catch (IOException e) {
|
||||
throw new ImportTrackException("i/o during single youtube video downloading", e);
|
||||
} catch (YtDlpException e) {
|
||||
throw new ImportTrackException("cannot download youtube video " + request.youtubeUrl(), e);
|
||||
}
|
||||
}
|
||||
|
||||
TrackSource trackSource = trackSourceService.createYoutubeTrackSource(SourceType.PLAYLIST,
|
||||
request.youtubeUrl());
|
||||
return youtubeTrackService.refreshYoutubePlaylist(user.getId(), playlistId, trackSource, videoInfos,
|
||||
request.youtubeUrl());
|
||||
}
|
||||
|
||||
public List<PlaylistTrackResponse> getPlaylistTracks(CustomUserDetails 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(CustomUserDetails user, Long playlistId, TrackBulkReorderRequest request) {
|
||||
trackPlaylistService.bulkReorder(playlistId, request.trackIds());
|
||||
}
|
||||
|
||||
private String fileNameWithoutExtension(String fileName) {
|
||||
return fileName.replaceFirst("[.][^.]+$", "");
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,68 @@
|
||||
package com.bivashy.backend.composer.service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.bivashy.backend.composer.model.SourceType;
|
||||
import com.bivashy.backend.composer.model.TrackSource;
|
||||
import com.bivashy.backend.composer.model.TrackSourceMetadata;
|
||||
import com.bivashy.backend.composer.repository.TrackSourceMetadataRepository;
|
||||
import com.bivashy.backend.composer.repository.TrackSourceRepository;
|
||||
|
||||
@Service
|
||||
public class TrackSourceService {
|
||||
public static final String FFPROBE_METADATA_KEY = "ffprobe";
|
||||
public static final String YTDLP_METADATA_KEY = "ytdlp";
|
||||
|
||||
private final TrackSourceRepository trackSourceRepository;
|
||||
private final TrackSourceMetadataRepository trackSourceMetadataRepository;
|
||||
private final AudioBlobStorageService s3Service;
|
||||
|
||||
public TrackSourceService(TrackSourceRepository trackSourceRepository,
|
||||
TrackSourceMetadataRepository trackSourceMetadataRepository, AudioBlobStorageService s3Service) {
|
||||
this.trackSourceRepository = trackSourceRepository;
|
||||
this.trackSourceMetadataRepository = trackSourceMetadataRepository;
|
||||
this.s3Service = s3Service;
|
||||
}
|
||||
|
||||
public TrackSource createLocalTrackSource(byte[] audioBytes,
|
||||
String ffprobeJson,
|
||||
String ytdlpMetadata,
|
||||
SourceType sourceType) {
|
||||
Map<String, String> metadata = new HashMap<>(Map.of(YTDLP_METADATA_KEY, ffprobeJson));
|
||||
if (ytdlpMetadata != null) {
|
||||
// TODO: Add tag or smth?
|
||||
}
|
||||
String audioPath = s3Service.store(audioBytes, metadata);
|
||||
|
||||
return trackSourceRepository.save(new TrackSource(audioPath, sourceType, LocalDateTime.now()));
|
||||
}
|
||||
|
||||
public TrackSource createTrackSourceWithKey(String key, byte[] audioBytes, String ffprobeJson,
|
||||
String ytdlpMetadata, SourceType sourceType) {
|
||||
Map<String, String> metadata = new HashMap<>(Map.of(YTDLP_METADATA_KEY, ffprobeJson));
|
||||
if (ytdlpMetadata != null) {
|
||||
// TODO: Add tag or smth?
|
||||
}
|
||||
String audioPath = s3Service.store(key, audioBytes, metadata);
|
||||
|
||||
return trackSourceRepository.save(new TrackSource(audioPath, sourceType, LocalDateTime.now()));
|
||||
}
|
||||
|
||||
public TrackSource createYoutubeTrackSource(SourceType sourceType, String youtubeUrl) {
|
||||
String folderPath = s3Service.storeFolder();
|
||||
TrackSource trackSource = trackSourceRepository
|
||||
.save(new TrackSource(folderPath, sourceType, LocalDateTime.now()));
|
||||
trackSourceMetadataRepository.save(new TrackSourceMetadata(trackSource, youtubeUrl));
|
||||
return trackSource;
|
||||
}
|
||||
|
||||
public Optional<TrackSourceMetadata> findWithMetadata(long sourceId) {
|
||||
return trackSourceMetadataRepository.findBySourceIdWithSource(sourceId);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,252 @@
|
||||
package com.bivashy.backend.composer.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.bivashy.backend.composer.auth.CustomUserDetails;
|
||||
import com.bivashy.backend.composer.dto.importing.PlaylistProgress;
|
||||
import com.bivashy.backend.composer.dto.importing.ProgressStatus;
|
||||
import com.bivashy.backend.composer.dto.track.TrackResponse;
|
||||
import com.bivashy.backend.composer.dto.track.service.AddLocalTrackParams;
|
||||
import com.bivashy.backend.composer.dto.track.service.AddLocalTrackParamsBuilder;
|
||||
import com.bivashy.backend.composer.exception.ImportTrackException;
|
||||
import com.bivashy.backend.composer.model.SourceType;
|
||||
import com.bivashy.backend.composer.model.Track;
|
||||
import com.bivashy.backend.composer.model.TrackSource;
|
||||
import com.bivashy.backend.composer.model.TrackSourceMetadata;
|
||||
import com.bivashy.backend.composer.repository.TrackRepository;
|
||||
import com.bivashy.backend.composer.service.MetadataParseService.Metadata;
|
||||
import com.bivashy.backend.composer.service.importing.RedisProgressService;
|
||||
import com.bivashy.backend.composer.util.SimpleBlob.PathBlob;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.jfposton.ytdlp.YtDlp;
|
||||
import com.jfposton.ytdlp.YtDlpException;
|
||||
import com.jfposton.ytdlp.YtDlpRequest;
|
||||
import com.jfposton.ytdlp.mapper.VideoInfo;
|
||||
|
||||
import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
|
||||
|
||||
@Service
|
||||
public class YoutubeTrackService {
|
||||
private static final Logger logger = LoggerFactory.getLogger(YoutubeTrackService.class);
|
||||
public static final String DOWNLOADED_METADATA_FILE = "downloaded";
|
||||
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
private final AudioS3StorageService s3StorageService;
|
||||
private final MetadataParseService metadataParseService;
|
||||
private final TrackRepository trackRepository;
|
||||
private final TrackMetadataService trackMetadataService;
|
||||
private final TrackPlaylistService trackPlaylistService;
|
||||
private final TrackSourceService trackSourceService;
|
||||
private final RedisProgressService redisProgressService;
|
||||
|
||||
public YoutubeTrackService(AudioS3StorageService s3StorageService, MetadataParseService metadataParseService,
|
||||
TrackRepository trackRepository, TrackMetadataService trackMetadataService,
|
||||
TrackPlaylistService trackPlaylistService, TrackSourceService trackSourceService,
|
||||
RedisProgressService redisProgressService) {
|
||||
this.s3StorageService = s3StorageService;
|
||||
this.metadataParseService = metadataParseService;
|
||||
this.trackRepository = trackRepository;
|
||||
this.trackMetadataService = trackMetadataService;
|
||||
this.trackPlaylistService = trackPlaylistService;
|
||||
this.trackSourceService = trackSourceService;
|
||||
this.redisProgressService = redisProgressService;
|
||||
}
|
||||
|
||||
public AddLocalTrackParams downloadYoutubeTrack(Path temporaryFolder, VideoInfo videoInfo, String youtubeUrl)
|
||||
throws IOException, YtDlpException, ImportTrackException {
|
||||
var ytDlpRequest = new YtDlpRequest(youtubeUrl, temporaryFolder.toAbsolutePath().toString());
|
||||
ytDlpRequest.setOption("output", "%(id)s");
|
||||
var response = YtDlp.execute(ytDlpRequest);
|
||||
// TODO: write to RedisProgressService
|
||||
|
||||
TrackResponse result = null;
|
||||
try (Stream<Path> pathStream = Files.walk(temporaryFolder)) {
|
||||
List<Path> downloadedFiles = Files.walk(temporaryFolder).toList();
|
||||
|
||||
if (downloadedFiles.isEmpty())
|
||||
throw new ImportTrackException("yt-dlp didn't downloaded anything for " + youtubeUrl);
|
||||
|
||||
for (Path downloadedFile : downloadedFiles) {
|
||||
var params = AddLocalTrackParamsBuilder.builder()
|
||||
.blob(new PathBlob(downloadedFile))
|
||||
.ytdlpMetadata(OBJECT_MAPPER.writeValueAsString(videoInfo))
|
||||
.includeProgressHistory(false)
|
||||
.build();
|
||||
|
||||
return params;
|
||||
}
|
||||
}
|
||||
throw new ImportTrackException("cannot download any youtube track");
|
||||
}
|
||||
|
||||
public List<TrackResponse> refreshYoutubePlaylist(CustomUserDetails user, long playlistId, long sourceId)
|
||||
throws ImportTrackException {
|
||||
Optional<TrackSourceMetadata> trackSourceMetadataOpt = trackSourceService.findWithMetadata(sourceId);
|
||||
if (trackSourceMetadataOpt.isEmpty())
|
||||
throw new ImportTrackException("cannot find track source with metadata with id " + sourceId);
|
||||
TrackSourceMetadata trackSourceMetadata = trackSourceMetadataOpt.get();
|
||||
String youtubeUrl = trackSourceMetadata.getUrl();
|
||||
|
||||
List<VideoInfo> videoInfos = Collections.emptyList();
|
||||
try {
|
||||
videoInfos = YtDlp.getVideoInfo(youtubeUrl);
|
||||
} catch (YtDlpException e) {
|
||||
throw new ImportTrackException("cannot `yt-dlp --dump-json` from " + youtubeUrl, e);
|
||||
}
|
||||
return refreshYoutubePlaylist(user.getId(), playlistId, trackSourceMetadata.getSource(), videoInfos,
|
||||
youtubeUrl);
|
||||
}
|
||||
|
||||
public List<TrackResponse> refreshYoutubePlaylist(long userId, long playlistId, TrackSource trackSource,
|
||||
List<VideoInfo> videoInfos,
|
||||
String youtubeUrl) throws ImportTrackException {
|
||||
List<TrackResponse> result = new ArrayList<>();
|
||||
logger.info(trackSource.getSourceUrl());
|
||||
try {
|
||||
Path temporaryFolder = Files.createTempDirectory("yt-dlp-tmp");
|
||||
logger.info("temporaryFolder created {}", temporaryFolder.toString());
|
||||
|
||||
String downloadedMetadataKey = trackSource.getSourceUrl() + DOWNLOADED_METADATA_FILE;
|
||||
try {
|
||||
var rawBody = s3StorageService
|
||||
.readRaw(downloadedMetadataKey);
|
||||
Files.write(temporaryFolder.resolve(DOWNLOADED_METADATA_FILE), rawBody);
|
||||
} catch (NoSuchKeyException e) {
|
||||
logger.warn(".downloaded metadata file was not found, ignoring");
|
||||
}
|
||||
|
||||
var ytDlpRequest = new YtDlpRequest(youtubeUrl, temporaryFolder.toAbsolutePath().toString());
|
||||
ytDlpRequest.setOption("output", "%(id)s");
|
||||
ytDlpRequest.setOption("download-archive", DOWNLOADED_METADATA_FILE);
|
||||
ytDlpRequest.setOption("extract-audio");
|
||||
ytDlpRequest.setOption("audio-quality", 0);
|
||||
ytDlpRequest.setOption("audio-format", "best");
|
||||
ytDlpRequest.setOption("no-overwrites");
|
||||
|
||||
String playlistTitle = videoInfos.stream()
|
||||
.map(VideoInfo::getExtraProperties)
|
||||
.filter(Objects::nonNull)
|
||||
.map(v -> String.valueOf(v.getOrDefault("playlist_title", ""))).findFirst()
|
||||
.orElse("");
|
||||
|
||||
PlaylistProgress playlistProgress = new PlaylistProgress(playlistId, trackSource.getId(), userId,
|
||||
videoInfos.size(), playlistTitle);
|
||||
redisProgressService.saveProgress(playlistProgress);
|
||||
|
||||
var response = YtDlp.execute(ytDlpRequest, (downloadProgress, ignored) -> {
|
||||
redisProgressService.<PlaylistProgress>updateTrackProgressField(playlistId, trackSource.getId(), userId,
|
||||
progress -> {
|
||||
progress.setOverallProgress((int) downloadProgress);
|
||||
});
|
||||
|
||||
}, stdoutLine -> {
|
||||
redisProgressService.<PlaylistProgress>updateTrackProgressField(playlistId, trackSource.getId(), userId,
|
||||
progress -> {
|
||||
String previousStdout = progress.getYtdlnStdout() == null ? "" : progress.getYtdlnStdout();
|
||||
progress.setYtdlnStdout(previousStdout + stdoutLine);
|
||||
});
|
||||
}, null);
|
||||
redisProgressService.<PlaylistProgress>updateTrackProgressField(playlistId, trackSource.getId(), userId,
|
||||
progress -> {
|
||||
progress.setOverallProgress(100);
|
||||
progress.setStatus(ProgressStatus.FINISHED);
|
||||
});
|
||||
logger.info("yt dlp response {}", response);
|
||||
|
||||
try (Stream<Path> pathStream = Files.walk(temporaryFolder)) {
|
||||
List<Path> downloadedFiles = Files.walk(temporaryFolder).toList();
|
||||
logger.info("downloaded file count {}", downloadedFiles.size() - 2);
|
||||
|
||||
for (Path path : downloadedFiles) {
|
||||
if (Files.isDirectory(path))
|
||||
continue;
|
||||
boolean isMetadataFile = path.getFileName().toString().equals(DOWNLOADED_METADATA_FILE);
|
||||
var body = Files.readAllBytes(path);
|
||||
|
||||
if (isMetadataFile) {
|
||||
s3StorageService.store(downloadedMetadataKey, body, Map.of());
|
||||
continue;
|
||||
}
|
||||
String fileName = fileNameWithoutExtension(path.getFileName().toString());
|
||||
VideoInfo videoInfo = videoInfos.stream()
|
||||
.filter(v -> v.getId().equals(fileName))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
|
||||
String audioKey = trackSource.getSourceUrl() + UUID.randomUUID().toString();
|
||||
|
||||
logger.info("downloaded file {} and info {}, key {}", fileName, videoInfo.getTitle(), audioKey);
|
||||
|
||||
Optional<Metadata> metadata = Optional.empty();
|
||||
|
||||
try (var inputStream = Files.newInputStream(path)) {
|
||||
metadata = metadataParseService.extractMetadata(inputStream);
|
||||
} catch (IOException e) {
|
||||
throw new ImportTrackException("cannot extract metadata from " + path.toString());
|
||||
}
|
||||
String ffprobeJson = metadata.map(Metadata::rawJson).orElse("{}");
|
||||
|
||||
TrackSource playlistEntrySource;
|
||||
try {
|
||||
playlistEntrySource = trackSourceService.createTrackSourceWithKey(audioKey, body, ffprobeJson,
|
||||
OBJECT_MAPPER.writeValueAsString(videoInfo), SourceType.PLAYLIST_ITEM);
|
||||
} catch (IOException e) {
|
||||
throw new ImportTrackException("cannot read blob body", e);
|
||||
}
|
||||
|
||||
Track track = trackRepository.save(new Track(playlistEntrySource));
|
||||
|
||||
String title = videoInfo.getTitle();
|
||||
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, audioKey, artist, null, durationSeconds);
|
||||
|
||||
trackPlaylistService.insertTrackAtEnd(playlistId, track.getId());
|
||||
|
||||
String fileFormat = "unknown";
|
||||
if (metadata.isPresent()) {
|
||||
fileFormat = metadata.map(m -> m.formatName()).get();
|
||||
}
|
||||
|
||||
var trackResponse = new TrackResponse(
|
||||
track.getId(),
|
||||
title,
|
||||
artist,
|
||||
audioKey,
|
||||
fileFormat,
|
||||
durationSeconds,
|
||||
fileName);
|
||||
result.add(trackResponse);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} catch (IOException e) {
|
||||
throw new ImportTrackException("i/o during playlist youtube video downloading", e);
|
||||
} catch (YtDlpException e) {
|
||||
throw new ImportTrackException("cannot download youtube video " + youtubeUrl, e);
|
||||
}
|
||||
}
|
||||
|
||||
private String fileNameWithoutExtension(String fileName) {
|
||||
return fileName.replaceFirst("[.][^.]+$", "");
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,62 @@
|
||||
package com.bivashy.backend.composer.service.importing;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
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.BaseTrackProgress;
|
||||
import com.bivashy.backend.composer.dto.importing.ImportTrackKey;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
@Component
|
||||
public class RedisMessageSubscriber {
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
private static final Logger logger = LoggerFactory.getLogger(Logger.class);
|
||||
|
||||
private final RedisMessageListenerContainer container;
|
||||
private final Map<String, Consumer<BaseTrackProgress>> subscriptions = new ConcurrentHashMap<>();
|
||||
|
||||
public RedisMessageSubscriber(RedisMessageListenerContainer container) {
|
||||
this.container = container;
|
||||
}
|
||||
|
||||
public void subscribeToPlaylist(long playlistId, long userId, Consumer<BaseTrackProgress> 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)) {
|
||||
try {
|
||||
BaseTrackProgress progress = OBJECT_MAPPER.readValue(receivedMessage,
|
||||
BaseTrackProgress.class);
|
||||
messageHandler.accept(progress);
|
||||
} catch (JsonProcessingException e) {
|
||||
logger.error("cannot deserialize message into BaseTrackProgress.class", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 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,146 @@
|
||||
package com.bivashy.backend.composer.service.importing;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.bivashy.backend.composer.dto.importing.BaseTrackProgress;
|
||||
import com.bivashy.backend.composer.dto.importing.ImportTrackKey;
|
||||
import com.fasterxml.jackson.databind.JavaType;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
@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(BaseTrackProgress progress) {
|
||||
try {
|
||||
String key = ImportTrackKey.progressKey(progress.getPlaylistId(), progress.getUserId());
|
||||
String trackKey = ImportTrackKey.trackKey(
|
||||
progress.getPlaylistId(),
|
||||
progress.getTrackSourceId(),
|
||||
progress.getUserId());
|
||||
|
||||
String progressJson = objectMapper.writeValueAsString(progress);
|
||||
redisTemplate.opsForHash().put(key, Long.toString(progress.getTrackSourceId()), 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 <T extends BaseTrackProgress> void updateTrackProgressField(long playlistId, long trackSourceId, long userId,
|
||||
Consumer<T> updater) {
|
||||
try {
|
||||
String trackKey = ImportTrackKey.trackKey(playlistId, trackSourceId, userId);
|
||||
String hashKey = ImportTrackKey.progressKey(playlistId, userId);
|
||||
|
||||
String existingJson = redisTemplate.opsForValue().get(trackKey);
|
||||
if (existingJson == null) {
|
||||
throw new RuntimeException("Track progress not found");
|
||||
}
|
||||
|
||||
JavaType progressType = objectMapper.getTypeFactory()
|
||||
.constructType(BaseTrackProgress.class);
|
||||
|
||||
T progress = objectMapper.readValue(existingJson, progressType);
|
||||
|
||||
updater.accept(progress);
|
||||
|
||||
String updatedJson = objectMapper.writeValueAsString(progress);
|
||||
redisTemplate.opsForHash().put(hashKey, Long.toString(trackSourceId), updatedJson);
|
||||
redisTemplate.opsForValue().set(trackKey, updatedJson);
|
||||
|
||||
redisTemplate.expire(hashKey, 24, TimeUnit.HOURS);
|
||||
redisTemplate.expire(trackKey, 24, TimeUnit.HOURS);
|
||||
|
||||
publishProgressUpdate(progress);
|
||||
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to update track progress", e);
|
||||
}
|
||||
}
|
||||
|
||||
public List<BaseTrackProgress> getPlaylistProgress(long playlistId, long userId) {
|
||||
try {
|
||||
String key = ImportTrackKey.progressKey(playlistId, userId);
|
||||
Map<Object, Object> progressMap = redisTemplate.opsForHash().entries(key);
|
||||
|
||||
List<BaseTrackProgress> progressList = new ArrayList<>();
|
||||
for (Object value : progressMap.values()) {
|
||||
BaseTrackProgress progress = objectMapper.readValue(
|
||||
(String) value,
|
||||
BaseTrackProgress.class);
|
||||
progressList.add(progress);
|
||||
}
|
||||
|
||||
progressList.sort(Comparator.comparingLong(t -> Optional.ofNullable(t.getTimestamp()).orElse(0L)));
|
||||
|
||||
return progressList;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to get progress from Redis", e);
|
||||
}
|
||||
}
|
||||
|
||||
public BaseTrackProgress getTrackProgress(long playlistId, long trackSourceId, long userId) {
|
||||
try {
|
||||
String key = ImportTrackKey.trackKey(playlistId, trackSourceId, userId);
|
||||
String progressJson = redisTemplate.opsForValue().get(key);
|
||||
|
||||
if (progressJson != null) {
|
||||
return objectMapper.readValue(progressJson, BaseTrackProgress.class);
|
||||
}
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to get track progress", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void publishProgressUpdate(BaseTrackProgress 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);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,64 @@
|
||||
package com.bivashy.backend.composer.util;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
public interface SimpleBlob {
|
||||
InputStream inputStream() throws IOException;
|
||||
|
||||
byte[] body() throws IOException;
|
||||
|
||||
String fileName();
|
||||
|
||||
public static class MultipartBlob implements SimpleBlob {
|
||||
private final MultipartFile multipartFile;
|
||||
|
||||
public MultipartBlob(MultipartFile multipartFile) {
|
||||
this.multipartFile = multipartFile;
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream inputStream() throws IOException {
|
||||
return multipartFile.getInputStream();
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] body() throws IOException {
|
||||
return multipartFile.getBytes();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String fileName() {
|
||||
return multipartFile.getOriginalFilename();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public static class PathBlob implements SimpleBlob {
|
||||
private final Path path;
|
||||
|
||||
public PathBlob(Path path) {
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream inputStream() throws IOException {
|
||||
return Files.newInputStream(path);
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] body() throws IOException {
|
||||
return Files.readAllBytes(path);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String fileName() {
|
||||
return path.getFileName().toString();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@ -11,6 +11,12 @@ spring:
|
||||
access-key: ${S3_ACCESS_KEY}
|
||||
secret-key: ${S3_SECRET_KEY}
|
||||
bucket: ${S3_BUCKET}
|
||||
redis:
|
||||
host-name: ${REDIS_HOST_NAME}
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 8096MB
|
||||
max-request-size: 8096MB
|
||||
|
||||
logging:
|
||||
level:
|
||||
|
||||
@ -1,85 +1,69 @@
|
||||
CREATE TABLE IF NOT EXISTS "users" (
|
||||
"id" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
"name" varchar(500) NOT NULL,
|
||||
"created_at" timestamp NOT NULL DEFAULT NOW(),
|
||||
"updated_at" timestamp NOT NULL DEFAULT NOW()
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ 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 TYPE source_type_enum AS ENUM (
|
||||
'VIDEO', 'PLAYLIST', 'PLAYLIST_ITEM', 'FILE', 'URL'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "source_type" (
|
||||
"id" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
"provider_id" bigint 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 (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
source_url TEXT NOT NULL,
|
||||
source_type SOURCE_TYPE_ENUM NOT NULL,
|
||||
last_fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "track_source" (
|
||||
"id" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
"source_url" varchar(500) NOT NULL,
|
||||
"source_type_id" bigint NOT NULL,
|
||||
"last_fetched_at" timestamp NOT NULL DEFAULT NOW(),
|
||||
"created_at" timestamp NOT NULL DEFAULT NOW(),
|
||||
"updated_at" timestamp NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT "fk_track_source_source_type_id"
|
||||
FOREIGN KEY ("source_type_id") REFERENCES "source_type" ("id")
|
||||
CREATE TABLE IF NOT EXISTS track_source_metadata (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
source_id BIGINT NOT NULL UNIQUE REFERENCES track_source (
|
||||
id
|
||||
) ON DELETE CASCADE,
|
||||
url TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "track" (
|
||||
"id" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
"source_id" bigint NOT NULL,
|
||||
CONSTRAINT "fk_track_source_id"
|
||||
FOREIGN KEY ("source_id") REFERENCES "track_source" ("id")
|
||||
CREATE TABLE IF NOT EXISTS track (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
source_id BIGINT NOT NULL REFERENCES track_source (id) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "track_metadata" (
|
||||
"id" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
"track_id" bigint NOT NULL,
|
||||
"title" varchar(500) NOT NULL,
|
||||
"audio_path" varchar(500) NOT NULL,
|
||||
"artist" varchar(500),
|
||||
"thumbnail_path" varchar(500),
|
||||
"duration_seconds" integer,
|
||||
CONSTRAINT "fk_track_metadata_track_id"
|
||||
FOREIGN KEY ("track_id") REFERENCES "track" ("id")
|
||||
CREATE TABLE IF NOT EXISTS track_metadata (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
track_id BIGINT NOT NULL REFERENCES track (id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
file_name TEXT NOT NULL,
|
||||
audio_path TEXT NOT NULL,
|
||||
artist TEXT,
|
||||
thumbnail_path TEXT,
|
||||
duration_seconds INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "playlist" (
|
||||
"id" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
"owner_id" bigint NOT NULL,
|
||||
"title" varchar(500) NOT NULL UNIQUE,
|
||||
"created_at" timestamp NOT NULL DEFAULT NOW(),
|
||||
"updated_at" timestamp NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT "fk_playlist_owner_id"
|
||||
FOREIGN KEY ("owner_id") REFERENCES "users" ("id")
|
||||
CREATE TABLE IF NOT EXISTS playlist (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
owner_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (owner_id, title)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "playlist_track" (
|
||||
"playlist_id" bigint NOT NULL,
|
||||
"track_id" bigint NOT NULL,
|
||||
"order" bigint NOT NULL,
|
||||
CONSTRAINT "pk_playlist_track" PRIMARY KEY ("playlist_id", "track_id"),
|
||||
CONSTRAINT "fk_playlist_track_playlist_id"
|
||||
FOREIGN KEY ("playlist_id") REFERENCES "playlist" ("id"),
|
||||
CONSTRAINT "fk_playlist_track_track_id"
|
||||
FOREIGN KEY ("track_id") REFERENCES "track" ("id")
|
||||
CREATE TABLE IF NOT EXISTS playlist_track (
|
||||
playlist_id BIGINT NOT NULL REFERENCES playlist (id) ON DELETE CASCADE,
|
||||
track_id BIGINT NOT NULL REFERENCES track (id) ON DELETE CASCADE,
|
||||
order_index DECIMAL NOT NULL,
|
||||
PRIMARY KEY (playlist_id, track_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "track_version" (
|
||||
"id" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
"track_id" bigint NOT NULL,
|
||||
"metadata_id" bigint NOT NULL,
|
||||
"source_id" bigint NOT NULL,
|
||||
"created_at" timestamp NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT "fk_track_version_track_id"
|
||||
FOREIGN KEY ("track_id") REFERENCES "track" ("id"),
|
||||
CONSTRAINT "fk_track_version_metadata_id"
|
||||
FOREIGN KEY ("metadata_id") REFERENCES "track_metadata" ("id"),
|
||||
CONSTRAINT "fk_track_version_source_id"
|
||||
FOREIGN KEY ("source_id") REFERENCES "track_source" ("id")
|
||||
CREATE TABLE IF NOT EXISTS track_version (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
track_id BIGINT NOT NULL REFERENCES track (id) ON DELETE CASCADE,
|
||||
metadata_id BIGINT NOT NULL REFERENCES track_metadata (
|
||||
id
|
||||
) ON DELETE CASCADE,
|
||||
source_id BIGINT NOT NULL REFERENCES track_source (id) ON DELETE RESTRICT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
|
||||
@ -1,17 +0,0 @@
|
||||
INSERT INTO "source_provider" ("id", "name")
|
||||
OVERRIDING SYSTEM VALUE
|
||||
VALUES
|
||||
(1, 'YOUTUBE'),
|
||||
(2, 'LOCAL'),
|
||||
(3, 'EXTERNAL')
|
||||
ON CONFLICT ("id") DO NOTHING;
|
||||
|
||||
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;
|
||||
|
||||
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