Compare commits
2 Commits
main
...
95ca3d1a65
| Author | SHA1 | Date | |
|---|---|---|---|
|
95ca3d1a65
|
|||
|
973e588947
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -33,5 +33,3 @@ build/
|
|||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
.env
|
.env
|
||||||
|
|
||||||
.sqruff
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
services:
|
services:
|
||||||
composer_backend:
|
hls-proxy:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.dev
|
dockerfile: Dockerfile.dev
|
||||||
|
|||||||
7
pom.xml
7
pom.xml
@ -35,7 +35,7 @@
|
|||||||
<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>
|
<springdoc-openapi.version>2.8.5</springdoc-openapi.version>
|
||||||
<jaffree.version>2024.08.29</jaffree.version>
|
<jaffree.version>2024.08.29</jaffree.version>
|
||||||
<yt-dlp-java.version>2.0.8</yt-dlp-java.version>
|
<yt-dlp-java.version>2.0.6</yt-dlp-java.version>
|
||||||
<record-builder.version>51</record-builder.version>
|
<record-builder.version>51</record-builder.version>
|
||||||
</properties>
|
</properties>
|
||||||
<repositories>
|
<repositories>
|
||||||
@ -121,11 +121,6 @@
|
|||||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||||
<version>${springdoc-openapi.version}</version>
|
<version>${springdoc-openapi.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
|
||||||
<groupId>org.springdoc</groupId>
|
|
||||||
<artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
|
|
||||||
<version>${springdoc-openapi.version}</version>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.github.kokorin.jaffree</groupId>
|
<groupId>com.github.kokorin.jaffree</groupId>
|
||||||
<artifactId>jaffree</artifactId>
|
<artifactId>jaffree</artifactId>
|
||||||
|
|||||||
@ -38,21 +38,11 @@ public class TrackController {
|
|||||||
@ModelAttribute AddLocalTrackRequest request) throws ImportTrackException {
|
@ModelAttribute AddLocalTrackRequest request) throws ImportTrackException {
|
||||||
var params = AddLocalTrackParamsBuilder.builder()
|
var params = AddLocalTrackParamsBuilder.builder()
|
||||||
.blob(new MultipartBlob(request.source()))
|
.blob(new MultipartBlob(request.source()))
|
||||||
.includeProgressHistory(true)
|
|
||||||
.build();
|
.build();
|
||||||
TrackResponse response = trackService.addLocalTrack(user, playlistId, params);
|
TrackResponse response = trackService.addLocalTrack(user, playlistId, params);
|
||||||
return ResponseEntity.ok(response);
|
return ResponseEntity.ok(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping(path = "/playlist/{playlistId}/track/youtube/refresh/{sourceId}")
|
|
||||||
public ResponseEntity<List<TrackResponse>> addYoutubeTrack(
|
|
||||||
@AuthenticationPrincipal CustomUserDetails user,
|
|
||||||
@PathVariable long playlistId,
|
|
||||||
@PathVariable long sourceId) throws ImportTrackException {
|
|
||||||
List<TrackResponse> response = trackService.refreshYoutubePlaylist(user, playlistId, sourceId);
|
|
||||||
return ResponseEntity.ok(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping(path = "/playlist/{playlistId}/track/youtube")
|
@PostMapping(path = "/playlist/{playlistId}/track/youtube")
|
||||||
public ResponseEntity<List<TrackResponse>> addYoutubeTrack(
|
public ResponseEntity<List<TrackResponse>> addYoutubeTrack(
|
||||||
@AuthenticationPrincipal CustomUserDetails user,
|
@AuthenticationPrincipal CustomUserDetails user,
|
||||||
|
|||||||
@ -4,9 +4,8 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.codec.ServerSentEvent;
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
@ -17,6 +16,7 @@ import com.bivashy.backend.composer.dto.importing.BaseTrackProgress;
|
|||||||
import com.bivashy.backend.composer.dto.importing.ImportTrackKey;
|
import com.bivashy.backend.composer.dto.importing.ImportTrackKey;
|
||||||
import com.bivashy.backend.composer.service.importing.RedisMessageSubscriber;
|
import com.bivashy.backend.composer.service.importing.RedisMessageSubscriber;
|
||||||
import com.bivashy.backend.composer.service.importing.RedisProgressService;
|
import com.bivashy.backend.composer.service.importing.RedisProgressService;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
@ -24,11 +24,10 @@ import reactor.core.publisher.Sinks;
|
|||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
public class ProgressSSEController {
|
public class ProgressSSEController {
|
||||||
private static final Logger logger = LoggerFactory.getLogger(ProgressSSEController.class);
|
|
||||||
|
|
||||||
private final RedisProgressService redisProgressService;
|
private final RedisProgressService redisProgressService;
|
||||||
private final RedisMessageSubscriber redisSubscriber;
|
private final RedisMessageSubscriber redisSubscriber;
|
||||||
private final Map<String, Sinks.Many<BaseTrackProgress>> sinks = new ConcurrentHashMap<>();
|
private final Map<String, Sinks.Many<String>> sinks = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
public ProgressSSEController(RedisProgressService redisProgressService,
|
public ProgressSSEController(RedisProgressService redisProgressService,
|
||||||
RedisMessageSubscriber redisSubscriber) {
|
RedisMessageSubscriber redisSubscriber) {
|
||||||
@ -37,7 +36,7 @@ public class ProgressSSEController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping(value = "/importing/stream/{playlistId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
@GetMapping(value = "/importing/stream/{playlistId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||||
public Flux<BaseTrackProgress> streamProgress(
|
public Flux<ServerSentEvent<String>> streamProgress(
|
||||||
@PathVariable long playlistId,
|
@PathVariable long playlistId,
|
||||||
@AuthenticationPrincipal CustomUserDetails user,
|
@AuthenticationPrincipal CustomUserDetails user,
|
||||||
HttpServletResponse response) {
|
HttpServletResponse response) {
|
||||||
@ -49,8 +48,8 @@ public class ProgressSSEController {
|
|||||||
|
|
||||||
String connectionKey = ImportTrackKey.subscriptionKey(playlistId, userId);
|
String connectionKey = ImportTrackKey.subscriptionKey(playlistId, userId);
|
||||||
|
|
||||||
Sinks.Many<BaseTrackProgress> sink = sinks.computeIfAbsent(connectionKey, k -> {
|
Sinks.Many<String> sink = sinks.computeIfAbsent(connectionKey, k -> {
|
||||||
Sinks.Many<BaseTrackProgress> newSink = Sinks.many().replay().latest();
|
Sinks.Many<String> newSink = Sinks.many().replay().latest();
|
||||||
|
|
||||||
redisSubscriber.subscribeToPlaylist(playlistId, userId, message -> {
|
redisSubscriber.subscribeToPlaylist(playlistId, userId, message -> {
|
||||||
newSink.tryEmitNext(message);
|
newSink.tryEmitNext(message);
|
||||||
@ -62,14 +61,19 @@ public class ProgressSSEController {
|
|||||||
redisProgressService.addActiveConnection(playlistId, userId);
|
redisProgressService.addActiveConnection(playlistId, userId);
|
||||||
|
|
||||||
return sink.asFlux()
|
return sink.asFlux()
|
||||||
|
.map(data -> ServerSentEvent.<String>builder()
|
||||||
|
.data(data)
|
||||||
|
.event("progress-update")
|
||||||
|
.build())
|
||||||
.doFirst(() -> {
|
.doFirst(() -> {
|
||||||
try {
|
try {
|
||||||
List<BaseTrackProgress> existingProgresses = redisProgressService.getPlaylistProgress(
|
List<BaseTrackProgress> existingProgresses = redisProgressService.getPlaylistProgress(
|
||||||
playlistId,
|
playlistId,
|
||||||
userId);
|
userId);
|
||||||
|
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
for (BaseTrackProgress progress : existingProgresses) {
|
for (BaseTrackProgress progress : existingProgresses) {
|
||||||
sink.tryEmitNext(progress);
|
sink.tryEmitNext(mapper.writeValueAsString(progress));
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
@ -88,7 +92,7 @@ public class ProgressSSEController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void cleanupConnection(Long playlistId, long userId,
|
private void cleanupConnection(Long playlistId, long userId,
|
||||||
Sinks.Many<BaseTrackProgress> sink, String connectionKey) {
|
Sinks.Many<String> sink, String connectionKey) {
|
||||||
try {
|
try {
|
||||||
redisProgressService.removeActiveConnection(playlistId, userId);
|
redisProgressService.removeActiveConnection(playlistId, userId);
|
||||||
redisSubscriber.unsubscribeFromPlaylist(playlistId, userId);
|
redisSubscriber.unsubscribeFromPlaylist(playlistId, userId);
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
package com.bivashy.backend.composer.dto.importing;
|
package com.bivashy.backend.composer.dto.importing;
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonSubTypes;
|
import com.fasterxml.jackson.annotation.JsonSubTypes;
|
||||||
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||||
|
|
||||||
@ -11,27 +9,21 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
|||||||
@JsonSubTypes.Type(value = SingleTrackProgress.class, name = "TRACK"),
|
@JsonSubTypes.Type(value = SingleTrackProgress.class, name = "TRACK"),
|
||||||
})
|
})
|
||||||
public abstract class BaseTrackProgress {
|
public abstract class BaseTrackProgress {
|
||||||
protected UUID id;
|
|
||||||
protected long playlistId;
|
protected long playlistId;
|
||||||
protected long trackSourceId;
|
protected long trackId;
|
||||||
protected long userId;
|
protected long userId;
|
||||||
|
|
||||||
protected long timestamp;
|
protected long timestamp;
|
||||||
private ProgressEntryType type;
|
private String type;
|
||||||
|
|
||||||
public BaseTrackProgress(long playlistId, long trackSourceId, long userId) {
|
public BaseTrackProgress(long playlistId, long trackId, long userId) {
|
||||||
this.id = UUID.randomUUID();
|
|
||||||
this.playlistId = playlistId;
|
this.playlistId = playlistId;
|
||||||
this.trackSourceId = trackSourceId;
|
this.trackId = trackId;
|
||||||
this.userId = userId;
|
this.userId = userId;
|
||||||
this.timestamp = System.currentTimeMillis();
|
this.timestamp = System.currentTimeMillis();
|
||||||
}
|
}
|
||||||
|
|
||||||
public UUID getId() {
|
public Long getTimestamp() {
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public long getTimestamp() {
|
|
||||||
return timestamp;
|
return timestamp;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -39,7 +31,7 @@ public abstract class BaseTrackProgress {
|
|||||||
return userId;
|
return userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ProgressEntryType getType() {
|
public String getType() {
|
||||||
return type;
|
return type;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -47,12 +39,12 @@ public abstract class BaseTrackProgress {
|
|||||||
return playlistId;
|
return playlistId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public long getTrackSourceId() {
|
public long getTrackId() {
|
||||||
return trackSourceId;
|
return trackId;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected void setType(ProgressEntryType type) {
|
protected void setType(ProgressEntryType type) {
|
||||||
this.type = type;
|
this.type = type.name();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,8 +5,8 @@ public class ImportTrackKey {
|
|||||||
return String.format("progress:%d:%d", userId, playlistId);
|
return String.format("progress:%d:%d", userId, playlistId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static String trackKey(long playlistId, long trackSourceId, long userId) {
|
public static String trackKey(long playlistId, long trackId, long userId) {
|
||||||
return String.format("track:%d:%d:%d", userId, playlistId, trackSourceId);
|
return String.format("track:%d:%d:%d", userId, playlistId, trackId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static String redisChannelKey(long playlistId, long userId) {
|
public static String redisChannelKey(long playlistId, long userId) {
|
||||||
|
|||||||
@ -3,20 +3,12 @@ package com.bivashy.backend.composer.dto.importing;
|
|||||||
public class PlaylistProgress extends BaseTrackProgress {
|
public class PlaylistProgress extends BaseTrackProgress {
|
||||||
private String ytdlnStdout;
|
private String ytdlnStdout;
|
||||||
private int overallProgress;
|
private int overallProgress;
|
||||||
private int trackCount;
|
private String status;
|
||||||
private String playlistTitle;
|
|
||||||
private ProgressStatus status;
|
|
||||||
|
|
||||||
PlaylistProgress() {
|
public PlaylistProgress(long playlistId, long trackId, long userId) {
|
||||||
super(0, 0, 0);
|
super(playlistId, trackId, userId);
|
||||||
}
|
|
||||||
|
|
||||||
public PlaylistProgress(long playlistId, long trackSourceId, long userId, int trackCount, String playlistTitle) {
|
|
||||||
super(playlistId, trackSourceId, userId);
|
|
||||||
this.setType(ProgressEntryType.PLAYLIST);
|
this.setType(ProgressEntryType.PLAYLIST);
|
||||||
this.status = ProgressStatus.LOADING;
|
this.status = "LOADING";
|
||||||
this.trackCount = trackCount;
|
|
||||||
this.playlistTitle = playlistTitle;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getYtdlnStdout() {
|
public String getYtdlnStdout() {
|
||||||
@ -35,20 +27,11 @@ public class PlaylistProgress extends BaseTrackProgress {
|
|||||||
this.overallProgress = overallProgress;
|
this.overallProgress = overallProgress;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ProgressStatus getStatus() {
|
public String getStatus() {
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setStatus(ProgressStatus status) {
|
public void setStatus(String status) {
|
||||||
this.status = status;
|
this.status = status;
|
||||||
}
|
}
|
||||||
|
|
||||||
public int getTrackCount() {
|
|
||||||
return trackCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getPlaylistTitle() {
|
|
||||||
return playlistTitle;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +0,0 @@
|
|||||||
package com.bivashy.backend.composer.dto.importing;
|
|
||||||
|
|
||||||
public enum ProgressStatus {
|
|
||||||
LOADING, FINISHED
|
|
||||||
}
|
|
||||||
@ -4,12 +4,8 @@ public class SingleTrackProgress extends BaseTrackProgress {
|
|||||||
private String title;
|
private String title;
|
||||||
private String format;
|
private String format;
|
||||||
|
|
||||||
SingleTrackProgress() {
|
public SingleTrackProgress(long playlistId, long trackId, long userId, String title, String format) {
|
||||||
super(0, 0, 0);
|
super(playlistId, trackId, userId);
|
||||||
}
|
|
||||||
|
|
||||||
public SingleTrackProgress(long playlistId, long trackSourceId, long userId, String title, String format) {
|
|
||||||
super(playlistId, trackSourceId, userId);
|
|
||||||
this.setType(ProgressEntryType.TRACK);
|
this.setType(ProgressEntryType.TRACK);
|
||||||
this.title = title;
|
this.title = title;
|
||||||
this.format = format;
|
this.format = format;
|
||||||
|
|||||||
@ -28,7 +28,7 @@ public class Playlist {
|
|||||||
@JoinColumn(name = "owner_id", nullable = false)
|
@JoinColumn(name = "owner_id", nullable = false)
|
||||||
private User owner;
|
private User owner;
|
||||||
|
|
||||||
@Column(unique = true, nullable = false)
|
@Column(unique = true, nullable = false, length = 500)
|
||||||
private String title;
|
private String title;
|
||||||
|
|
||||||
@Column(name = "created_at", nullable = false, updatable = false)
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
|||||||
@ -1,5 +0,0 @@
|
|||||||
package com.bivashy.backend.composer.model;
|
|
||||||
|
|
||||||
public enum SourceMetadataType {
|
|
||||||
YOUTUBE
|
|
||||||
}
|
|
||||||
@ -1,5 +1,47 @@
|
|||||||
package com.bivashy.backend.composer.model;
|
package com.bivashy.backend.composer.model;
|
||||||
|
|
||||||
public enum SourceType {
|
import java.util.HashSet;
|
||||||
VIDEO, PLAYLIST, PLAYLIST_ITEM, FILE, URL
|
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_type")
|
||||||
|
public class SourceType {
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 500)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "sourceType", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||||
|
private Set<TrackSource> trackSources = new HashSet<>();
|
||||||
|
|
||||||
|
SourceType() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public SourceType(String name) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<TrackSource> getTrackSources() {
|
||||||
|
return trackSources;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,8 @@
|
|||||||
|
package com.bivashy.backend.composer.model;
|
||||||
|
|
||||||
|
public class SourceTypes {
|
||||||
|
public static final String AUDIO = "VIDEO";
|
||||||
|
public static final String PLAYLIST = "PLAYLIST";
|
||||||
|
public static final String FILE = "FILE";
|
||||||
|
public static final String URL = "URL";
|
||||||
|
}
|
||||||
@ -4,16 +4,15 @@ import java.time.LocalDateTime;
|
|||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
import org.hibernate.annotations.JdbcType;
|
|
||||||
import org.hibernate.dialect.PostgreSQLEnumJdbcType;
|
|
||||||
|
|
||||||
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.Enumerated;
|
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;
|
||||||
|
|
||||||
@ -24,12 +23,11 @@ public class TrackSource {
|
|||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
private Long id;
|
private Long id;
|
||||||
|
|
||||||
@Column(name = "source_url", nullable = false)
|
@Column(name = "source_url", nullable = false, length = 500)
|
||||||
private String sourceUrl;
|
private String sourceUrl;
|
||||||
|
|
||||||
@Enumerated
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
@Column(name = "source_type", nullable = false)
|
@JoinColumn(name = "source_type_id", nullable = false)
|
||||||
@JdbcType(PostgreSQLEnumJdbcType.class)
|
|
||||||
private SourceType sourceType;
|
private SourceType sourceType;
|
||||||
|
|
||||||
@Column(name = "last_fetched_at", nullable = false)
|
@Column(name = "last_fetched_at", nullable = false)
|
||||||
|
|||||||
@ -1,47 +0,0 @@
|
|||||||
package com.bivashy.backend.composer.model;
|
|
||||||
|
|
||||||
import jakarta.persistence.Column;
|
|
||||||
import jakarta.persistence.Entity;
|
|
||||||
import jakarta.persistence.FetchType;
|
|
||||||
import jakarta.persistence.GeneratedValue;
|
|
||||||
import jakarta.persistence.GenerationType;
|
|
||||||
import jakarta.persistence.Id;
|
|
||||||
import jakarta.persistence.JoinColumn;
|
|
||||||
import jakarta.persistence.ManyToOne;
|
|
||||||
import jakarta.persistence.Table;
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
@Table(name = "track_source_metadata")
|
|
||||||
public class TrackSourceMetadata {
|
|
||||||
@Id
|
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
|
||||||
private Long id;
|
|
||||||
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
|
||||||
@JoinColumn(name = "source_id", nullable = false, unique = true)
|
|
||||||
private TrackSource source;
|
|
||||||
|
|
||||||
@Column(name = "url", nullable = false)
|
|
||||||
private String url;
|
|
||||||
|
|
||||||
TrackSourceMetadata() {
|
|
||||||
}
|
|
||||||
|
|
||||||
public TrackSourceMetadata(TrackSource source, String url) {
|
|
||||||
this.source = source;
|
|
||||||
this.url = url;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Long getId() {
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public TrackSource getSource() {
|
|
||||||
return source;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getUrl() {
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -20,7 +20,7 @@ public class User {
|
|||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
private Long id;
|
private Long id;
|
||||||
|
|
||||||
@Column(nullable = false)
|
@Column(nullable = false, length = 500)
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
@Column(name = "created_at", nullable = false, updatable = false)
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
@ -1,18 +0,0 @@
|
|||||||
package com.bivashy.backend.composer.repository;
|
|
||||||
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
|
||||||
import org.springframework.data.jpa.repository.Query;
|
|
||||||
import org.springframework.data.repository.query.Param;
|
|
||||||
import org.springframework.stereotype.Repository;
|
|
||||||
|
|
||||||
import com.bivashy.backend.composer.model.TrackSourceMetadata;
|
|
||||||
|
|
||||||
@Repository
|
|
||||||
public interface TrackSourceMetadataRepository extends JpaRepository<TrackSourceMetadata, Long> {
|
|
||||||
@Query("SELECT tsm FROM TrackSourceMetadata tsm " +
|
|
||||||
"JOIN FETCH tsm.source " +
|
|
||||||
"WHERE tsm.source.id = :sourceId")
|
|
||||||
Optional<TrackSourceMetadata> findBySourceIdWithSource(@Param("sourceId") Long sourceId);
|
|
||||||
}
|
|
||||||
@ -1,13 +1,14 @@
|
|||||||
package com.bivashy.backend.composer.service;
|
package com.bivashy.backend.composer.service;
|
||||||
|
|
||||||
import java.io.File;
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.Comparator;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
@ -24,42 +25,50 @@ import com.bivashy.backend.composer.dto.track.TrackBulkReorderRequest;
|
|||||||
import com.bivashy.backend.composer.dto.track.TrackResponse;
|
import com.bivashy.backend.composer.dto.track.TrackResponse;
|
||||||
import com.bivashy.backend.composer.dto.track.YoutubeTrackRequest;
|
import com.bivashy.backend.composer.dto.track.YoutubeTrackRequest;
|
||||||
import com.bivashy.backend.composer.dto.track.service.AddLocalTrackParams;
|
import com.bivashy.backend.composer.dto.track.service.AddLocalTrackParams;
|
||||||
|
import com.bivashy.backend.composer.dto.track.service.AddLocalTrackParamsBuilder;
|
||||||
import com.bivashy.backend.composer.exception.ImportTrackException;
|
import com.bivashy.backend.composer.exception.ImportTrackException;
|
||||||
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.Track;
|
||||||
import com.bivashy.backend.composer.model.TrackMetadata;
|
import com.bivashy.backend.composer.model.TrackMetadata;
|
||||||
import com.bivashy.backend.composer.model.TrackSource;
|
import com.bivashy.backend.composer.model.TrackSource;
|
||||||
import com.bivashy.backend.composer.repository.TrackRepository;
|
import com.bivashy.backend.composer.repository.TrackRepository;
|
||||||
import com.bivashy.backend.composer.service.MetadataParseService.Metadata;
|
import com.bivashy.backend.composer.service.MetadataParseService.Metadata;
|
||||||
import com.bivashy.backend.composer.service.importing.RedisProgressService;
|
import com.bivashy.backend.composer.service.importing.RedisProgressService;
|
||||||
|
import com.bivashy.backend.composer.util.SimpleBlob.PathBlob;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.jfposton.ytdlp.YtDlp;
|
import com.jfposton.ytdlp.YtDlp;
|
||||||
import com.jfposton.ytdlp.YtDlpException;
|
import com.jfposton.ytdlp.YtDlpException;
|
||||||
|
import com.jfposton.ytdlp.YtDlpRequest;
|
||||||
import com.jfposton.ytdlp.mapper.VideoInfo;
|
import com.jfposton.ytdlp.mapper.VideoInfo;
|
||||||
|
|
||||||
|
import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class TrackService {
|
public class TrackService {
|
||||||
private static final Logger logger = LoggerFactory.getLogger(TrackService.class);
|
private static final Logger logger = LoggerFactory.getLogger(TrackService.class);
|
||||||
public static final String DOWNLOADED_METADATA_FILE = "downloaded";
|
public static final String DOWNLOADED_METADATA_FILE = "downloaded";
|
||||||
|
|
||||||
|
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
private final TrackRepository trackRepository;
|
private final TrackRepository trackRepository;
|
||||||
private final TrackSourceService trackSourceService;
|
private final TrackSourceService trackSourceService;
|
||||||
private final TrackMetadataService trackMetadataService;
|
private final TrackMetadataService trackMetadataService;
|
||||||
private final TrackPlaylistService trackPlaylistService;
|
private final TrackPlaylistService trackPlaylistService;
|
||||||
private final MetadataParseService metadataParseService;
|
private final MetadataParseService metadataParseService;
|
||||||
private final RedisProgressService redisProgressService;
|
private final RedisProgressService redisProgressService;
|
||||||
private final YoutubeTrackService youtubeTrackService;
|
private final AudioS3StorageService s3StorageService;
|
||||||
|
|
||||||
public TrackService(TrackRepository trackRepository, TrackSourceService trackSourceService,
|
public TrackService(TrackRepository trackRepository, TrackSourceService trackSourceService,
|
||||||
TrackMetadataService trackMetadataService, TrackPlaylistService trackPlaylistService,
|
TrackMetadataService trackMetadataService, TrackPlaylistService trackPlaylistService,
|
||||||
MetadataParseService metadataParseService, RedisProgressService redisProgressService,
|
MetadataParseService metadataParseService, RedisProgressService redisProgressService,
|
||||||
YoutubeTrackService youtubeTrackService) {
|
AudioS3StorageService s3StorageService) {
|
||||||
this.trackRepository = trackRepository;
|
this.trackRepository = trackRepository;
|
||||||
this.trackSourceService = trackSourceService;
|
this.trackSourceService = trackSourceService;
|
||||||
this.trackMetadataService = trackMetadataService;
|
this.trackMetadataService = trackMetadataService;
|
||||||
this.trackPlaylistService = trackPlaylistService;
|
this.trackPlaylistService = trackPlaylistService;
|
||||||
this.metadataParseService = metadataParseService;
|
this.metadataParseService = metadataParseService;
|
||||||
this.redisProgressService = redisProgressService;
|
this.redisProgressService = redisProgressService;
|
||||||
this.youtubeTrackService = youtubeTrackService;
|
this.s3StorageService = s3StorageService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public TrackResponse addLocalTrack(CustomUserDetails user,
|
public TrackResponse addLocalTrack(CustomUserDetails user,
|
||||||
@ -78,7 +87,7 @@ public class TrackService {
|
|||||||
TrackSource trackSource;
|
TrackSource trackSource;
|
||||||
try {
|
try {
|
||||||
trackSource = trackSourceService.createLocalTrackSource(
|
trackSource = trackSourceService.createLocalTrackSource(
|
||||||
request.body(), ffprobeJson, params.ytdlpMetadata(), SourceType.FILE);
|
request.body(), ffprobeJson, params.ytdlpMetadata(), SourceTypes.FILE);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw new ImportTrackException("cannot read blob body", e);
|
throw new ImportTrackException("cannot read blob body", e);
|
||||||
}
|
}
|
||||||
@ -104,8 +113,7 @@ public class TrackService {
|
|||||||
|
|
||||||
if (params.includeProgressHistory()) {
|
if (params.includeProgressHistory()) {
|
||||||
redisProgressService
|
redisProgressService
|
||||||
.saveProgress(
|
.saveProgress(new SingleTrackProgress(playlistId, track.getId(), user.getId(), title, fileFormat));
|
||||||
new SingleTrackProgress(playlistId, trackSource.getId(), user.getId(), title, fileFormat));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new TrackResponse(
|
return new TrackResponse(
|
||||||
@ -118,12 +126,6 @@ public class TrackService {
|
|||||||
fileName);
|
fileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public List<TrackResponse> refreshYoutubePlaylist(CustomUserDetails user, long playlistId, long sourceId)
|
|
||||||
throws ImportTrackException {
|
|
||||||
return youtubeTrackService.refreshYoutubePlaylist(user, playlistId, sourceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public List<TrackResponse> addYoutubeTrack(CustomUserDetails user, long playlistId,
|
public List<TrackResponse> addYoutubeTrack(CustomUserDetails user, long playlistId,
|
||||||
YoutubeTrackRequest request) throws ImportTrackException {
|
YoutubeTrackRequest request) throws ImportTrackException {
|
||||||
@ -143,17 +145,34 @@ public class TrackService {
|
|||||||
if (videoInfos.size() == 1) {
|
if (videoInfos.size() == 1) {
|
||||||
try {
|
try {
|
||||||
VideoInfo videoInfo = videoInfos.get(0);
|
VideoInfo videoInfo = videoInfos.get(0);
|
||||||
|
|
||||||
Path temporaryFolder = Files.createTempDirectory("yt-dlp-tmp");
|
Path temporaryFolder = Files.createTempDirectory("yt-dlp-tmp");
|
||||||
|
var ytDlpRequest = new YtDlpRequest(request.youtubeUrl(), temporaryFolder.toAbsolutePath().toString());
|
||||||
|
ytDlpRequest.setOption("output", "%(id)s");
|
||||||
|
var response = YtDlp.execute(ytDlpRequest);
|
||||||
|
// TODO: write to RedisProgressService
|
||||||
|
|
||||||
var params = youtubeTrackService.downloadYoutubeTrack(temporaryFolder, videoInfo,
|
TrackResponse result = null;
|
||||||
request.youtubeUrl());
|
|
||||||
TrackResponse result = addLocalTrack(user, playlistId, params);
|
|
||||||
|
|
||||||
try (Stream<Path> pathStream = Files.walk(temporaryFolder)) {
|
try (Stream<Path> pathStream = Files.walk(temporaryFolder)) {
|
||||||
pathStream.sorted(Comparator.reverseOrder())
|
List<Path> downloadedFiles = Files.walk(temporaryFolder).toList();
|
||||||
.map(Path::toFile)
|
|
||||||
.forEach(File::delete);
|
if (downloadedFiles.isEmpty())
|
||||||
|
throw new ImportTrackException("yt-dlp didn't downloaded anything for " + request.youtubeUrl());
|
||||||
|
|
||||||
|
for (Path downloadedFile : downloadedFiles) {
|
||||||
|
var params = AddLocalTrackParamsBuilder.builder()
|
||||||
|
.blob(new PathBlob(downloadedFile))
|
||||||
|
.ytdlpMetadata(OBJECT_MAPPER.writeValueAsString(videoInfo))
|
||||||
|
.includeProgressHistory(false)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
result = addLocalTrack(user,
|
||||||
|
playlistId,
|
||||||
|
params);
|
||||||
|
Files.delete(downloadedFile);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Files.delete(temporaryFolder);
|
||||||
return List.of(result);
|
return List.of(result);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw new ImportTrackException("i/o during single youtube video downloading", e);
|
throw new ImportTrackException("i/o during single youtube video downloading", e);
|
||||||
@ -162,10 +181,116 @@ public class TrackService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TrackSource trackSource = trackSourceService.createYoutubeTrackSource(SourceType.PLAYLIST,
|
TrackSource trackSource = trackSourceService.createYoutubeTrackSource(SourceTypes.PLAYLIST);
|
||||||
request.youtubeUrl());
|
return refreshYoutubePlaylist(playlistId, trackSource, videoInfos, request.youtubeUrl());
|
||||||
return youtubeTrackService.refreshYoutubePlaylist(user.getId(), playlistId, trackSource, videoInfos,
|
}
|
||||||
request.youtubeUrl());
|
|
||||||
|
public List<TrackResponse> refreshYoutubePlaylist(long playlistId, TrackSource trackSource,
|
||||||
|
List<VideoInfo> videoInfos,
|
||||||
|
String youtubeUrl) throws ImportTrackException {
|
||||||
|
List<TrackResponse> result = new ArrayList<>();
|
||||||
|
logger.info(trackSource.getSourceUrl());
|
||||||
|
try {
|
||||||
|
Path temporaryFolder = Files.createTempDirectory("yt-dlp-tmp");
|
||||||
|
logger.info("temporaryFolder created {}", temporaryFolder.toString());
|
||||||
|
|
||||||
|
String downloadedMetadataKey = trackSource.getSourceUrl() + DOWNLOADED_METADATA_FILE;
|
||||||
|
try {
|
||||||
|
var rawBody = s3StorageService
|
||||||
|
.readRaw(downloadedMetadataKey);
|
||||||
|
Files.write(temporaryFolder.resolve(DOWNLOADED_METADATA_FILE), rawBody);
|
||||||
|
} catch (NoSuchKeyException e) {
|
||||||
|
logger.warn(".downloaded metadata file was not found, ignoring");
|
||||||
|
}
|
||||||
|
|
||||||
|
var ytDlpRequest = new YtDlpRequest(youtubeUrl, temporaryFolder.toAbsolutePath().toString());
|
||||||
|
ytDlpRequest.setOption("output", "%(id)s");
|
||||||
|
ytDlpRequest.setOption("download-archive", DOWNLOADED_METADATA_FILE);
|
||||||
|
ytDlpRequest.setOption("extract-audio");
|
||||||
|
ytDlpRequest.setOption("audio-quality", 0);
|
||||||
|
ytDlpRequest.setOption("audio-format", "best");
|
||||||
|
ytDlpRequest.setOption("no-overwrites");
|
||||||
|
var response = YtDlp.execute(ytDlpRequest);
|
||||||
|
logger.info("yt dlp response {}", response);
|
||||||
|
|
||||||
|
// TODO: write to RedisProgressService
|
||||||
|
|
||||||
|
try (Stream<Path> pathStream = Files.walk(temporaryFolder)) {
|
||||||
|
List<Path> downloadedFiles = Files.walk(temporaryFolder).toList();
|
||||||
|
logger.info("downloaded file count {}", downloadedFiles.size());
|
||||||
|
|
||||||
|
for (Path path : downloadedFiles) {
|
||||||
|
if (Files.isDirectory(path))
|
||||||
|
continue;
|
||||||
|
boolean isMetadataFile = path.getFileName().toString().equals(DOWNLOADED_METADATA_FILE);
|
||||||
|
var body = Files.readAllBytes(path);
|
||||||
|
|
||||||
|
if (isMetadataFile) {
|
||||||
|
s3StorageService.store(downloadedMetadataKey, body, Map.of());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String fileName = fileNameWithoutExtension(path.getFileName().toString());
|
||||||
|
VideoInfo videoInfo = videoInfos.stream()
|
||||||
|
.filter(v -> v.getId().equals(fileName))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow();
|
||||||
|
|
||||||
|
String audioKey = trackSource.getSourceUrl() + UUID.randomUUID().toString();
|
||||||
|
|
||||||
|
logger.info("downloaded file {} and info {}, key {}", fileName, videoInfo.getTitle(), audioKey);
|
||||||
|
|
||||||
|
Optional<Metadata> metadata = Optional.empty();
|
||||||
|
|
||||||
|
try (var inputStream = Files.newInputStream(path)) {
|
||||||
|
metadata = metadataParseService.extractMetadata(inputStream);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new ImportTrackException("cannot extract metadata from " + path.toString());
|
||||||
|
}
|
||||||
|
String ffprobeJson = metadata.map(Metadata::rawJson).orElse("{}");
|
||||||
|
|
||||||
|
TrackSource playlistEntrySource;
|
||||||
|
try {
|
||||||
|
playlistEntrySource = trackSourceService.createTrackSourceWithKey(audioKey, body, ffprobeJson,
|
||||||
|
OBJECT_MAPPER.writeValueAsString(videoInfo), SourceTypes.PLAYLIST);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new ImportTrackException("cannot read blob body", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
Track track = trackRepository.save(new Track(playlistEntrySource));
|
||||||
|
|
||||||
|
String title = videoInfo.getTitle();
|
||||||
|
String artist = metadata.map(Metadata::artist).orElse(null);
|
||||||
|
int durationSeconds = metadata.map(Metadata::durationSeconds).map(Float::intValue).orElse(0);
|
||||||
|
// TODO: thumbnail
|
||||||
|
// TODO: Recognize music if the duration is less than five minutes
|
||||||
|
// (configurable), and if not, it is a playlist and should be marked as is
|
||||||
|
trackMetadataService.createTrackMetadata(
|
||||||
|
track, title, fileName, audioKey, artist, null, durationSeconds);
|
||||||
|
|
||||||
|
trackPlaylistService.insertTrackAtEnd(playlistId, track.getId());
|
||||||
|
|
||||||
|
String fileFormat = "unknown";
|
||||||
|
if (metadata.isPresent()) {
|
||||||
|
fileFormat = metadata.map(m -> m.formatName()).get();
|
||||||
|
}
|
||||||
|
|
||||||
|
var trackResponse = new TrackResponse(
|
||||||
|
track.getId(),
|
||||||
|
title,
|
||||||
|
artist,
|
||||||
|
audioKey,
|
||||||
|
fileFormat,
|
||||||
|
durationSeconds,
|
||||||
|
fileName);
|
||||||
|
result.add(trackResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new ImportTrackException("i/o during playlist youtube video downloading", e);
|
||||||
|
} catch (YtDlpException e) {
|
||||||
|
throw new ImportTrackException("cannot download youtube video " + youtubeUrl, e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<PlaylistTrackResponse> getPlaylistTracks(CustomUserDetails user, Long playlistId) {
|
public List<PlaylistTrackResponse> getPlaylistTracks(CustomUserDetails user, Long playlistId) {
|
||||||
|
|||||||
@ -3,14 +3,12 @@ package com.bivashy.backend.composer.service;
|
|||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import com.bivashy.backend.composer.model.SourceType;
|
import com.bivashy.backend.composer.model.SourceType;
|
||||||
import com.bivashy.backend.composer.model.TrackSource;
|
import com.bivashy.backend.composer.model.TrackSource;
|
||||||
import com.bivashy.backend.composer.model.TrackSourceMetadata;
|
import com.bivashy.backend.composer.repository.SourceTypeRepository;
|
||||||
import com.bivashy.backend.composer.repository.TrackSourceMetadataRepository;
|
|
||||||
import com.bivashy.backend.composer.repository.TrackSourceRepository;
|
import com.bivashy.backend.composer.repository.TrackSourceRepository;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@ -19,50 +17,49 @@ public class TrackSourceService {
|
|||||||
public static final String YTDLP_METADATA_KEY = "ytdlp";
|
public static final String YTDLP_METADATA_KEY = "ytdlp";
|
||||||
|
|
||||||
private final TrackSourceRepository trackSourceRepository;
|
private final TrackSourceRepository trackSourceRepository;
|
||||||
private final TrackSourceMetadataRepository trackSourceMetadataRepository;
|
private final SourceTypeRepository sourceTypeRepository;
|
||||||
private final AudioBlobStorageService s3Service;
|
private final AudioBlobStorageService s3Service;
|
||||||
|
|
||||||
public TrackSourceService(TrackSourceRepository trackSourceRepository,
|
public TrackSourceService(TrackSourceRepository trackSourceRepository,
|
||||||
TrackSourceMetadataRepository trackSourceMetadataRepository, AudioBlobStorageService s3Service) {
|
SourceTypeRepository sourceTypeRepository,
|
||||||
|
AudioBlobStorageService s3Service) {
|
||||||
this.trackSourceRepository = trackSourceRepository;
|
this.trackSourceRepository = trackSourceRepository;
|
||||||
this.trackSourceMetadataRepository = trackSourceMetadataRepository;
|
this.sourceTypeRepository = sourceTypeRepository;
|
||||||
this.s3Service = s3Service;
|
this.s3Service = s3Service;
|
||||||
}
|
}
|
||||||
|
|
||||||
public TrackSource createLocalTrackSource(byte[] audioBytes,
|
public TrackSource createLocalTrackSource(byte[] audioBytes,
|
||||||
String ffprobeJson,
|
String ffprobeJson,
|
||||||
String ytdlpMetadata,
|
String ytdlpMetadata,
|
||||||
SourceType sourceType) {
|
String sourceType) {
|
||||||
Map<String, String> metadata = new HashMap<>(Map.of(YTDLP_METADATA_KEY, ffprobeJson));
|
Map<String, String> metadata = new HashMap<>(Map.of(YTDLP_METADATA_KEY, ffprobeJson));
|
||||||
if (ytdlpMetadata != null) {
|
if (ytdlpMetadata != null) {
|
||||||
// TODO: Add tag or smth?
|
// TODO: Add tag or smth?
|
||||||
}
|
}
|
||||||
String audioPath = s3Service.store(audioBytes, metadata);
|
String audioPath = s3Service.store(audioBytes, metadata);
|
||||||
|
|
||||||
return trackSourceRepository.save(new TrackSource(audioPath, sourceType, LocalDateTime.now()));
|
SourceType type = sourceTypeRepository.findByName(sourceType)
|
||||||
|
.orElseThrow(() -> new IllegalStateException("Source type not found: " + sourceType));
|
||||||
|
return trackSourceRepository.save(new TrackSource(audioPath, type, LocalDateTime.now()));
|
||||||
}
|
}
|
||||||
|
|
||||||
public TrackSource createTrackSourceWithKey(String key, byte[] audioBytes, String ffprobeJson,
|
public TrackSource createTrackSourceWithKey(String key, byte[] audioBytes, String ffprobeJson,
|
||||||
String ytdlpMetadata, SourceType sourceType) {
|
String ytdlpMetadata, String sourceType) {
|
||||||
Map<String, String> metadata = new HashMap<>(Map.of(YTDLP_METADATA_KEY, ffprobeJson));
|
Map<String, String> metadata = new HashMap<>(Map.of(YTDLP_METADATA_KEY, ffprobeJson));
|
||||||
if (ytdlpMetadata != null) {
|
if (ytdlpMetadata != null) {
|
||||||
// TODO: Add tag or smth?
|
// TODO: Add tag or smth?
|
||||||
}
|
}
|
||||||
String audioPath = s3Service.store(key, audioBytes, metadata);
|
String audioPath = s3Service.store(key, audioBytes, metadata);
|
||||||
|
|
||||||
return trackSourceRepository.save(new TrackSource(audioPath, sourceType, LocalDateTime.now()));
|
SourceType type = sourceTypeRepository.findByName(sourceType)
|
||||||
|
.orElseThrow(() -> new IllegalStateException("Source type not found: " + sourceType));
|
||||||
|
return trackSourceRepository.save(new TrackSource(audioPath, type, LocalDateTime.now()));
|
||||||
}
|
}
|
||||||
|
|
||||||
public TrackSource createYoutubeTrackSource(SourceType sourceType, String youtubeUrl) {
|
public TrackSource createYoutubeTrackSource(String sourceType) {
|
||||||
String folderPath = s3Service.storeFolder();
|
String folderPath = s3Service.storeFolder();
|
||||||
TrackSource trackSource = trackSourceRepository
|
SourceType type = sourceTypeRepository.findByName(sourceType)
|
||||||
.save(new TrackSource(folderPath, sourceType, LocalDateTime.now()));
|
.orElseThrow(() -> new IllegalStateException("Source type not found: " + sourceType));
|
||||||
trackSourceMetadataRepository.save(new TrackSourceMetadata(trackSource, youtubeUrl));
|
return trackSourceRepository.save(new TrackSource(folderPath, type, LocalDateTime.now()));
|
||||||
return trackSource;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Optional<TrackSourceMetadata> findWithMetadata(long sourceId) {
|
|
||||||
return trackSourceMetadataRepository.findBySourceIdWithSource(sourceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,252 +0,0 @@
|
|||||||
package com.bivashy.backend.composer.service;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.UUID;
|
|
||||||
import java.util.stream.Stream;
|
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
import com.bivashy.backend.composer.auth.CustomUserDetails;
|
|
||||||
import com.bivashy.backend.composer.dto.importing.PlaylistProgress;
|
|
||||||
import com.bivashy.backend.composer.dto.importing.ProgressStatus;
|
|
||||||
import com.bivashy.backend.composer.dto.track.TrackResponse;
|
|
||||||
import com.bivashy.backend.composer.dto.track.service.AddLocalTrackParams;
|
|
||||||
import com.bivashy.backend.composer.dto.track.service.AddLocalTrackParamsBuilder;
|
|
||||||
import com.bivashy.backend.composer.exception.ImportTrackException;
|
|
||||||
import com.bivashy.backend.composer.model.SourceType;
|
|
||||||
import com.bivashy.backend.composer.model.Track;
|
|
||||||
import com.bivashy.backend.composer.model.TrackSource;
|
|
||||||
import com.bivashy.backend.composer.model.TrackSourceMetadata;
|
|
||||||
import com.bivashy.backend.composer.repository.TrackRepository;
|
|
||||||
import com.bivashy.backend.composer.service.MetadataParseService.Metadata;
|
|
||||||
import com.bivashy.backend.composer.service.importing.RedisProgressService;
|
|
||||||
import com.bivashy.backend.composer.util.SimpleBlob.PathBlob;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import com.jfposton.ytdlp.YtDlp;
|
|
||||||
import com.jfposton.ytdlp.YtDlpException;
|
|
||||||
import com.jfposton.ytdlp.YtDlpRequest;
|
|
||||||
import com.jfposton.ytdlp.mapper.VideoInfo;
|
|
||||||
|
|
||||||
import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
public class YoutubeTrackService {
|
|
||||||
private static final Logger logger = LoggerFactory.getLogger(YoutubeTrackService.class);
|
|
||||||
public static final String DOWNLOADED_METADATA_FILE = "downloaded";
|
|
||||||
|
|
||||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
|
||||||
private final AudioS3StorageService s3StorageService;
|
|
||||||
private final MetadataParseService metadataParseService;
|
|
||||||
private final TrackRepository trackRepository;
|
|
||||||
private final TrackMetadataService trackMetadataService;
|
|
||||||
private final TrackPlaylistService trackPlaylistService;
|
|
||||||
private final TrackSourceService trackSourceService;
|
|
||||||
private final RedisProgressService redisProgressService;
|
|
||||||
|
|
||||||
public YoutubeTrackService(AudioS3StorageService s3StorageService, MetadataParseService metadataParseService,
|
|
||||||
TrackRepository trackRepository, TrackMetadataService trackMetadataService,
|
|
||||||
TrackPlaylistService trackPlaylistService, TrackSourceService trackSourceService,
|
|
||||||
RedisProgressService redisProgressService) {
|
|
||||||
this.s3StorageService = s3StorageService;
|
|
||||||
this.metadataParseService = metadataParseService;
|
|
||||||
this.trackRepository = trackRepository;
|
|
||||||
this.trackMetadataService = trackMetadataService;
|
|
||||||
this.trackPlaylistService = trackPlaylistService;
|
|
||||||
this.trackSourceService = trackSourceService;
|
|
||||||
this.redisProgressService = redisProgressService;
|
|
||||||
}
|
|
||||||
|
|
||||||
public AddLocalTrackParams downloadYoutubeTrack(Path temporaryFolder, VideoInfo videoInfo, String youtubeUrl)
|
|
||||||
throws IOException, YtDlpException, ImportTrackException {
|
|
||||||
var ytDlpRequest = new YtDlpRequest(youtubeUrl, temporaryFolder.toAbsolutePath().toString());
|
|
||||||
ytDlpRequest.setOption("output", "%(id)s");
|
|
||||||
var response = YtDlp.execute(ytDlpRequest);
|
|
||||||
// TODO: write to RedisProgressService
|
|
||||||
|
|
||||||
TrackResponse result = null;
|
|
||||||
try (Stream<Path> pathStream = Files.walk(temporaryFolder)) {
|
|
||||||
List<Path> downloadedFiles = Files.walk(temporaryFolder).toList();
|
|
||||||
|
|
||||||
if (downloadedFiles.isEmpty())
|
|
||||||
throw new ImportTrackException("yt-dlp didn't downloaded anything for " + youtubeUrl);
|
|
||||||
|
|
||||||
for (Path downloadedFile : downloadedFiles) {
|
|
||||||
var params = AddLocalTrackParamsBuilder.builder()
|
|
||||||
.blob(new PathBlob(downloadedFile))
|
|
||||||
.ytdlpMetadata(OBJECT_MAPPER.writeValueAsString(videoInfo))
|
|
||||||
.includeProgressHistory(false)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
return params;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new ImportTrackException("cannot download any youtube track");
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<TrackResponse> refreshYoutubePlaylist(CustomUserDetails user, long playlistId, long sourceId)
|
|
||||||
throws ImportTrackException {
|
|
||||||
Optional<TrackSourceMetadata> trackSourceMetadataOpt = trackSourceService.findWithMetadata(sourceId);
|
|
||||||
if (trackSourceMetadataOpt.isEmpty())
|
|
||||||
throw new ImportTrackException("cannot find track source with metadata with id " + sourceId);
|
|
||||||
TrackSourceMetadata trackSourceMetadata = trackSourceMetadataOpt.get();
|
|
||||||
String youtubeUrl = trackSourceMetadata.getUrl();
|
|
||||||
|
|
||||||
List<VideoInfo> videoInfos = Collections.emptyList();
|
|
||||||
try {
|
|
||||||
videoInfos = YtDlp.getVideoInfo(youtubeUrl);
|
|
||||||
} catch (YtDlpException e) {
|
|
||||||
throw new ImportTrackException("cannot `yt-dlp --dump-json` from " + youtubeUrl, e);
|
|
||||||
}
|
|
||||||
return refreshYoutubePlaylist(user.getId(), playlistId, trackSourceMetadata.getSource(), videoInfos,
|
|
||||||
youtubeUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<TrackResponse> refreshYoutubePlaylist(long userId, long playlistId, TrackSource trackSource,
|
|
||||||
List<VideoInfo> videoInfos,
|
|
||||||
String youtubeUrl) throws ImportTrackException {
|
|
||||||
List<TrackResponse> result = new ArrayList<>();
|
|
||||||
logger.info(trackSource.getSourceUrl());
|
|
||||||
try {
|
|
||||||
Path temporaryFolder = Files.createTempDirectory("yt-dlp-tmp");
|
|
||||||
logger.info("temporaryFolder created {}", temporaryFolder.toString());
|
|
||||||
|
|
||||||
String downloadedMetadataKey = trackSource.getSourceUrl() + DOWNLOADED_METADATA_FILE;
|
|
||||||
try {
|
|
||||||
var rawBody = s3StorageService
|
|
||||||
.readRaw(downloadedMetadataKey);
|
|
||||||
Files.write(temporaryFolder.resolve(DOWNLOADED_METADATA_FILE), rawBody);
|
|
||||||
} catch (NoSuchKeyException e) {
|
|
||||||
logger.warn(".downloaded metadata file was not found, ignoring");
|
|
||||||
}
|
|
||||||
|
|
||||||
var ytDlpRequest = new YtDlpRequest(youtubeUrl, temporaryFolder.toAbsolutePath().toString());
|
|
||||||
ytDlpRequest.setOption("output", "%(id)s");
|
|
||||||
ytDlpRequest.setOption("download-archive", DOWNLOADED_METADATA_FILE);
|
|
||||||
ytDlpRequest.setOption("extract-audio");
|
|
||||||
ytDlpRequest.setOption("audio-quality", 0);
|
|
||||||
ytDlpRequest.setOption("audio-format", "best");
|
|
||||||
ytDlpRequest.setOption("no-overwrites");
|
|
||||||
|
|
||||||
String playlistTitle = videoInfos.stream()
|
|
||||||
.map(VideoInfo::getExtraProperties)
|
|
||||||
.filter(Objects::nonNull)
|
|
||||||
.map(v -> String.valueOf(v.getOrDefault("playlist_title", ""))).findFirst()
|
|
||||||
.orElse("");
|
|
||||||
|
|
||||||
PlaylistProgress playlistProgress = new PlaylistProgress(playlistId, trackSource.getId(), userId,
|
|
||||||
videoInfos.size(), playlistTitle);
|
|
||||||
redisProgressService.saveProgress(playlistProgress);
|
|
||||||
|
|
||||||
var response = YtDlp.execute(ytDlpRequest, (downloadProgress, ignored) -> {
|
|
||||||
redisProgressService.<PlaylistProgress>updateTrackProgressField(playlistId, trackSource.getId(), userId,
|
|
||||||
progress -> {
|
|
||||||
progress.setOverallProgress((int) downloadProgress);
|
|
||||||
});
|
|
||||||
|
|
||||||
}, stdoutLine -> {
|
|
||||||
redisProgressService.<PlaylistProgress>updateTrackProgressField(playlistId, trackSource.getId(), userId,
|
|
||||||
progress -> {
|
|
||||||
String previousStdout = progress.getYtdlnStdout() == null ? "" : progress.getYtdlnStdout();
|
|
||||||
progress.setYtdlnStdout(previousStdout + stdoutLine);
|
|
||||||
});
|
|
||||||
}, null);
|
|
||||||
redisProgressService.<PlaylistProgress>updateTrackProgressField(playlistId, trackSource.getId(), userId,
|
|
||||||
progress -> {
|
|
||||||
progress.setOverallProgress(100);
|
|
||||||
progress.setStatus(ProgressStatus.FINISHED);
|
|
||||||
});
|
|
||||||
logger.info("yt dlp response {}", response);
|
|
||||||
|
|
||||||
try (Stream<Path> pathStream = Files.walk(temporaryFolder)) {
|
|
||||||
List<Path> downloadedFiles = Files.walk(temporaryFolder).toList();
|
|
||||||
logger.info("downloaded file count {}", downloadedFiles.size() - 2);
|
|
||||||
|
|
||||||
for (Path path : downloadedFiles) {
|
|
||||||
if (Files.isDirectory(path))
|
|
||||||
continue;
|
|
||||||
boolean isMetadataFile = path.getFileName().toString().equals(DOWNLOADED_METADATA_FILE);
|
|
||||||
var body = Files.readAllBytes(path);
|
|
||||||
|
|
||||||
if (isMetadataFile) {
|
|
||||||
s3StorageService.store(downloadedMetadataKey, body, Map.of());
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
String fileName = fileNameWithoutExtension(path.getFileName().toString());
|
|
||||||
VideoInfo videoInfo = videoInfos.stream()
|
|
||||||
.filter(v -> v.getId().equals(fileName))
|
|
||||||
.findFirst()
|
|
||||||
.orElseThrow();
|
|
||||||
|
|
||||||
String audioKey = trackSource.getSourceUrl() + UUID.randomUUID().toString();
|
|
||||||
|
|
||||||
logger.info("downloaded file {} and info {}, key {}", fileName, videoInfo.getTitle(), audioKey);
|
|
||||||
|
|
||||||
Optional<Metadata> metadata = Optional.empty();
|
|
||||||
|
|
||||||
try (var inputStream = Files.newInputStream(path)) {
|
|
||||||
metadata = metadataParseService.extractMetadata(inputStream);
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new ImportTrackException("cannot extract metadata from " + path.toString());
|
|
||||||
}
|
|
||||||
String ffprobeJson = metadata.map(Metadata::rawJson).orElse("{}");
|
|
||||||
|
|
||||||
TrackSource playlistEntrySource;
|
|
||||||
try {
|
|
||||||
playlistEntrySource = trackSourceService.createTrackSourceWithKey(audioKey, body, ffprobeJson,
|
|
||||||
OBJECT_MAPPER.writeValueAsString(videoInfo), SourceType.PLAYLIST_ITEM);
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new ImportTrackException("cannot read blob body", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
Track track = trackRepository.save(new Track(playlistEntrySource));
|
|
||||||
|
|
||||||
String title = videoInfo.getTitle();
|
|
||||||
String artist = metadata.map(Metadata::artist).orElse(null);
|
|
||||||
int durationSeconds = metadata.map(Metadata::durationSeconds).map(Float::intValue).orElse(0);
|
|
||||||
// TODO: thumbnail
|
|
||||||
// TODO: Recognize music if the duration is less than five minutes
|
|
||||||
// (configurable), and if not, it is a playlist and should be marked as is
|
|
||||||
trackMetadataService.createTrackMetadata(
|
|
||||||
track, title, fileName, audioKey, artist, null, durationSeconds);
|
|
||||||
|
|
||||||
trackPlaylistService.insertTrackAtEnd(playlistId, track.getId());
|
|
||||||
|
|
||||||
String fileFormat = "unknown";
|
|
||||||
if (metadata.isPresent()) {
|
|
||||||
fileFormat = metadata.map(m -> m.formatName()).get();
|
|
||||||
}
|
|
||||||
|
|
||||||
var trackResponse = new TrackResponse(
|
|
||||||
track.getId(),
|
|
||||||
title,
|
|
||||||
artist,
|
|
||||||
audioKey,
|
|
||||||
fileFormat,
|
|
||||||
durationSeconds,
|
|
||||||
fileName);
|
|
||||||
result.add(trackResponse);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new ImportTrackException("i/o during playlist youtube video downloading", e);
|
|
||||||
} catch (YtDlpException e) {
|
|
||||||
throw new ImportTrackException("cannot download youtube video " + youtubeUrl, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String fileNameWithoutExtension(String fileName) {
|
|
||||||
return fileName.replaceFirst("[.][^.]+$", "");
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -1,35 +1,28 @@
|
|||||||
package com.bivashy.backend.composer.service.importing;
|
package com.bivashy.backend.composer.service.importing;
|
||||||
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
import java.util.function.Consumer;
|
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.springframework.data.redis.connection.Message;
|
import org.springframework.data.redis.connection.Message;
|
||||||
import org.springframework.data.redis.connection.MessageListener;
|
import org.springframework.data.redis.connection.MessageListener;
|
||||||
import org.springframework.data.redis.listener.ChannelTopic;
|
import org.springframework.data.redis.listener.ChannelTopic;
|
||||||
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
|
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import com.bivashy.backend.composer.dto.importing.BaseTrackProgress;
|
|
||||||
import com.bivashy.backend.composer.dto.importing.ImportTrackKey;
|
import com.bivashy.backend.composer.dto.importing.ImportTrackKey;
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class RedisMessageSubscriber {
|
public class RedisMessageSubscriber {
|
||||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
|
||||||
private static final Logger logger = LoggerFactory.getLogger(Logger.class);
|
|
||||||
|
|
||||||
private final RedisMessageListenerContainer container;
|
private final RedisMessageListenerContainer container;
|
||||||
private final Map<String, Consumer<BaseTrackProgress>> subscriptions = new ConcurrentHashMap<>();
|
private final Map<String, Consumer<String>> subscriptions = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
public RedisMessageSubscriber(RedisMessageListenerContainer container) {
|
public RedisMessageSubscriber(RedisMessageListenerContainer container) {
|
||||||
this.container = container;
|
this.container = container;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void subscribeToPlaylist(long playlistId, long userId, Consumer<BaseTrackProgress> messageHandler) {
|
public void subscribeToPlaylist(long playlistId, long userId, Consumer<String> messageHandler) {
|
||||||
String channel = ImportTrackKey.redisChannelKey(playlistId, userId);
|
String channel = ImportTrackKey.redisChannelKey(playlistId, userId);
|
||||||
String subscriptionKey = ImportTrackKey.subscriptionKey(playlistId, userId);
|
String subscriptionKey = ImportTrackKey.subscriptionKey(playlistId, userId);
|
||||||
|
|
||||||
@ -39,13 +32,7 @@ public class RedisMessageSubscriber {
|
|||||||
public void onMessage(Message message, byte[] pattern) {
|
public void onMessage(Message message, byte[] pattern) {
|
||||||
String receivedMessage = new String(message.getBody());
|
String receivedMessage = new String(message.getBody());
|
||||||
if (subscriptions.containsKey(subscriptionKey)) {
|
if (subscriptions.containsKey(subscriptionKey)) {
|
||||||
try {
|
messageHandler.accept(receivedMessage);
|
||||||
BaseTrackProgress progress = OBJECT_MAPPER.readValue(receivedMessage,
|
|
||||||
BaseTrackProgress.class);
|
|
||||||
messageHandler.accept(progress);
|
|
||||||
} catch (JsonProcessingException e) {
|
|
||||||
logger.error("cannot deserialize message into BaseTrackProgress.class", e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, new ChannelTopic(channel));
|
}, new ChannelTopic(channel));
|
||||||
|
|||||||
@ -1,22 +1,14 @@
|
|||||||
package com.bivashy.backend.composer.service.importing;
|
package com.bivashy.backend.composer.service.importing;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import com.bivashy.backend.composer.dto.importing.BaseTrackProgress;
|
||||||
import java.util.Comparator;
|
import com.bivashy.backend.composer.dto.importing.ImportTrackKey;
|
||||||
import java.util.List;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
import java.util.function.Consumer;
|
|
||||||
|
|
||||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import com.bivashy.backend.composer.dto.importing.BaseTrackProgress;
|
import java.util.*;
|
||||||
import com.bivashy.backend.composer.dto.importing.ImportTrackKey;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import com.fasterxml.jackson.databind.JavaType;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class RedisProgressService {
|
public class RedisProgressService {
|
||||||
@ -35,11 +27,11 @@ public class RedisProgressService {
|
|||||||
String key = ImportTrackKey.progressKey(progress.getPlaylistId(), progress.getUserId());
|
String key = ImportTrackKey.progressKey(progress.getPlaylistId(), progress.getUserId());
|
||||||
String trackKey = ImportTrackKey.trackKey(
|
String trackKey = ImportTrackKey.trackKey(
|
||||||
progress.getPlaylistId(),
|
progress.getPlaylistId(),
|
||||||
progress.getTrackSourceId(),
|
progress.getTrackId(),
|
||||||
progress.getUserId());
|
progress.getUserId());
|
||||||
|
|
||||||
String progressJson = objectMapper.writeValueAsString(progress);
|
String progressJson = objectMapper.writeValueAsString(progress);
|
||||||
redisTemplate.opsForHash().put(key, Long.toString(progress.getTrackSourceId()), progressJson);
|
redisTemplate.opsForHash().put(key, Long.toString(progress.getTrackId()), progressJson);
|
||||||
|
|
||||||
redisTemplate.opsForValue().set(trackKey, progressJson);
|
redisTemplate.opsForValue().set(trackKey, progressJson);
|
||||||
|
|
||||||
@ -52,38 +44,6 @@ public class RedisProgressService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public <T extends BaseTrackProgress> void updateTrackProgressField(long playlistId, long trackSourceId, long userId,
|
|
||||||
Consumer<T> updater) {
|
|
||||||
try {
|
|
||||||
String trackKey = ImportTrackKey.trackKey(playlistId, trackSourceId, userId);
|
|
||||||
String hashKey = ImportTrackKey.progressKey(playlistId, userId);
|
|
||||||
|
|
||||||
String existingJson = redisTemplate.opsForValue().get(trackKey);
|
|
||||||
if (existingJson == null) {
|
|
||||||
throw new RuntimeException("Track progress not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
JavaType progressType = objectMapper.getTypeFactory()
|
|
||||||
.constructType(BaseTrackProgress.class);
|
|
||||||
|
|
||||||
T progress = objectMapper.readValue(existingJson, progressType);
|
|
||||||
|
|
||||||
updater.accept(progress);
|
|
||||||
|
|
||||||
String updatedJson = objectMapper.writeValueAsString(progress);
|
|
||||||
redisTemplate.opsForHash().put(hashKey, Long.toString(trackSourceId), updatedJson);
|
|
||||||
redisTemplate.opsForValue().set(trackKey, updatedJson);
|
|
||||||
|
|
||||||
redisTemplate.expire(hashKey, 24, TimeUnit.HOURS);
|
|
||||||
redisTemplate.expire(trackKey, 24, TimeUnit.HOURS);
|
|
||||||
|
|
||||||
publishProgressUpdate(progress);
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
throw new RuntimeException("Failed to update track progress", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<BaseTrackProgress> getPlaylistProgress(long playlistId, long userId) {
|
public List<BaseTrackProgress> getPlaylistProgress(long playlistId, long userId) {
|
||||||
try {
|
try {
|
||||||
String key = ImportTrackKey.progressKey(playlistId, userId);
|
String key = ImportTrackKey.progressKey(playlistId, userId);
|
||||||
@ -105,9 +65,9 @@ public class RedisProgressService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public BaseTrackProgress getTrackProgress(long playlistId, long trackSourceId, long userId) {
|
public BaseTrackProgress getTrackProgress(long playlistId, long trackId, long userId) {
|
||||||
try {
|
try {
|
||||||
String key = ImportTrackKey.trackKey(playlistId, trackSourceId, userId);
|
String key = ImportTrackKey.trackKey(playlistId, trackId, userId);
|
||||||
String progressJson = redisTemplate.opsForValue().get(key);
|
String progressJson = redisTemplate.opsForValue().get(key);
|
||||||
|
|
||||||
if (progressJson != null) {
|
if (progressJson != null) {
|
||||||
|
|||||||
@ -1,69 +1,80 @@
|
|||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS "users" (
|
||||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
"id" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
"name" varchar(500) NOT NULL,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
"created_at" timestamp NOT NULL DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
"updated_at" timestamp NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TYPE source_type_enum AS ENUM (
|
CREATE TABLE IF NOT EXISTS "source_type" (
|
||||||
'VIDEO', 'PLAYLIST', 'PLAYLIST_ITEM', 'FILE', 'URL'
|
"id" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
"name" varchar(500) NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS track_source (
|
CREATE TABLE IF NOT EXISTS "track_source" (
|
||||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
"id" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
source_url TEXT NOT NULL,
|
"source_url" varchar(500) NOT NULL,
|
||||||
source_type SOURCE_TYPE_ENUM NOT NULL,
|
"source_type_id" bigint NOT NULL,
|
||||||
last_fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
"last_fetched_at" timestamp NOT NULL DEFAULT NOW(),
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
"created_at" timestamp NOT NULL DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
"updated_at" timestamp NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT "fk_track_source_source_type_id"
|
||||||
|
FOREIGN KEY ("source_type_id") REFERENCES "source_type" ("id")
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS track_source_metadata (
|
CREATE TABLE IF NOT EXISTS "track" (
|
||||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
"id" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
source_id BIGINT NOT NULL UNIQUE REFERENCES track_source (
|
"source_id" bigint NOT NULL,
|
||||||
id
|
CONSTRAINT "fk_track_source_id"
|
||||||
) ON DELETE CASCADE,
|
FOREIGN KEY ("source_id") REFERENCES "track_source" ("id")
|
||||||
url TEXT NOT NULL
|
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS track (
|
CREATE TABLE IF NOT EXISTS "track_metadata" (
|
||||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
"id" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
source_id BIGINT NOT NULL REFERENCES track_source (id) ON DELETE RESTRICT
|
"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),
|
||||||
|
"duration_seconds" integer,
|
||||||
|
CONSTRAINT "fk_track_metadata_track_id"
|
||||||
|
FOREIGN KEY ("track_id") REFERENCES "track" ("id")
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS track_metadata (
|
CREATE TABLE IF NOT EXISTS "playlist" (
|
||||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
"id" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
track_id BIGINT NOT NULL REFERENCES track (id) ON DELETE CASCADE,
|
"owner_id" bigint NOT NULL,
|
||||||
title TEXT NOT NULL,
|
"title" varchar(500) NOT NULL,
|
||||||
file_name TEXT NOT NULL,
|
"created_at" timestamp NOT NULL DEFAULT NOW(),
|
||||||
audio_path TEXT NOT NULL,
|
"updated_at" timestamp NOT NULL DEFAULT NOW(),
|
||||||
artist TEXT,
|
CONSTRAINT "fk_playlist_owner_id"
|
||||||
thumbnail_path TEXT,
|
FOREIGN KEY ("owner_id") REFERENCES "users" ("id"),
|
||||||
duration_seconds INTEGER
|
CONSTRAINT "uq_playlist_owner_title"
|
||||||
|
UNIQUE ("owner_id", "title")
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS playlist (
|
CREATE TABLE IF NOT EXISTS "playlist_track" (
|
||||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
"playlist_id" bigint NOT NULL,
|
||||||
owner_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
"track_id" bigint NOT NULL,
|
||||||
title TEXT NOT NULL,
|
"order_index" numeric NOT NULL,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
CONSTRAINT "pk_playlist_track_new" PRIMARY KEY ("playlist_id", "track_id"),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
CONSTRAINT "fk_playlist_track_playlist_id_new"
|
||||||
UNIQUE (owner_id, title)
|
FOREIGN KEY ("playlist_id") REFERENCES "playlist" ("id"),
|
||||||
|
CONSTRAINT "fk_playlist_track_track_id_new"
|
||||||
|
FOREIGN KEY ("track_id") REFERENCES "track" ("id")
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS playlist_track (
|
CREATE TABLE IF NOT EXISTS "track_version" (
|
||||||
playlist_id BIGINT NOT NULL REFERENCES playlist (id) ON DELETE CASCADE,
|
"id" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
track_id BIGINT NOT NULL REFERENCES track (id) ON DELETE CASCADE,
|
"track_id" bigint NOT NULL,
|
||||||
order_index DECIMAL NOT NULL,
|
"metadata_id" bigint NOT NULL,
|
||||||
PRIMARY KEY (playlist_id, track_id)
|
"source_id" bigint NOT NULL,
|
||||||
|
"created_at" timestamp NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT "fk_track_version_track_id"
|
||||||
|
FOREIGN KEY ("track_id") REFERENCES "track" ("id"),
|
||||||
|
CONSTRAINT "fk_track_version_metadata_id"
|
||||||
|
FOREIGN KEY ("metadata_id") REFERENCES "track_metadata" ("id"),
|
||||||
|
CONSTRAINT "fk_track_version_source_id"
|
||||||
|
FOREIGN KEY ("source_id") REFERENCES "track_source" ("id")
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS track_version (
|
|
||||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
||||||
track_id BIGINT NOT NULL REFERENCES track (id) ON DELETE CASCADE,
|
|
||||||
metadata_id BIGINT NOT NULL REFERENCES track_metadata (
|
|
||||||
id
|
|
||||||
) ON DELETE CASCADE,
|
|
||||||
source_id BIGINT NOT NULL REFERENCES track_source (id) ON DELETE RESTRICT,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|||||||
9
src/main/resources/db/migration/V1_20__insert_enums.sql
Normal file
9
src/main/resources/db/migration/V1_20__insert_enums.sql
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
INSERT INTO "source_type" ("id", "name")
|
||||||
|
OVERRIDING SYSTEM VALUE
|
||||||
|
VALUES
|
||||||
|
(1, 'VIDEO'),
|
||||||
|
(2, 'PLAYLIST'),
|
||||||
|
(3, 'FILE'),
|
||||||
|
(4, 'URL')
|
||||||
|
ON CONFLICT ("id") DO NOTHING;
|
||||||
|
|
||||||
Reference in New Issue
Block a user