diff --git a/compose.yml b/compose.yml index 8845852..5f30956 100644 --- a/compose.yml +++ b/compose.yml @@ -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 diff --git a/pom.xml b/pom.xml index b5836eb..c58dd27 100644 --- a/pom.xml +++ b/pom.xml @@ -33,7 +33,15 @@ 3.2.4 4.0.0 3.2.3 + 2.8.5 + 2024.08.29 + + + jitpack.io + https://jitpack.io + + @@ -98,6 +106,16 @@ tika-parsers-standard-package ${apache-tika.version} + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc-openapi.version} + + + com.github.kokorin.jaffree + jaffree + ${jaffree.version} + org.postgresql diff --git a/src/main/java/com/bivashy/backend/composer/config/SecurityConfig.java b/src/main/java/com/bivashy/backend/composer/config/SecurityConfig.java index 208b713..88c51d5 100644 --- a/src/main/java/com/bivashy/backend/composer/config/SecurityConfig.java +++ b/src/main/java/com/bivashy/backend/composer/config/SecurityConfig.java @@ -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 = 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 = 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 diff --git a/src/main/java/com/bivashy/backend/composer/controller/TrackController.java b/src/main/java/com/bivashy/backend/composer/controller/TrackController.java new file mode 100644 index 0000000..be7fc7b --- /dev/null +++ b/src/main/java/com/bivashy/backend/composer/controller/TrackController.java @@ -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 addLocalTrack( + @AuthenticationPrincipal User user, + @PathVariable Long playlistId, + @ModelAttribute AddLocalTrackRequest request) throws IOException { + TrackResponse response = trackService.addLocalTrack(user, playlistId, request); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/bivashy/backend/composer/dto/track/AddLocalTrackRequest.java b/src/main/java/com/bivashy/backend/composer/dto/track/AddLocalTrackRequest.java new file mode 100644 index 0000000..e937057 --- /dev/null +++ b/src/main/java/com/bivashy/backend/composer/dto/track/AddLocalTrackRequest.java @@ -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) { +} diff --git a/src/main/java/com/bivashy/backend/composer/dto/track/TrackResponse.java b/src/main/java/com/bivashy/backend/composer/dto/track/TrackResponse.java new file mode 100644 index 0000000..49ed489 --- /dev/null +++ b/src/main/java/com/bivashy/backend/composer/dto/track/TrackResponse.java @@ -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) { +} diff --git a/src/main/java/com/bivashy/backend/composer/model/TrackMetadata.java b/src/main/java/com/bivashy/backend/composer/model/TrackMetadata.java index 7feb6be..0d249e9 100644 --- a/src/main/java/com/bivashy/backend/composer/model/TrackMetadata.java +++ b/src/main/java/com/bivashy/backend/composer/model/TrackMetadata.java @@ -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; } diff --git a/src/main/java/com/bivashy/backend/composer/model/TrackPlaylist.java b/src/main/java/com/bivashy/backend/composer/model/TrackPlaylist.java new file mode 100644 index 0000000..5f1fb9c --- /dev/null +++ b/src/main/java/com/bivashy/backend/composer/model/TrackPlaylist.java @@ -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; + } + +} diff --git a/src/main/java/com/bivashy/backend/composer/model/key/PlaylistTrackId.java b/src/main/java/com/bivashy/backend/composer/model/key/PlaylistTrackId.java new file mode 100644 index 0000000..5c1fd71 --- /dev/null +++ b/src/main/java/com/bivashy/backend/composer/model/key/PlaylistTrackId.java @@ -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; + } +} diff --git a/src/main/java/com/bivashy/backend/composer/repository/SourceTypeRepository.java b/src/main/java/com/bivashy/backend/composer/repository/SourceTypeRepository.java new file mode 100644 index 0000000..d73d4de --- /dev/null +++ b/src/main/java/com/bivashy/backend/composer/repository/SourceTypeRepository.java @@ -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 { + Optional findByName(String name); +} diff --git a/src/main/java/com/bivashy/backend/composer/repository/TrackMetadataRepository.java b/src/main/java/com/bivashy/backend/composer/repository/TrackMetadataRepository.java new file mode 100644 index 0000000..fe8b689 --- /dev/null +++ b/src/main/java/com/bivashy/backend/composer/repository/TrackMetadataRepository.java @@ -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 { +} diff --git a/src/main/java/com/bivashy/backend/composer/repository/TrackPlaylistRepository.java b/src/main/java/com/bivashy/backend/composer/repository/TrackPlaylistRepository.java new file mode 100644 index 0000000..ac385bd --- /dev/null +++ b/src/main/java/com/bivashy/backend/composer/repository/TrackPlaylistRepository.java @@ -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 { +} diff --git a/src/main/java/com/bivashy/backend/composer/repository/TrackRepository.java b/src/main/java/com/bivashy/backend/composer/repository/TrackRepository.java new file mode 100644 index 0000000..52c239c --- /dev/null +++ b/src/main/java/com/bivashy/backend/composer/repository/TrackRepository.java @@ -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 { +} diff --git a/src/main/java/com/bivashy/backend/composer/repository/TrackSourceRepository.java b/src/main/java/com/bivashy/backend/composer/repository/TrackSourceRepository.java new file mode 100644 index 0000000..528b089 --- /dev/null +++ b/src/main/java/com/bivashy/backend/composer/repository/TrackSourceRepository.java @@ -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 { +} diff --git a/src/main/java/com/bivashy/backend/composer/service/CustomUserDetailsService.java b/src/main/java/com/bivashy/backend/composer/service/CustomUserDetailsService.java index 75af40f..47b16a3 100644 --- a/src/main/java/com/bivashy/backend/composer/service/CustomUserDetailsService.java +++ b/src/main/java/com/bivashy/backend/composer/service/CustomUserDetailsService.java @@ -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 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(); } } diff --git a/src/main/java/com/bivashy/backend/composer/service/MetadataParseService.java b/src/main/java/com/bivashy/backend/composer/service/MetadataParseService.java new file mode 100644 index 0000000..3f1aef2 --- /dev/null +++ b/src/main/java/com/bivashy/backend/composer/service/MetadataParseService.java @@ -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 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) { + } +} diff --git a/src/main/java/com/bivashy/backend/composer/service/TrackService.java b/src/main/java/com/bivashy/backend/composer/service/TrackService.java new file mode 100644 index 0000000..91bd3bd --- /dev/null +++ b/src/main/java/com/bivashy/backend/composer/service/TrackService.java @@ -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 = metadataParseService.extractMetadata(request.source().getInputStream()); + + Optional 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()); + } + +} diff --git a/src/main/resources/db/migration/V1_10__create_base_tables.sql b/src/main/resources/db/migration/V1_10__create_base_tables.sql index fb4005a..1cdd35e 100644 --- a/src/main/resources/db/migration/V1_10__create_base_tables.sql +++ b/src/main/resources/db/migration/V1_10__create_base_tables.sql @@ -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"),