Implement "Create track in playlist" endpoint
This commit is contained in:
@ -6,7 +6,7 @@ services:
|
||||
networks:
|
||||
- mp3_composer
|
||||
ports:
|
||||
- 9000:9000
|
||||
- 9001:9001
|
||||
postgres:
|
||||
image: postgres:alpine
|
||||
container_name: composer_postgres
|
||||
@ -41,4 +41,4 @@ services:
|
||||
|
||||
networks:
|
||||
mp3_composer:
|
||||
driver: bridge
|
||||
external: true
|
||||
|
||||
18
pom.xml
18
pom.xml
@ -33,7 +33,15 @@
|
||||
<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>
|
||||
</properties>
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>jitpack.io</id>
|
||||
<url>https://jitpack.io</url>
|
||||
</repository>
|
||||
</repositories>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
@ -98,6 +106,16 @@
|
||||
<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>com.github.kokorin.jaffree</groupId>
|
||||
<artifactId>jaffree</artifactId>
|
||||
<version>${jaffree.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -0,0 +1,35 @@
|
||||
package com.bivashy.backend.composer.controller;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
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.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import com.bivashy.backend.composer.dto.track.AddLocalTrackRequest;
|
||||
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", 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);
|
||||
}
|
||||
}
|
||||
@ -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 TrackResponse(
|
||||
Long trackId,
|
||||
String title,
|
||||
String artist,
|
||||
String audioPath,
|
||||
Integer durationSeconds,
|
||||
String fileName) {
|
||||
}
|
||||
@ -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,67 @@
|
||||
package com.bivashy.backend.composer.model;
|
||||
|
||||
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)
|
||||
private Long 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, Long order) {
|
||||
this.playlistId = playlistId;
|
||||
this.trackId = trackId;
|
||||
this.order = order;
|
||||
}
|
||||
|
||||
public Long getPlaylistId() {
|
||||
return playlistId;
|
||||
}
|
||||
|
||||
public Long getTrackId() {
|
||||
return trackId;
|
||||
}
|
||||
|
||||
public Long getOrder() {
|
||||
return order;
|
||||
}
|
||||
|
||||
public Playlist getPlaylist() {
|
||||
return playlist;
|
||||
}
|
||||
|
||||
public Track getTrack() {
|
||||
return track;
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,63 @@
|
||||
package com.bivashy.backend.composer.model.key;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
public class PlaylistTrackId implements Serializable {
|
||||
private Long playlistId;
|
||||
private Long trackId;
|
||||
|
||||
PlaylistTrackId() {
|
||||
}
|
||||
|
||||
public PlaylistTrackId(Long playlistId, Long trackId) {
|
||||
this.playlistId = playlistId;
|
||||
this.trackId = trackId;
|
||||
}
|
||||
|
||||
public Long getPlaylistId() {
|
||||
return playlistId;
|
||||
}
|
||||
|
||||
public void setPlaylistId(Long playlistId) {
|
||||
this.playlistId = playlistId;
|
||||
}
|
||||
|
||||
public Long getTrackId() {
|
||||
return trackId;
|
||||
}
|
||||
|
||||
public void setTrackId(Long trackId) {
|
||||
this.trackId = trackId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
final int prime = 31;
|
||||
int result = 1;
|
||||
result = prime * result + ((playlistId == null) ? 0 : playlistId.hashCode());
|
||||
result = prime * result + ((trackId == null) ? 0 : trackId.hashCode());
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object obj) {
|
||||
if (this == obj)
|
||||
return true;
|
||||
if (obj == null)
|
||||
return false;
|
||||
if (getClass() != obj.getClass())
|
||||
return false;
|
||||
PlaylistTrackId other = (PlaylistTrackId) obj;
|
||||
if (playlistId == null) {
|
||||
if (other.playlistId != null)
|
||||
return false;
|
||||
} else if (!playlistId.equals(other.playlistId))
|
||||
return false;
|
||||
if (trackId == null) {
|
||||
if (other.trackId != null)
|
||||
return false;
|
||||
} else if (!trackId.equals(other.trackId))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
package com.bivashy.backend.composer.repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import com.bivashy.backend.composer.model.SourceType;
|
||||
|
||||
public interface SourceTypeRepository extends JpaRepository<SourceType, Long> {
|
||||
Optional<SourceType> findByName(String name);
|
||||
}
|
||||
@ -0,0 +1,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.TrackMetadata;
|
||||
|
||||
@Repository
|
||||
public interface TrackMetadataRepository extends JpaRepository<TrackMetadata, Long> {
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
package com.bivashy.backend.composer.repository;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import com.bivashy.backend.composer.model.TrackPlaylist;
|
||||
import com.bivashy.backend.composer.model.key.PlaylistTrackId;
|
||||
|
||||
public interface TrackPlaylistRepository extends JpaRepository<TrackPlaylist, PlaylistTrackId> {
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
package com.bivashy.backend.composer.repository;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import com.bivashy.backend.composer.model.Track;
|
||||
|
||||
@Repository
|
||||
public interface TrackRepository extends JpaRepository<Track, Long> {
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
package com.bivashy.backend.composer.repository;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import com.bivashy.backend.composer.model.TrackSource;
|
||||
|
||||
@Repository
|
||||
public interface TrackSourceRepository extends JpaRepository<TrackSource, Long> {
|
||||
}
|
||||
@ -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,33 @@
|
||||
package com.bivashy.backend.composer.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.github.kokorin.jaffree.ffprobe.FFprobe;
|
||||
import com.github.kokorin.jaffree.ffprobe.FFprobeResult;
|
||||
import com.github.kokorin.jaffree.ffprobe.Format;
|
||||
import com.github.kokorin.jaffree.ffprobe.PipeInput;
|
||||
|
||||
@Service
|
||||
public class MetadataParseService {
|
||||
public Optional<Metadata> extractMetadata(InputStream input) throws IOException {
|
||||
FFprobeResult result = FFprobe.atPath()
|
||||
.setShowFormat(true)
|
||||
.setInput(PipeInput.pumpFrom(input))
|
||||
.execute();
|
||||
|
||||
Format format = result.getFormat();
|
||||
|
||||
if (format == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
return Optional.of(new Metadata(format.getTag("title"), format.getTag("artist"), format.getDuration()));
|
||||
}
|
||||
|
||||
public static record Metadata(String title, String artist, Float durationSeconds) {
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,88 @@
|
||||
package com.bivashy.backend.composer.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.bivashy.backend.composer.dto.track.AddLocalTrackRequest;
|
||||
import com.bivashy.backend.composer.dto.track.TrackResponse;
|
||||
import com.bivashy.backend.composer.model.SourceType;
|
||||
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.TrackPlaylist;
|
||||
import com.bivashy.backend.composer.model.TrackSource;
|
||||
import com.bivashy.backend.composer.model.User;
|
||||
import com.bivashy.backend.composer.repository.SourceTypeRepository;
|
||||
import com.bivashy.backend.composer.repository.TrackMetadataRepository;
|
||||
import com.bivashy.backend.composer.repository.TrackPlaylistRepository;
|
||||
import com.bivashy.backend.composer.repository.TrackRepository;
|
||||
import com.bivashy.backend.composer.repository.TrackSourceRepository;
|
||||
import com.bivashy.backend.composer.service.MetadataParseService.Metadata;
|
||||
|
||||
@Service
|
||||
public class TrackService {
|
||||
private final AudioBlobStorageService s3Service;
|
||||
private final TrackSourceRepository trackSourceRepository;
|
||||
private final TrackRepository trackRepository;
|
||||
private final TrackMetadataRepository trackMetadataRepository;
|
||||
private final TrackPlaylistRepository trackPlaylistRepository;
|
||||
private final SourceTypeRepository sourceTypeRepository;
|
||||
private final MetadataParseService metadataParseService;
|
||||
|
||||
public TrackService(AudioBlobStorageService s3Service, TrackSourceRepository trackSourceRepository,
|
||||
TrackRepository trackRepository, TrackMetadataRepository trackMetadataRepository,
|
||||
TrackPlaylistRepository trackPlaylistRepository,
|
||||
SourceTypeRepository sourceTypeRepository, MetadataParseService metadataParseService) {
|
||||
this.s3Service = s3Service;
|
||||
this.trackSourceRepository = trackSourceRepository;
|
||||
this.trackRepository = trackRepository;
|
||||
this.trackMetadataRepository = trackMetadataRepository;
|
||||
this.trackPlaylistRepository = trackPlaylistRepository;
|
||||
this.sourceTypeRepository = sourceTypeRepository;
|
||||
this.metadataParseService = metadataParseService;
|
||||
}
|
||||
|
||||
public TrackResponse addLocalTrack(User user, Long playlistId, AddLocalTrackRequest request) throws IOException {
|
||||
String audioPath = s3Service.store(request.source().getBytes());
|
||||
|
||||
Optional<Metadata> metadata = metadataParseService.extractMetadata(request.source().getInputStream());
|
||||
|
||||
Optional<SourceType> possibleSourceType = sourceTypeRepository.findByName(SourceTypes.FILE);
|
||||
if (possibleSourceType.isEmpty()) {
|
||||
throw new IllegalStateException("cannot find source type " + SourceTypes.FILE);
|
||||
}
|
||||
SourceType sourceType = possibleSourceType.get();
|
||||
TrackSource trackSource = new TrackSource(audioPath, sourceType, LocalDateTime.now());
|
||||
trackSource = trackSourceRepository.save(trackSource);
|
||||
|
||||
Track track = new Track(trackSource);
|
||||
track = trackRepository.save(track);
|
||||
|
||||
String fileName = request.source().getOriginalFilename();
|
||||
String title = metadata.map(m -> m.title()).orElse(fileName);
|
||||
String artist = metadata.map(m -> m.artist()).orElse(null);
|
||||
String thumbnailPath = null; // TODO:?
|
||||
int durationSeconds = metadata.map(m -> m.durationSeconds()).map(Float::intValue).orElse(0);
|
||||
|
||||
TrackMetadata trackMetadata = new TrackMetadata(track, title, fileName, audioPath,
|
||||
artist, thumbnailPath,
|
||||
durationSeconds);
|
||||
trackMetadata = trackMetadataRepository.save(trackMetadata);
|
||||
|
||||
// TODO: use linked list instead of int order?
|
||||
TrackPlaylist playlistTrack = new TrackPlaylist(playlistId, track.getId(), /* order */ 0L);
|
||||
playlistTrack = trackPlaylistRepository.save(playlistTrack);
|
||||
|
||||
return new TrackResponse(
|
||||
track.getId(),
|
||||
trackMetadata.getTitle(),
|
||||
trackMetadata.getArtist(),
|
||||
trackMetadata.getAudioPath(),
|
||||
trackMetadata.getDurationSeconds(),
|
||||
trackMetadata.getFileName());
|
||||
}
|
||||
|
||||
}
|
||||
@ -40,6 +40,7 @@ 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,
|
||||
"file_name" varchar(500) NOT NULL,
|
||||
"audio_path" varchar(500) NOT NULL,
|
||||
"artist" varchar(500),
|
||||
"thumbnail_path" varchar(500),
|
||||
@ -51,17 +52,19 @@ CREATE TABLE IF NOT EXISTS "track_metadata" (
|
||||
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,
|
||||
"title" varchar(500) NOT NULL,
|
||||
"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")
|
||||
FOREIGN KEY ("owner_id") REFERENCES "users" ("id"),
|
||||
CONSTRAINT "uq_playlist_owner_title"
|
||||
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,
|
||||
"order_index" 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"),
|
||||
|
||||
Reference in New Issue
Block a user