Compare commits

..

10 Commits

47 changed files with 1302 additions and 127 deletions

36
.dockerignore Normal file
View 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

View File

@ -6,3 +6,5 @@ S3_ENDPOINT=http://s3:9000
S3_ACCESS_KEY=minioadmin S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin S3_SECRET_KEY=minioadmin
S3_BUCKET=composer-dev S3_BUCKET=composer-dev
REDIS_HOST_NAME=redis

35
Dockerfile.dev Normal file
View File

@ -0,0 +1,35 @@
FROM eclipse-temurin:21-jdk-jammy
RUN apt-get update && \
apt-get install -y --no-install-recommends \
curl \
vim \
git \
ca-certificates \
ffmpeg && \
rm -rf /var/lib/apt/lists/*
# Create non-root user
RUN groupadd --gid 1000 spring-app && \
useradd --uid 1000 --gid spring-app --shell /bin/bash --create-home spring-app
RUN mkdir -p /home/spring-app/.m2 && \
chown -R spring-app:spring-app /home/spring-app/.m2
WORKDIR /app
COPY mvnw .
COPY .mvn .mvn
COPY pom.xml .
COPY target target
RUN chmod +x mvnw && \
chown -R spring-app:spring-app /app
USER spring-app:spring-app
COPY src ./src
EXPOSE 8080
ENTRYPOINT ["./mvnw", "spring-boot:run"]

View File

@ -1,4 +1,17 @@
services: services:
hls-proxy:
build:
context: .
dockerfile: Dockerfile.dev
env_file: .env
ports:
- 8080:8080
networks:
- mp3_composer
volumes:
- .:/app
- maven-repo:/home/spring-app/.m2/
s3: s3:
image: minio/minio:latest image: minio/minio:latest
container_name: composer_s3 container_name: composer_s3
@ -6,7 +19,7 @@ services:
networks: networks:
- mp3_composer - mp3_composer
ports: ports:
- 9000:9000 - 0:9001
postgres: postgres:
image: postgres:alpine image: postgres:alpine
container_name: composer_postgres container_name: composer_postgres
@ -39,6 +52,9 @@ services:
networks: networks:
- mp3_composer - mp3_composer
volumes:
maven-repo:
networks: networks:
mp3_composer: mp3_composer:
driver: bridge external: true

33
pom.xml
View File

@ -33,7 +33,15 @@
<modelmapper.version>3.2.4</modelmapper.version> <modelmapper.version>3.2.4</modelmapper.version>
<spring-dotenv.version>4.0.0</spring-dotenv.version> <spring-dotenv.version>4.0.0</spring-dotenv.version>
<apache-tika.version>3.2.3</apache-tika.version> <apache-tika.version>3.2.3</apache-tika.version>
<springdoc-openapi.version>2.8.5</springdoc-openapi.version>
<jaffree.version>2024.08.29</jaffree.version>
</properties> </properties>
<repositories>
<repository>
<id>jitpack.io</id>
<url>https://jitpack.io</url>
</repository>
</repositories>
<dependencyManagement> <dependencyManagement>
<dependencies> <dependencies>
<dependency> <dependency>
@ -82,6 +90,14 @@
<groupId>software.amazon.awssdk</groupId> <groupId>software.amazon.awssdk</groupId>
<artifactId>s3</artifactId> <artifactId>s3</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency> <dependency>
<groupId>me.paulschwarz</groupId> <groupId>me.paulschwarz</groupId>
@ -98,12 +114,29 @@
<artifactId>tika-parsers-standard-package</artifactId> <artifactId>tika-parsers-standard-package</artifactId>
<version>${apache-tika.version}</version> <version>${apache-tika.version}</version>
</dependency> </dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc-openapi.version}</version>
</dependency>
<dependency>
<groupId>com.github.kokorin.jaffree</groupId>
<artifactId>jaffree</artifactId>
<version>${jaffree.version}</version>
</dependency>
<dependency> <dependency>
<groupId>org.postgresql</groupId> <groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId> <artifactId>postgresql</artifactId>
<scope>runtime</scope> <scope>runtime</scope>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<optional>true</optional>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId> <artifactId>spring-boot-starter-test</artifactId>

View File

@ -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;
}
}

View File

@ -25,19 +25,24 @@ public class SecurityConfig {
http.authorizeHttpRequests(auth -> auth http.authorizeHttpRequests(auth -> auth
.anyRequest().authenticated()) .anyRequest().authenticated())
.httpBasic(Customizer.withDefaults()) .httpBasic(Customizer.withDefaults())
// TODO: Temporary to test API, remove after testing
.csrf(c -> c.disable()); .csrf(c -> c.disable());
return http.build(); return http.build();
} }
@Bean @Bean
public CustomUserDetailsService customUserDetailsService(UserRepository repository) { public CustomUserDetailsService customUserDetailsService(UserRepository repository) {
CustomUserDetails defaultUser = new CustomUserDetails(1, "user", passwordEncoder().encode("password")); CustomUserDetails defaultUser1 = create(repository, 1, "user", passwordEncoder().encode("password"));
Optional<User> user = repository.findById(defaultUser.getId()); CustomUserDetails defaultUser2 = create(repository, 2, "user1", passwordEncoder().encode("password"));
return new CustomUserDetailsService(defaultUser1, defaultUser2);
}
private CustomUserDetails create(UserRepository repository, long id, String username, String password) {
CustomUserDetails userDetails = new CustomUserDetails(id, username, password);
Optional<User> user = repository.findById(userDetails.getId());
if (user.isEmpty()) { if (user.isEmpty()) {
repository.save(new User(defaultUser.getUsername())); repository.save(new User(userDetails.getUsername()));
} }
return new CustomUserDetailsService(defaultUser); return userDetails;
} }
@Bean @Bean

View File

@ -9,8 +9,8 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import com.bivashy.backend.composer.auth.CustomUserDetails; import com.bivashy.backend.composer.auth.CustomUserDetails;
import com.bivashy.backend.composer.dto.playlist.PlaylistCreateDTO; import com.bivashy.backend.composer.dto.playlist.PlaylistCreateRequest;
import com.bivashy.backend.composer.dto.playlist.PlaylistReadDTO; import com.bivashy.backend.composer.dto.playlist.PlaylistReadResponse;
import com.bivashy.backend.composer.service.PlaylistService; import com.bivashy.backend.composer.service.PlaylistService;
@RestController @RestController
@ -22,13 +22,14 @@ public class PlaylistController {
} }
@GetMapping("/playlists") @GetMapping("/playlists")
public List<PlaylistReadDTO> playlists(@AuthenticationPrincipal CustomUserDetails user) { public List<PlaylistReadResponse> playlists(@AuthenticationPrincipal CustomUserDetails user) {
return service.findPlaylists(user.getId()); return service.findPlaylists(user.getId());
} }
@PostMapping("/playlist") @PostMapping("/playlist")
public PlaylistReadDTO createPlaylist(@AuthenticationPrincipal CustomUserDetails user, public PlaylistReadResponse createPlaylist(@AuthenticationPrincipal CustomUserDetails user,
@RequestBody PlaylistCreateDTO playlist) { @RequestBody PlaylistCreateRequest playlist) {
return service.createPlaylist(user.getId(), playlist); return service.createPlaylist(user.getId(), playlist);
} }
} }

View File

@ -0,0 +1,55 @@
package com.bivashy.backend.composer.controller;
import java.io.IOException;
import java.util.List;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import com.bivashy.backend.composer.dto.track.AddLocalTrackRequest;
import com.bivashy.backend.composer.dto.track.PlaylistTrackResponse;
import com.bivashy.backend.composer.dto.track.TrackBulkReorderRequest;
import com.bivashy.backend.composer.dto.track.TrackReorderAfterRequest;
import com.bivashy.backend.composer.dto.track.TrackResponse;
import com.bivashy.backend.composer.model.User;
import com.bivashy.backend.composer.service.TrackService;
@RestController
public class TrackController {
private final TrackService trackService;
public TrackController(TrackService trackService) {
this.trackService = trackService;
}
@PostMapping(path = "/playlist/{playlistId}/track/local", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<TrackResponse> addLocalTrack(
@AuthenticationPrincipal User user,
@PathVariable Long playlistId,
@ModelAttribute AddLocalTrackRequest request) throws IOException {
TrackResponse response = trackService.addLocalTrack(user, playlistId, request);
return ResponseEntity.ok(response);
}
@GetMapping("/playlist/{playlistId}/tracks")
public ResponseEntity<List<PlaylistTrackResponse>> getPlaylistTracks(
@AuthenticationPrincipal User user,
@PathVariable Long playlistId) {
List<PlaylistTrackResponse> tracks = trackService.getPlaylistTracks(user, playlistId);
return ResponseEntity.ok(tracks);
}
@PostMapping("/playlist/{playlistId}/bulk-reorder")
public void bulkReorder(@AuthenticationPrincipal User user,
@RequestBody TrackBulkReorderRequest request,
@PathVariable Long playlistId) {
trackService.bulkReorder(user, playlistId, request);
}
}

View File

@ -0,0 +1,113 @@
package com.bivashy.backend.composer.controller.importing;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import com.bivashy.backend.composer.auth.CustomUserDetails;
import com.bivashy.backend.composer.dto.importing.ImportTrackKey;
import com.bivashy.backend.composer.dto.importing.TrackProgressDTO;
import com.bivashy.backend.composer.service.importing.RedisMessageSubscriber;
import com.bivashy.backend.composer.service.importing.RedisProgressService;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletResponse;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Sinks;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@RestController
public class ProgressSSEController {
private final RedisProgressService redisProgressService;
private final RedisMessageSubscriber redisSubscriber;
private final Map<String, Sinks.Many<String>> sinks = new ConcurrentHashMap<>();
public ProgressSSEController(RedisProgressService redisProgressService,
RedisMessageSubscriber redisSubscriber) {
this.redisProgressService = redisProgressService;
this.redisSubscriber = redisSubscriber;
}
@GetMapping("/importing/test/{playlistId}")
public void test(@PathVariable long playlistId, @AuthenticationPrincipal CustomUserDetails user) {
var userId = user.getId();
redisProgressService.saveProgress(new TrackProgressDTO(
playlistId,
"test",
userId));
}
@GetMapping(value = "/importing/stream/{playlistId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> streamProgress(
@PathVariable long playlistId,
@AuthenticationPrincipal CustomUserDetails user,
HttpServletResponse response) {
var userId = user.getId();
response.setHeader("Cache-Control", "no-cache");
response.setHeader("Connection", "keep-alive");
response.setCharacterEncoding("UTF-8");
String connectionKey = ImportTrackKey.subscriptionKey(playlistId, userId);
Sinks.Many<String> sink = sinks.computeIfAbsent(connectionKey, k -> {
Sinks.Many<String> newSink = Sinks.many().replay().latest();
redisSubscriber.subscribeToPlaylist(playlistId, userId, message -> {
newSink.tryEmitNext(message);
});
return newSink;
});
redisProgressService.addActiveConnection(playlistId, userId);
return sink.asFlux()
.map(data -> ServerSentEvent.<String>builder()
.data(data)
.event("progress-update")
.build())
.doFirst(() -> {
try {
List<TrackProgressDTO> existingProgresses = redisProgressService.getPlaylistProgress(playlistId,
userId);
System.out.println(existingProgresses);
ObjectMapper mapper = new ObjectMapper();
for (TrackProgressDTO progress : existingProgresses) {
sink.tryEmitNext(mapper.writeValueAsString(progress));
}
} catch (Exception e) {
e.printStackTrace();
}
})
.doOnCancel(() -> {
cleanupConnection(playlistId, userId, sink, connectionKey);
})
.doOnTerminate(() -> {
cleanupConnection(playlistId, userId, sink, connectionKey);
})
.timeout(Duration.ofHours(2))
.onErrorResume(e -> {
cleanupConnection(playlistId, userId, sink, connectionKey);
return Flux.empty();
});
}
private void cleanupConnection(Long playlistId, long userId,
Sinks.Many<String> sink, String connectionKey) {
try {
redisProgressService.removeActiveConnection(playlistId, userId);
redisSubscriber.unsubscribeFromPlaylist(playlistId, userId);
sinks.remove(connectionKey);
sink.tryEmitComplete();
} catch (Exception e) {
e.printStackTrace();
}
}
}

View File

@ -2,20 +2,20 @@ package com.bivashy.backend.composer.converter;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import com.bivashy.backend.composer.dto.playlist.PlaylistCreateDTO; import com.bivashy.backend.composer.dto.playlist.PlaylistCreateRequest;
import com.bivashy.backend.composer.dto.playlist.PlaylistReadDTO; import com.bivashy.backend.composer.dto.playlist.PlaylistReadResponse;
import com.bivashy.backend.composer.model.Playlist; import com.bivashy.backend.composer.model.Playlist;
import com.bivashy.backend.composer.model.User; import com.bivashy.backend.composer.model.User;
@Component @Component
public class PlaylistConverter { public class PlaylistConverter {
public PlaylistReadDTO convertToRead(Playlist playlist) { public PlaylistReadResponse convertToRead(Playlist playlist) {
return new PlaylistReadDTO(playlist.getId(), playlist.getOwner().getId(), playlist.getTitle(), return new PlaylistReadResponse(playlist.getId(), playlist.getOwner().getId(), playlist.getTitle(),
playlist.getCreatedAt(), playlist.getCreatedAt(),
playlist.getUpdatedAt()); playlist.getUpdatedAt());
} }
public Playlist convertFromCreate(long userId, PlaylistCreateDTO playlist) { public Playlist convertFromCreate(long userId, PlaylistCreateRequest playlist) {
return new Playlist(new User(userId), playlist.title()); return new Playlist(new User(userId), playlist.title());
} }

View File

@ -0,0 +1,27 @@
package com.bivashy.backend.composer.dto;
public enum SourceType {
AUDIO("AUDIO"),
PLAYLIST("PLAYLIST"),
FILE("FILE"),
URL("URL");
private final String value;
SourceType(String value) {
this.value = value;
}
public String getValue() {
return value;
}
public static SourceType fromValue(String value) {
for (SourceType type : values()) {
if (type.value.equalsIgnoreCase(value)) {
return type;
}
}
throw new IllegalArgumentException("Unknown source type: " + value);
}
}

View File

@ -0,0 +1,19 @@
package com.bivashy.backend.composer.dto.importing;
public class ImportTrackKey {
public static String progressKey(long playlistId, long userId) {
return String.format("progress:%d:%d", userId, playlistId);
}
public static String trackKey(long playlistId, String trackId, long userId) {
return String.format("track:%d:%d:%s", userId, playlistId, trackId);
}
public static String redisChannelKey(long playlistId, long userId) {
return String.format("progress_updates:%d:%d", userId, playlistId);
}
public static String subscriptionKey(long playlistId, long userId) {
return String.format("%d:%d", playlistId, userId);
}
}

View File

@ -0,0 +1,116 @@
package com.bivashy.backend.composer.dto.importing;
public class TrackProgressDTO {
private long playlistId;
private String trackId;
private String trackTitle;
private String format;
private String sourceType;
private int progress;
private String metadata;
private Long timestamp;
private long userId;
public TrackProgressDTO() {
}
public TrackProgressDTO(long playlistId, String trackId, long userId) {
this.playlistId = playlistId;
this.trackId = trackId;
this.userId = userId;
this.timestamp = System.currentTimeMillis();
}
public TrackProgressDTO(long playlistId,
String trackId,
String trackTitle,
String format,
String sourceType,
int progress,
String metadata,
Long timestamp,
long userId) {
this.playlistId = playlistId;
this.trackId = trackId;
this.trackTitle = trackTitle;
this.format = format;
this.sourceType = sourceType;
this.progress = progress;
this.metadata = metadata;
this.timestamp = timestamp;
this.userId = userId;
}
public long getPlaylistId() {
return playlistId;
}
public void setPlaylistId(long playlistId) {
this.playlistId = playlistId;
}
public String getTrackId() {
return trackId;
}
public void setTrackId(String trackId) {
this.trackId = trackId;
}
public String getTrackTitle() {
return trackTitle;
}
public void setTrackTitle(String trackTitle) {
this.trackTitle = trackTitle;
}
public String getFormat() {
return format;
}
public void setFormat(String format) {
this.format = format;
}
public String getSourceType() {
return sourceType;
}
public void setSourceType(String sourceType) {
this.sourceType = sourceType;
}
public int getProgress() {
return progress;
}
public void setProgress(int progress) {
this.progress = progress;
}
public String getMetadata() {
return metadata;
}
public void setMetadata(String metadata) {
this.metadata = metadata;
}
public Long getTimestamp() {
return timestamp;
}
public void setTimestamp(Long timestamp) {
this.timestamp = timestamp;
}
public long getUserId() {
return userId;
}
public void setUserId(long userId) {
this.userId = userId;
}
}

View File

@ -1,4 +1,4 @@
package com.bivashy.backend.composer.dto.playlist; package com.bivashy.backend.composer.dto.playlist;
public record PlaylistCreateDTO(String title) { public record PlaylistCreateRequest(String title) {
} }

View File

@ -2,13 +2,13 @@ package com.bivashy.backend.composer.dto.playlist;
import java.time.LocalDateTime; import java.time.LocalDateTime;
public record PlaylistReadDTO( public record PlaylistReadResponse(
long id, long id,
long ownerId, long ownerId,
String title, String title,
LocalDateTime createdAt, LocalDateTime createdAt,
LocalDateTime updatedAt) { LocalDateTime updatedAt) {
public PlaylistReadDTO withUserId(long userId) { public PlaylistReadResponse withUserId(long userId) {
return new PlaylistReadDTO(this.id, userId, this.title, this.createdAt, this.updatedAt); return new PlaylistReadResponse(this.id, userId, this.title, this.createdAt, this.updatedAt);
} }
} }

View File

@ -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) {
}

View File

@ -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) {
}

View File

@ -0,0 +1,6 @@
package com.bivashy.backend.composer.dto.track;
import java.util.List;
public record TrackBulkReorderRequest(List<Long> trackIds) {
}

View File

@ -0,0 +1,4 @@
package com.bivashy.backend.composer.dto.track;
public record TrackReorderAfterRequest(long moveTrackId, long targetTrackId) {
}

View File

@ -0,0 +1,10 @@
package com.bivashy.backend.composer.dto.track;
public record TrackResponse(
Long trackId,
String title,
String artist,
String audioPath,
Integer durationSeconds,
String fileName) {
}

View File

@ -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;
}
}

View File

@ -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";
}

View File

@ -6,12 +6,9 @@ import java.util.Set;
import jakarta.persistence.CascadeType; import jakarta.persistence.CascadeType;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue; import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType; import jakarta.persistence.GenerationType;
import jakarta.persistence.Id; import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany; import jakarta.persistence.OneToMany;
import jakarta.persistence.Table; import jakarta.persistence.Table;
@ -22,10 +19,6 @@ public class SourceType {
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "provider_id", nullable = false)
private SourceProvider provider;
@Column(nullable = false, length = 500) @Column(nullable = false, length = 500)
private String name; private String name;
@ -43,10 +36,6 @@ public class SourceType {
return id; return id;
} }
public SourceProvider getProvider() {
return provider;
}
public String getName() { public String getName() {
return name; return name;
} }

View File

@ -28,6 +28,9 @@ public class TrackMetadata {
@Column(nullable = false, length = 500) @Column(nullable = false, length = 500)
private String title; private String title;
@Column(length = 500)
private String fileName;
@Column(name = "audio_path", nullable = false, length = 500) @Column(name = "audio_path", nullable = false, length = 500)
private String audioPath; private String audioPath;
@ -46,10 +49,12 @@ public class TrackMetadata {
TrackMetadata() { TrackMetadata() {
} }
public TrackMetadata(Track track, String title, String audioPath, String artist, String thumbnailPath, public TrackMetadata(Track track, String title, String fileName, String audioPath, String artist,
String thumbnailPath,
Integer durationSeconds) { Integer durationSeconds) {
this.track = track; this.track = track;
this.title = title; this.title = title;
this.fileName = fileName;
this.audioPath = audioPath; this.audioPath = audioPath;
this.artist = artist; this.artist = artist;
this.thumbnailPath = thumbnailPath; this.thumbnailPath = thumbnailPath;
@ -68,6 +73,10 @@ public class TrackMetadata {
return title; return title;
} }
public String getFileName() {
return fileName;
}
public String getAudioPath() { public String getAudioPath() {
return audioPath; return audioPath;
} }

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,11 @@
package com.bivashy.backend.composer.repository;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import com.bivashy.backend.composer.model.SourceType;
public interface SourceTypeRepository extends JpaRepository<SourceType, Long> {
Optional<SourceType> findByName(String name);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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> {
}

View File

@ -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> {
}

View File

@ -2,6 +2,7 @@ package com.bivashy.backend.composer.service;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.util.Map;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
@ -10,10 +11,14 @@ public interface AudioBlobStorageService {
String store(byte[] data); String store(byte[] data);
String store(InputStream inputStream, Map<String, String> metadata);
String store(byte[] data, Map<String, String> metadata);
byte[] readRaw(String path) throws IOException; byte[] readRaw(String path) throws IOException;
Blob read(String path); Blob read(String path);
public record Blob(InputStream stream, MediaType contentType) { public record Blob(InputStream stream, MediaType contentType, Map<String, String> metadata) {
} }
} }

View File

@ -3,6 +3,7 @@ package com.bivashy.backend.composer.service;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.util.Map;
import java.util.UUID; import java.util.UUID;
import org.apache.tika.Tika; import org.apache.tika.Tika;
@ -37,17 +38,28 @@ public class AudioS3StorageService implements AudioBlobStorageService {
@Override @Override
public String store(InputStream inputStream) { public String store(InputStream inputStream) {
return store(new ByteArrayInputStream(DEFAULT_BUFFER).readAllBytes()); return store(inputStream, Map.of());
} }
@Override @Override
public String store(byte[] data) { public String store(byte[] data) {
return store(data, Map.of());
}
@Override
public String store(InputStream inputStream, Map<String, String> metadata) {
return store(new ByteArrayInputStream(DEFAULT_BUFFER).readAllBytes(), metadata);
}
@Override
public String store(byte[] data, Map<String, String> metadata) {
String objectKey = newObjectName(); String objectKey = newObjectName();
String contentType = detectContentType(data); String contentType = detectContentType(data);
s3Client.putObject(PutObjectRequest.builder() s3Client.putObject(PutObjectRequest.builder()
.bucket(bucket) .bucket(bucket)
.key(objectKey) .key(objectKey)
.contentType(contentType) .contentType(contentType)
.metadata(metadata)
.build(), RequestBody.fromBytes(data)); .build(), RequestBody.fromBytes(data));
return String.join("/", bucket, objectKey); return String.join("/", bucket, objectKey);
} }
@ -73,7 +85,7 @@ public class AudioS3StorageService implements AudioBlobStorageService {
} catch (InvalidMediaTypeException e) { } catch (InvalidMediaTypeException e) {
logger.error("invalid media type", e); logger.error("invalid media type", e);
} }
return new Blob(response, mediaType); return new Blob(response, mediaType, response.response().metadata());
} }
private String detectContentType(byte[] data) { private String detectContentType(byte[] data) {

View File

@ -1,5 +1,8 @@
package com.bivashy.backend.composer.service; package com.bivashy.backend.composer.service;
import java.util.Arrays;
import java.util.List;
import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.core.userdetails.UsernameNotFoundException;
@ -9,15 +12,18 @@ import com.bivashy.backend.composer.auth.CustomUserDetails;
@Service @Service
public class CustomUserDetailsService implements UserDetailsService { public class CustomUserDetailsService implements UserDetailsService {
private final CustomUserDetails defaultUser; private final List<CustomUserDetails> users;
public CustomUserDetailsService(CustomUserDetails defaultUser) { public CustomUserDetailsService(CustomUserDetails... users) {
this.defaultUser = defaultUser; this.users = Arrays.asList(users);
} }
@Override @Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return defaultUser; return users.stream()
.filter(user -> user.getUsername().equals(username))
.findFirst()
.orElseThrow();
} }
} }

View File

@ -0,0 +1,69 @@
package com.bivashy.backend.composer.service;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.stream.Stream;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.github.kokorin.jaffree.ffprobe.FFprobe;
import com.github.kokorin.jaffree.ffprobe.FFprobeResult;
@Service
public class MetadataParseService {
private final ObjectMapper ffprobeObjectMapper;
public MetadataParseService() {
var result = new ObjectMapper();
result.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false);
result.setSerializationInclusion(JsonInclude.Include.NON_NULL);
this.ffprobeObjectMapper = result;
}
public Optional<Metadata> extractMetadata(InputStream input) throws IOException {
Path tempFile = Files.createTempFile("metadata-file", "");
Files.copy(input, tempFile, StandardCopyOption.REPLACE_EXISTING);
FFprobeResult result = FFprobe.atPath()
.setShowFormat(true)
.setShowStreams(true)
.setInput(tempFile)
.execute();
Files.deleteIfExists(tempFile);
var format = Optional.ofNullable(result.getFormat());
Optional<Float> formatDuration = format.map(f -> f.getDuration());
List<Optional<Float>> streamDuration = Optional.ofNullable(result.getStreams())
.map(streams -> streams.stream()
.map(s -> s.getDuration())
.map(Optional::ofNullable).toList())
.orElse(List.of());
var foundDuration = Stream.of(Collections.singletonList(formatDuration), streamDuration)
.flatMap(Collection::stream)
.filter(Optional::isPresent)
.map(Optional::get)
.findFirst();
var jsonResult = ffprobeObjectMapper.writeValueAsString(result);
var title = format.map(f -> f.getTag("title")).orElse(null);
var artist = format.map(f -> f.getTag("artist")).orElse(null);
return Optional.of(new Metadata(title, artist, foundDuration.orElse(0f), jsonResult));
}
public static record Metadata(String title, String artist, Float durationSeconds, String rawJson) {
}
}

View File

@ -5,8 +5,8 @@ import java.util.List;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import com.bivashy.backend.composer.converter.PlaylistConverter; import com.bivashy.backend.composer.converter.PlaylistConverter;
import com.bivashy.backend.composer.dto.playlist.PlaylistCreateDTO; import com.bivashy.backend.composer.dto.playlist.PlaylistCreateRequest;
import com.bivashy.backend.composer.dto.playlist.PlaylistReadDTO; import com.bivashy.backend.composer.dto.playlist.PlaylistReadResponse;
import com.bivashy.backend.composer.model.Playlist; import com.bivashy.backend.composer.model.Playlist;
import com.bivashy.backend.composer.repository.PlaylistRepository; import com.bivashy.backend.composer.repository.PlaylistRepository;
@ -20,12 +20,12 @@ public class PlaylistService {
this.converter = converter; this.converter = converter;
} }
public PlaylistReadDTO createPlaylist(long userId, PlaylistCreateDTO playlist) { public PlaylistReadResponse createPlaylist(long userId, PlaylistCreateRequest playlist) {
Playlist result = repository.save(converter.convertFromCreate(userId, playlist)); Playlist result = repository.save(converter.convertFromCreate(userId, playlist));
return converter.convertToRead(result); return converter.convertToRead(result);
} }
public List<PlaylistReadDTO> findPlaylists(long userId) { public List<PlaylistReadResponse> findPlaylists(long userId) {
return repository.findAllByOwnerId(userId) return repository.findAllByOwnerId(userId)
.stream() .stream()
.map(converter::convertToRead) .map(converter::convertToRead)

View File

@ -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"));
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,98 @@
package com.bivashy.backend.composer.service;
import java.io.IOException;
import java.util.List;
import java.util.Optional;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import com.bivashy.backend.composer.dto.track.AddLocalTrackRequest;
import com.bivashy.backend.composer.dto.track.PlaylistTrackResponse;
import com.bivashy.backend.composer.dto.track.TrackBulkReorderRequest;
import com.bivashy.backend.composer.dto.track.TrackReorderAfterRequest;
import com.bivashy.backend.composer.dto.track.TrackResponse;
import com.bivashy.backend.composer.model.SourceTypes;
import com.bivashy.backend.composer.model.Track;
import com.bivashy.backend.composer.model.TrackMetadata;
import com.bivashy.backend.composer.model.TrackSource;
import com.bivashy.backend.composer.model.User;
import com.bivashy.backend.composer.repository.TrackRepository;
import com.bivashy.backend.composer.service.MetadataParseService.Metadata;
@Service
public class TrackService {
private final TrackRepository trackRepository;
private final TrackSourceService trackSourceService;
private final TrackMetadataService trackMetadataService;
private final TrackPlaylistService trackPlaylistService;
private final MetadataParseService metadataParseService;
public TrackService(TrackRepository trackRepository,
TrackSourceService trackSourceService,
TrackMetadataService trackMetadataService,
TrackPlaylistService trackPlaylistService,
MetadataParseService metadataParseService) {
this.trackRepository = trackRepository;
this.trackSourceService = trackSourceService;
this.trackMetadataService = trackMetadataService;
this.trackPlaylistService = trackPlaylistService;
this.metadataParseService = metadataParseService;
}
public TrackResponse addLocalTrack(User user, Long playlistId, AddLocalTrackRequest request) throws IOException {
Optional<Metadata> metadata = metadataParseService.extractMetadata(request.source().getInputStream());
String ffprobeJson = metadata.map(Metadata::rawJson).orElse("{}");
TrackSource trackSource = trackSourceService.createTrackSource(
request.source().getBytes(), ffprobeJson, SourceTypes.FILE);
Track track = trackRepository.save(new Track(trackSource));
String fileName = fileNameWithoutExtension(request.source().getOriginalFilename());
String title = metadata.map(Metadata::title).orElse(fileName);
String artist = metadata.map(Metadata::artist).orElse(null);
int durationSeconds = metadata.map(Metadata::durationSeconds).map(Float::intValue).orElse(0);
// TODO: thumbnail
// TODO: Recognize music if the duration is less than five minutes
// (configurable), and if not, it is a playlist and should be marked as is
trackMetadataService.createTrackMetadata(
track, title, fileName, trackSource.getSourceUrl(), artist, null, durationSeconds);
trackPlaylistService.insertTrackAtEnd(playlistId, track.getId());
return new TrackResponse(
track.getId(),
title,
artist,
trackSource.getSourceUrl(),
durationSeconds,
fileName);
}
public List<PlaylistTrackResponse> getPlaylistTracks(User user, Long playlistId) {
return trackPlaylistService.getPlaylistTracks(playlistId).stream()
.map(pt -> {
Track track = trackRepository.findById(pt.getTrackId())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Track not found"));
TrackMetadata metadata = trackMetadataService.getTrackMetadata(track.getId());
return new PlaylistTrackResponse(
track.getId(),
metadata.getTitle(),
metadata.getArtist(),
metadata.getAudioPath(),
metadata.getDurationSeconds(),
metadata.getFileName());
})
.toList();
}
public void bulkReorder(User user, Long playlistId, TrackBulkReorderRequest request) {
trackPlaylistService.bulkReorder(playlistId, request.trackIds());
}
private String fileNameWithoutExtension(String fileName) {
return fileName.replaceFirst("[.][^.]+$", "");
}
}

View File

@ -0,0 +1,33 @@
package com.bivashy.backend.composer.service;
import java.time.LocalDateTime;
import java.util.Map;
import org.springframework.stereotype.Service;
import com.bivashy.backend.composer.model.SourceType;
import com.bivashy.backend.composer.model.TrackSource;
import com.bivashy.backend.composer.repository.SourceTypeRepository;
import com.bivashy.backend.composer.repository.TrackSourceRepository;
@Service
public class TrackSourceService {
private final TrackSourceRepository trackSourceRepository;
private final SourceTypeRepository sourceTypeRepository;
private final AudioBlobStorageService s3Service;
public TrackSourceService(TrackSourceRepository trackSourceRepository,
SourceTypeRepository sourceTypeRepository,
AudioBlobStorageService s3Service) {
this.trackSourceRepository = trackSourceRepository;
this.sourceTypeRepository = sourceTypeRepository;
this.s3Service = s3Service;
}
public TrackSource createTrackSource(byte[] audioBytes, String ffprobeJson, String sourceType) {
String audioPath = s3Service.store(audioBytes, Map.of("ffprobe", ffprobeJson));
SourceType type = sourceTypeRepository.findByName(sourceType)
.orElseThrow(() -> new IllegalStateException("Source type not found: " + sourceType));
return trackSourceRepository.save(new TrackSource(audioPath, type, LocalDateTime.now()));
}
}

View File

@ -0,0 +1,49 @@
package com.bivashy.backend.composer.service.importing;
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.connection.MessageListener;
import org.springframework.data.redis.listener.ChannelTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.stereotype.Component;
import com.bivashy.backend.composer.dto.importing.ImportTrackKey;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
@Component
public class RedisMessageSubscriber {
private final RedisMessageListenerContainer container;
private final Map<String, Consumer<String>> subscriptions = new ConcurrentHashMap<>();
public RedisMessageSubscriber(RedisMessageListenerContainer container) {
this.container = container;
}
public void subscribeToPlaylist(long playlistId, long userId, Consumer<String> messageHandler) {
String channel = ImportTrackKey.redisChannelKey(playlistId, userId);
String subscriptionKey = ImportTrackKey.subscriptionKey(playlistId, userId);
if (!subscriptions.containsKey(subscriptionKey)) {
container.addMessageListener(new MessageListener() {
@Override
public void onMessage(Message message, byte[] pattern) {
String receivedMessage = new String(message.getBody());
if (subscriptions.containsKey(subscriptionKey)) {
messageHandler.accept(receivedMessage);
}
}
}, new ChannelTopic(channel));
subscriptions.put(subscriptionKey, messageHandler);
}
}
public void unsubscribeFromPlaylist(long playlistId, long userId) {
String subscriptionKey = ImportTrackKey.subscriptionKey(playlistId, userId);
subscriptions.remove(subscriptionKey);
}
}

View File

@ -0,0 +1,106 @@
package com.bivashy.backend.composer.service.importing;
import com.bivashy.backend.composer.dto.importing.ImportTrackKey;
import com.bivashy.backend.composer.dto.importing.TrackProgressDTO;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class RedisProgressService {
private final StringRedisTemplate redisTemplate;
private final ObjectMapper objectMapper;
private final Map<String, Set<String>> activeConnections = new ConcurrentHashMap<>();
public RedisProgressService(StringRedisTemplate redisTemplate,
ObjectMapper objectMapper) {
this.redisTemplate = redisTemplate;
this.objectMapper = objectMapper;
}
public void saveProgress(TrackProgressDTO progress) {
try {
String key = ImportTrackKey.progressKey(progress.getPlaylistId(), progress.getUserId());
String trackKey = ImportTrackKey.trackKey(
progress.getPlaylistId(),
progress.getTrackId(),
progress.getUserId());
String progressJson = objectMapper.writeValueAsString(progress);
redisTemplate.opsForHash().put(key, progress.getTrackId(), progressJson);
redisTemplate.opsForValue().set(trackKey, progressJson);
redisTemplate.expire(key, 24, java.util.concurrent.TimeUnit.HOURS);
redisTemplate.expire(trackKey, 24, java.util.concurrent.TimeUnit.HOURS);
publishProgressUpdate(progress);
} catch (Exception e) {
throw new RuntimeException("Failed to save progress to Redis", e);
}
}
public List<TrackProgressDTO> getPlaylistProgress(long playlistId, long userId) {
try {
String key = ImportTrackKey.progressKey(playlistId, userId);
Map<Object, Object> progressMap = redisTemplate.opsForHash().entries(key);
List<TrackProgressDTO> progressList = new ArrayList<>();
for (Object value : progressMap.values()) {
TrackProgressDTO progress = objectMapper.readValue(
(String) value,
TrackProgressDTO.class);
progressList.add(progress);
}
progressList.sort(Comparator.comparingLong(TrackProgressDTO::getTimestamp));
return progressList;
} catch (Exception e) {
throw new RuntimeException("Failed to get progress from Redis", e);
}
}
public TrackProgressDTO getTrackProgress(long playlistId, String trackId, long userId) {
try {
String key = ImportTrackKey.trackKey(playlistId, trackId, userId);
String progressJson = redisTemplate.opsForValue().get(key);
if (progressJson != null) {
return objectMapper.readValue(progressJson, TrackProgressDTO.class);
}
return null;
} catch (Exception e) {
throw new RuntimeException("Failed to get track progress", e);
}
}
private void publishProgressUpdate(TrackProgressDTO progress) {
try {
String channel = ImportTrackKey.redisChannelKey(progress.getPlaylistId(), progress.getUserId());
String message = objectMapper.writeValueAsString(progress);
redisTemplate.convertAndSend(channel, message);
} catch (Exception e) {
e.printStackTrace();
}
}
public void addActiveConnection(long playlistId, long userId) {
String connectionKey = ImportTrackKey.subscriptionKey(playlistId, userId);
activeConnections.computeIfAbsent(connectionKey, k -> ConcurrentHashMap.newKeySet()).add(connectionKey);
}
public void removeActiveConnection(long playlistId, long userId) {
String connectionKey = ImportTrackKey.subscriptionKey(playlistId, userId);
activeConnections.remove(connectionKey);
}
public boolean hasActiveConnections(long playlistId, long userId) {
String connectionKey = ImportTrackKey.subscriptionKey(playlistId, userId);
return activeConnections.containsKey(connectionKey);
}
}

View File

@ -11,6 +11,12 @@ spring:
access-key: ${S3_ACCESS_KEY} access-key: ${S3_ACCESS_KEY}
secret-key: ${S3_SECRET_KEY} secret-key: ${S3_SECRET_KEY}
bucket: ${S3_BUCKET} bucket: ${S3_BUCKET}
redis:
host-name: ${REDIS_HOST_NAME}
servlet:
multipart:
max-file-size: 8096MB
max-request-size: 8096MB
logging: logging:
level: level:

View File

@ -5,17 +5,9 @@ CREATE TABLE IF NOT EXISTS "users" (
"updated_at" timestamp NOT NULL DEFAULT NOW() "updated_at" timestamp NOT NULL DEFAULT NOW()
); );
CREATE TABLE IF NOT EXISTS "source_provider" (
"id" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
"name" varchar(500) NOT NULL
);
CREATE TABLE IF NOT EXISTS "source_type" ( CREATE TABLE IF NOT EXISTS "source_type" (
"id" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, "id" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
"provider_id" bigint NOT NULL, "name" varchar(500) NOT NULL
"name" varchar(500) NOT NULL,
CONSTRAINT "fk_source_type_provider_id"
FOREIGN KEY ("provider_id") REFERENCES "source_provider" ("id")
); );
CREATE TABLE IF NOT EXISTS "track_source" ( CREATE TABLE IF NOT EXISTS "track_source" (
@ -40,6 +32,7 @@ CREATE TABLE IF NOT EXISTS "track_metadata" (
"id" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, "id" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
"track_id" bigint NOT NULL, "track_id" bigint NOT NULL,
"title" varchar(500) NOT NULL, "title" varchar(500) NOT NULL,
"file_name" varchar(500) NOT NULL,
"audio_path" varchar(500) NOT NULL, "audio_path" varchar(500) NOT NULL,
"artist" varchar(500), "artist" varchar(500),
"thumbnail_path" varchar(500), "thumbnail_path" varchar(500),
@ -51,21 +44,23 @@ CREATE TABLE IF NOT EXISTS "track_metadata" (
CREATE TABLE IF NOT EXISTS "playlist" ( CREATE TABLE IF NOT EXISTS "playlist" (
"id" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, "id" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
"owner_id" bigint NOT NULL, "owner_id" bigint NOT NULL,
"title" varchar(500) NOT NULL UNIQUE, "title" varchar(500) NOT NULL,
"created_at" timestamp NOT NULL DEFAULT NOW(), "created_at" timestamp NOT NULL DEFAULT NOW(),
"updated_at" timestamp NOT NULL DEFAULT NOW(), "updated_at" timestamp NOT NULL DEFAULT NOW(),
CONSTRAINT "fk_playlist_owner_id" CONSTRAINT "fk_playlist_owner_id"
FOREIGN KEY ("owner_id") REFERENCES "users" ("id") FOREIGN KEY ("owner_id") REFERENCES "users" ("id"),
CONSTRAINT "uq_playlist_owner_title"
UNIQUE ("owner_id", "title")
); );
CREATE TABLE IF NOT EXISTS "playlist_track" ( CREATE TABLE IF NOT EXISTS "playlist_track" (
"playlist_id" bigint NOT NULL, "playlist_id" bigint NOT NULL,
"track_id" bigint NOT NULL, "track_id" bigint NOT NULL,
"order" bigint NOT NULL, "order_index" numeric NOT NULL,
CONSTRAINT "pk_playlist_track" PRIMARY KEY ("playlist_id", "track_id"), CONSTRAINT "pk_playlist_track_new" PRIMARY KEY ("playlist_id", "track_id"),
CONSTRAINT "fk_playlist_track_playlist_id" CONSTRAINT "fk_playlist_track_playlist_id_new"
FOREIGN KEY ("playlist_id") REFERENCES "playlist" ("id"), FOREIGN KEY ("playlist_id") REFERENCES "playlist" ("id"),
CONSTRAINT "fk_playlist_track_track_id" CONSTRAINT "fk_playlist_track_track_id_new"
FOREIGN KEY ("track_id") REFERENCES "track" ("id") FOREIGN KEY ("track_id") REFERENCES "track" ("id")
); );

View File

@ -1,17 +1,9 @@
INSERT INTO "source_provider" ("id", "name") INSERT INTO "source_type" ("id", "name")
OVERRIDING SYSTEM VALUE OVERRIDING SYSTEM VALUE
VALUES VALUES
(1, 'YOUTUBE'), (1, 'VIDEO'),
(2, 'LOCAL'), (2, 'PLAYLIST'),
(3, 'EXTERNAL') (3, 'FILE'),
ON CONFLICT ("id") DO NOTHING; (4, 'URL')
INSERT INTO "source_type" ("id", "provider_id", "name")
OVERRIDING SYSTEM VALUE
VALUES
(1, 1, 'VIDEO'),
(2, 1, 'PLAYLIST'),
(3, 2, 'FILE'),
(4, 3, 'URL')
ON CONFLICT ("id") DO NOTHING; ON CONFLICT ("id") DO NOTHING;

View File

@ -0,0 +1,3 @@
spring:
flyway:
enabled: false