Implement "Create track in playlist" endpoint

This commit is contained in:
2025-11-15 02:33:10 +05:00
parent ef72a590d2
commit d908c1e64b
18 changed files with 411 additions and 15 deletions

View File

@ -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
View File

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

View File

@ -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());
CustomUserDetails defaultUser1 = create(repository, 1, "user", passwordEncoder().encode("password"));
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()) {
repository.save(new User(defaultUser.getUsername()));
repository.save(new User(userDetails.getUsername()));
}
return new CustomUserDetailsService(defaultUser);
return userDetails;
}
@Bean

View File

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

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 TrackResponse(
Long trackId,
String title,
String artist,
String audioPath,
Integer durationSeconds,
String fileName) {
}

View File

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

View File

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

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

View File

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

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

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

View File

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

View File

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

View File

@ -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"),