Compare commits

..

5 Commits

Author SHA1 Message Date
0b669c2a23 Fix includeProgressHistory in TrackController 2026-01-14 00:25:53 +05:00
9fb0ab3513 Add playlistTitle, bump yt-dlp-java version 2026-01-08 19:59:27 +05:00
f6b545d46d Fix stdout 2026-01-08 18:56:55 +05:00
3334d51e96 [Feature] Youtube importing and refreshing implementation (ytdlp) (#1)
Reviewed-on: #1
2026-01-07 22:56:37 +00:00
802e968522 Fix compose service name 2026-01-06 15:46:18 +05:00
26 changed files with 583 additions and 362 deletions

2
.gitignore vendored
View File

@ -33,3 +33,5 @@ build/
.vscode/ .vscode/
.env .env
.sqruff

View File

@ -1,5 +1,5 @@
services: services:
hls-proxy: composer_backend:
build: build:
context: . context: .
dockerfile: Dockerfile.dev dockerfile: Dockerfile.dev

View File

@ -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.6</yt-dlp-java.version> <yt-dlp-java.version>2.0.8</yt-dlp-java.version>
<record-builder.version>51</record-builder.version> <record-builder.version>51</record-builder.version>
</properties> </properties>
<repositories> <repositories>
@ -121,6 +121,11 @@
<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>

View File

@ -38,11 +38,21 @@ 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,

View File

@ -4,8 +4,9 @@ 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;
@ -16,7 +17,6 @@ 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,10 +24,11 @@ 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<String>> sinks = new ConcurrentHashMap<>(); private final Map<String, Sinks.Many<BaseTrackProgress>> sinks = new ConcurrentHashMap<>();
public ProgressSSEController(RedisProgressService redisProgressService, public ProgressSSEController(RedisProgressService redisProgressService,
RedisMessageSubscriber redisSubscriber) { RedisMessageSubscriber redisSubscriber) {
@ -36,7 +37,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<ServerSentEvent<String>> streamProgress( public Flux<BaseTrackProgress> streamProgress(
@PathVariable long playlistId, @PathVariable long playlistId,
@AuthenticationPrincipal CustomUserDetails user, @AuthenticationPrincipal CustomUserDetails user,
HttpServletResponse response) { HttpServletResponse response) {
@ -48,8 +49,8 @@ public class ProgressSSEController {
String connectionKey = ImportTrackKey.subscriptionKey(playlistId, userId); String connectionKey = ImportTrackKey.subscriptionKey(playlistId, userId);
Sinks.Many<String> sink = sinks.computeIfAbsent(connectionKey, k -> { Sinks.Many<BaseTrackProgress> sink = sinks.computeIfAbsent(connectionKey, k -> {
Sinks.Many<String> newSink = Sinks.many().replay().latest(); Sinks.Many<BaseTrackProgress> newSink = Sinks.many().replay().latest();
redisSubscriber.subscribeToPlaylist(playlistId, userId, message -> { redisSubscriber.subscribeToPlaylist(playlistId, userId, message -> {
newSink.tryEmitNext(message); newSink.tryEmitNext(message);
@ -61,19 +62,14 @@ 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(mapper.writeValueAsString(progress)); sink.tryEmitNext(progress);
} }
} catch (Exception e) { } catch (Exception e) {
e.printStackTrace(); e.printStackTrace();
@ -92,7 +88,7 @@ public class ProgressSSEController {
} }
private void cleanupConnection(Long playlistId, long userId, private void cleanupConnection(Long playlistId, long userId,
Sinks.Many<String> sink, String connectionKey) { Sinks.Many<BaseTrackProgress> sink, String connectionKey) {
try { try {
redisProgressService.removeActiveConnection(playlistId, userId); redisProgressService.removeActiveConnection(playlistId, userId);
redisSubscriber.unsubscribeFromPlaylist(playlistId, userId); redisSubscriber.unsubscribeFromPlaylist(playlistId, userId);

View File

@ -1,5 +1,7 @@
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;
@ -9,21 +11,27 @@ 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 trackId; protected long trackSourceId;
protected long userId; protected long userId;
protected long timestamp; protected long timestamp;
private String type; private ProgressEntryType type;
public BaseTrackProgress(long playlistId, long trackId, long userId) { public BaseTrackProgress(long playlistId, long trackSourceId, long userId) {
this.id = UUID.randomUUID();
this.playlistId = playlistId; this.playlistId = playlistId;
this.trackId = trackId; this.trackSourceId = trackSourceId;
this.userId = userId; this.userId = userId;
this.timestamp = System.currentTimeMillis(); this.timestamp = System.currentTimeMillis();
} }
public Long getTimestamp() { public UUID getId() {
return id;
}
public long getTimestamp() {
return timestamp; return timestamp;
} }
@ -31,7 +39,7 @@ public abstract class BaseTrackProgress {
return userId; return userId;
} }
public String getType() { public ProgressEntryType getType() {
return type; return type;
} }
@ -39,12 +47,12 @@ public abstract class BaseTrackProgress {
return playlistId; return playlistId;
} }
public long getTrackId() { public long getTrackSourceId() {
return trackId; return trackSourceId;
} }
protected void setType(ProgressEntryType type) { protected void setType(ProgressEntryType type) {
this.type = type.name(); this.type = type;
} }
} }

View File

@ -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 trackId, long userId) { public static String trackKey(long playlistId, long trackSourceId, long userId) {
return String.format("track:%d:%d:%d", userId, playlistId, trackId); return String.format("track:%d:%d:%d", userId, playlistId, trackSourceId);
} }
public static String redisChannelKey(long playlistId, long userId) { public static String redisChannelKey(long playlistId, long userId) {

View File

@ -3,12 +3,20 @@ 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 String status; private int trackCount;
private String playlistTitle;
private ProgressStatus status;
public PlaylistProgress(long playlistId, long trackId, long userId) { PlaylistProgress() {
super(playlistId, trackId, userId); super(0, 0, 0);
}
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 = "LOADING"; this.status = ProgressStatus.LOADING;
this.trackCount = trackCount;
this.playlistTitle = playlistTitle;
} }
public String getYtdlnStdout() { public String getYtdlnStdout() {
@ -27,11 +35,20 @@ public class PlaylistProgress extends BaseTrackProgress {
this.overallProgress = overallProgress; this.overallProgress = overallProgress;
} }
public String getStatus() { public ProgressStatus getStatus() {
return status; return status;
} }
public void setStatus(String status) { public void setStatus(ProgressStatus status) {
this.status = status; this.status = status;
} }
public int getTrackCount() {
return trackCount;
}
public String getPlaylistTitle() {
return playlistTitle;
}
} }

View File

@ -0,0 +1,5 @@
package com.bivashy.backend.composer.dto.importing;
public enum ProgressStatus {
LOADING, FINISHED
}

View File

@ -4,8 +4,12 @@ public class SingleTrackProgress extends BaseTrackProgress {
private String title; private String title;
private String format; private String format;
public SingleTrackProgress(long playlistId, long trackId, long userId, String title, String format) { SingleTrackProgress() {
super(playlistId, trackId, userId); super(0, 0, 0);
}
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;

View File

@ -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, length = 500) @Column(unique = true, nullable = false)
private String title; private String title;
@Column(name = "created_at", nullable = false, updatable = false) @Column(name = "created_at", nullable = false, updatable = false)

View File

@ -0,0 +1,5 @@
package com.bivashy.backend.composer.model;
public enum SourceMetadataType {
YOUTUBE
}

View File

@ -1,47 +1,5 @@
package com.bivashy.backend.composer.model; package com.bivashy.backend.composer.model;
import java.util.HashSet; public enum SourceType {
import java.util.Set; VIDEO, PLAYLIST, PLAYLIST_ITEM, FILE, URL
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;
}
} }

View File

@ -1,8 +0,0 @@
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";
}

View File

@ -4,15 +4,16 @@ 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.FetchType; import jakarta.persistence.Enumerated;
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;
@ -23,11 +24,12 @@ public class TrackSource {
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; private Long id;
@Column(name = "source_url", nullable = false, length = 500) @Column(name = "source_url", nullable = false)
private String sourceUrl; private String sourceUrl;
@ManyToOne(fetch = FetchType.LAZY) @Enumerated
@JoinColumn(name = "source_type_id", nullable = false) @Column(name = "source_type", 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)

View File

@ -0,0 +1,47 @@
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;
}
}

View File

@ -20,7 +20,7 @@ public class User {
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; private Long id;
@Column(nullable = false, length = 500) @Column(nullable = false)
private String name; private String name;
@Column(name = "created_at", nullable = false, updatable = false) @Column(name = "created_at", nullable = false, updatable = false)

View File

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

View File

@ -0,0 +1,18 @@
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);
}

View File

@ -1,14 +1,13 @@
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;
@ -25,50 +24,42 @@ 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.SourceTypes; import com.bivashy.backend.composer.model.SourceType;
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 AudioS3StorageService s3StorageService; private final YoutubeTrackService youtubeTrackService;
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,
AudioS3StorageService s3StorageService) { YoutubeTrackService youtubeTrackService) {
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.s3StorageService = s3StorageService; this.youtubeTrackService = youtubeTrackService;
} }
public TrackResponse addLocalTrack(CustomUserDetails user, public TrackResponse addLocalTrack(CustomUserDetails user,
@ -87,7 +78,7 @@ public class TrackService {
TrackSource trackSource; TrackSource trackSource;
try { try {
trackSource = trackSourceService.createLocalTrackSource( trackSource = trackSourceService.createLocalTrackSource(
request.body(), ffprobeJson, params.ytdlpMetadata(), SourceTypes.FILE); request.body(), ffprobeJson, params.ytdlpMetadata(), SourceType.FILE);
} catch (IOException e) { } catch (IOException e) {
throw new ImportTrackException("cannot read blob body", e); throw new ImportTrackException("cannot read blob body", e);
} }
@ -113,7 +104,8 @@ public class TrackService {
if (params.includeProgressHistory()) { if (params.includeProgressHistory()) {
redisProgressService redisProgressService
.saveProgress(new SingleTrackProgress(playlistId, track.getId(), user.getId(), title, fileFormat)); .saveProgress(
new SingleTrackProgress(playlistId, trackSource.getId(), user.getId(), title, fileFormat));
} }
return new TrackResponse( return new TrackResponse(
@ -126,6 +118,12 @@ 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 {
@ -145,34 +143,17 @@ 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
TrackResponse result = null; var params = youtubeTrackService.downloadYoutubeTrack(temporaryFolder, videoInfo,
request.youtubeUrl());
TrackResponse result = addLocalTrack(user, playlistId, params);
try (Stream<Path> pathStream = Files.walk(temporaryFolder)) { try (Stream<Path> pathStream = Files.walk(temporaryFolder)) {
List<Path> downloadedFiles = Files.walk(temporaryFolder).toList(); pathStream.sorted(Comparator.reverseOrder())
.map(Path::toFile)
if (downloadedFiles.isEmpty()) .forEach(File::delete);
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);
@ -181,116 +162,10 @@ public class TrackService {
} }
} }
TrackSource trackSource = trackSourceService.createYoutubeTrackSource(SourceTypes.PLAYLIST); TrackSource trackSource = trackSourceService.createYoutubeTrackSource(SourceType.PLAYLIST,
return refreshYoutubePlaylist(playlistId, trackSource, videoInfos, request.youtubeUrl()); 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) {

View File

@ -3,12 +3,14 @@ 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.repository.SourceTypeRepository; import com.bivashy.backend.composer.model.TrackSourceMetadata;
import com.bivashy.backend.composer.repository.TrackSourceMetadataRepository;
import com.bivashy.backend.composer.repository.TrackSourceRepository; import com.bivashy.backend.composer.repository.TrackSourceRepository;
@Service @Service
@ -17,49 +19,50 @@ 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 SourceTypeRepository sourceTypeRepository; private final TrackSourceMetadataRepository trackSourceMetadataRepository;
private final AudioBlobStorageService s3Service; private final AudioBlobStorageService s3Service;
public TrackSourceService(TrackSourceRepository trackSourceRepository, public TrackSourceService(TrackSourceRepository trackSourceRepository,
SourceTypeRepository sourceTypeRepository, TrackSourceMetadataRepository trackSourceMetadataRepository, AudioBlobStorageService s3Service) {
AudioBlobStorageService s3Service) {
this.trackSourceRepository = trackSourceRepository; this.trackSourceRepository = trackSourceRepository;
this.sourceTypeRepository = sourceTypeRepository; this.trackSourceMetadataRepository = trackSourceMetadataRepository;
this.s3Service = s3Service; this.s3Service = s3Service;
} }
public TrackSource createLocalTrackSource(byte[] audioBytes, public TrackSource createLocalTrackSource(byte[] audioBytes,
String ffprobeJson, String ffprobeJson,
String ytdlpMetadata, String ytdlpMetadata,
String sourceType) { SourceType 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);
SourceType type = sourceTypeRepository.findByName(sourceType) return trackSourceRepository.save(new TrackSource(audioPath, sourceType, LocalDateTime.now()));
.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, String sourceType) { String ytdlpMetadata, SourceType 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);
SourceType type = sourceTypeRepository.findByName(sourceType) return trackSourceRepository.save(new TrackSource(audioPath, sourceType, LocalDateTime.now()));
.orElseThrow(() -> new IllegalStateException("Source type not found: " + sourceType));
return trackSourceRepository.save(new TrackSource(audioPath, type, LocalDateTime.now()));
} }
public TrackSource createYoutubeTrackSource(String sourceType) { public TrackSource createYoutubeTrackSource(SourceType sourceType, String youtubeUrl) {
String folderPath = s3Service.storeFolder(); String folderPath = s3Service.storeFolder();
SourceType type = sourceTypeRepository.findByName(sourceType) TrackSource trackSource = trackSourceRepository
.orElseThrow(() -> new IllegalStateException("Source type not found: " + sourceType)); .save(new TrackSource(folderPath, sourceType, LocalDateTime.now()));
return trackSourceRepository.save(new TrackSource(folderPath, type, LocalDateTime.now())); trackSourceMetadataRepository.save(new TrackSourceMetadata(trackSource, youtubeUrl));
return trackSource;
} }
public Optional<TrackSourceMetadata> findWithMetadata(long sourceId) {
return trackSourceMetadataRepository.findBySourceIdWithSource(sourceId);
}
} }

View File

@ -0,0 +1,252 @@
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("[.][^.]+$", "");
}
}

View File

@ -1,28 +1,35 @@
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 java.util.Map; import com.fasterxml.jackson.databind.ObjectMapper;
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<String>> subscriptions = new ConcurrentHashMap<>(); private final Map<String, Consumer<BaseTrackProgress>> 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<String> messageHandler) { public void subscribeToPlaylist(long playlistId, long userId, Consumer<BaseTrackProgress> 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);
@ -32,7 +39,13 @@ 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)) {
messageHandler.accept(receivedMessage); try {
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));

View File

@ -1,14 +1,22 @@
package com.bivashy.backend.composer.service.importing; package com.bivashy.backend.composer.service.importing;
import com.bivashy.backend.composer.dto.importing.BaseTrackProgress; import java.util.ArrayList;
import com.bivashy.backend.composer.dto.importing.ImportTrackKey; import java.util.Comparator;
import com.fasterxml.jackson.databind.ObjectMapper; import java.util.List;
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 java.util.*; import com.bivashy.backend.composer.dto.importing.BaseTrackProgress;
import java.util.concurrent.ConcurrentHashMap; import com.bivashy.backend.composer.dto.importing.ImportTrackKey;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.ObjectMapper;
@Service @Service
public class RedisProgressService { public class RedisProgressService {
@ -27,11 +35,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.getTrackId(), progress.getTrackSourceId(),
progress.getUserId()); progress.getUserId());
String progressJson = objectMapper.writeValueAsString(progress); String progressJson = objectMapper.writeValueAsString(progress);
redisTemplate.opsForHash().put(key, Long.toString(progress.getTrackId()), progressJson); redisTemplate.opsForHash().put(key, Long.toString(progress.getTrackSourceId()), progressJson);
redisTemplate.opsForValue().set(trackKey, progressJson); redisTemplate.opsForValue().set(trackKey, progressJson);
@ -44,6 +52,38 @@ 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);
@ -65,9 +105,9 @@ public class RedisProgressService {
} }
} }
public BaseTrackProgress getTrackProgress(long playlistId, long trackId, long userId) { public BaseTrackProgress getTrackProgress(long playlistId, long trackSourceId, long userId) {
try { try {
String key = ImportTrackKey.trackKey(playlistId, trackId, userId); String key = ImportTrackKey.trackKey(playlistId, trackSourceId, userId);
String progressJson = redisTemplate.opsForValue().get(key); String progressJson = redisTemplate.opsForValue().get(key);
if (progressJson != null) { if (progressJson != null) {

View File

@ -1,80 +1,69 @@
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" varchar(500) NOT NULL, name TEXT NOT NULL,
"created_at" timestamp NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
"updated_at" timestamp NOT NULL DEFAULT NOW() updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );
CREATE TABLE IF NOT EXISTS "source_type" ( CREATE TYPE source_type_enum AS ENUM (
"id" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, 'VIDEO', 'PLAYLIST', 'PLAYLIST_ITEM', 'FILE', 'URL'
"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" varchar(500) NOT NULL, source_url TEXT NOT NULL,
"source_type_id" bigint NOT NULL, source_type SOURCE_TYPE_ENUM NOT NULL,
"last_fetched_at" timestamp NOT NULL DEFAULT NOW(), last_fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
"created_at" timestamp NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
"updated_at" timestamp NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ 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" ( CREATE TABLE IF NOT EXISTS track_source_metadata (
"id" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
"source_id" bigint NOT NULL, source_id BIGINT NOT NULL UNIQUE REFERENCES track_source (
CONSTRAINT "fk_track_source_id" id
FOREIGN KEY ("source_id") REFERENCES "track_source" ("id") ) ON DELETE CASCADE,
url TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS "track_metadata" ( CREATE TABLE IF NOT EXISTS track (
"id" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
"track_id" bigint NOT NULL, source_id BIGINT NOT NULL REFERENCES track_source (id) ON DELETE RESTRICT
"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 "playlist" ( CREATE TABLE IF NOT EXISTS track_metadata (
"id" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
"owner_id" bigint NOT NULL, track_id BIGINT NOT NULL REFERENCES track (id) ON DELETE CASCADE,
"title" varchar(500) NOT NULL, title TEXT NOT NULL,
"created_at" timestamp NOT NULL DEFAULT NOW(), file_name TEXT NOT NULL,
"updated_at" timestamp NOT NULL DEFAULT NOW(), audio_path TEXT NOT NULL,
CONSTRAINT "fk_playlist_owner_id" artist TEXT,
FOREIGN KEY ("owner_id") REFERENCES "users" ("id"), thumbnail_path TEXT,
CONSTRAINT "uq_playlist_owner_title" duration_seconds INTEGER
UNIQUE ("owner_id", "title")
); );
CREATE TABLE IF NOT EXISTS "playlist_track" ( CREATE TABLE IF NOT EXISTS playlist (
"playlist_id" bigint NOT NULL, id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
"track_id" bigint NOT NULL, owner_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
"order_index" numeric NOT NULL, title TEXT NOT NULL,
CONSTRAINT "pk_playlist_track_new" PRIMARY KEY ("playlist_id", "track_id"), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT "fk_playlist_track_playlist_id_new" updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
FOREIGN KEY ("playlist_id") REFERENCES "playlist" ("id"), UNIQUE (owner_id, title)
CONSTRAINT "fk_playlist_track_track_id_new"
FOREIGN KEY ("track_id") REFERENCES "track" ("id")
); );
CREATE TABLE IF NOT EXISTS "track_version" ( CREATE TABLE IF NOT EXISTS playlist_track (
"id" bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, playlist_id BIGINT NOT NULL REFERENCES playlist (id) ON DELETE CASCADE,
"track_id" bigint NOT NULL, track_id BIGINT NOT NULL REFERENCES track (id) ON DELETE CASCADE,
"metadata_id" bigint NOT NULL, order_index DECIMAL NOT NULL,
"source_id" bigint NOT NULL, PRIMARY KEY (playlist_id, track_id)
"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()
);

View File

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