refactor(media): consolidate upload/edit/download into MediaService
Merge UploadService, EditService, JobOrchestrationService, and
DownloadService into a single MediaService with a unified Job lifecycle
(READY → PROCESSING → SUCCEEDED/FAILED via JobState enum).
- Add MediaController (/api/v1/media) exposing compress, save, and clip
- Add JobController (/api/v1/job) for status polling and file download
- Move clip/thumbnail download into ClipController (/{id}/media, /{id}/thumbnail)
- Move stream saveSection/clip from StreamService into MediaService
- Simplify Job DTO: remove dual ClipOptions/files, add ProgressTracker and download file
- Refactor JobRegistryService.add() → generateJob() to co-locate creation and registration
- Wire FFmpeg out_time_ms progress parsing into StreamActionsService via CommandRunner
- Rename GET /api/v1/auth/user → GET /api/v1/user/me
- Delete DownloadController, MetadataController, UploadController
This commit is contained in:
@@ -6,45 +6,28 @@ import com.ddf.vodsystem.exceptions.AlreadyStreaming;
|
||||
import com.ddf.vodsystem.exceptions.KeyNotFound;
|
||||
import com.ddf.vodsystem.exceptions.NotAuthenticated;
|
||||
import com.ddf.vodsystem.repositories.StreamRepository;
|
||||
import com.ddf.vodsystem.services.media.StreamActionsService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class StreamService {
|
||||
private static final Logger logger = LoggerFactory.getLogger(StreamService.class);
|
||||
|
||||
private static final int HEARTBEAT_TIMEOUT_SECONDS = 60;
|
||||
private static final float CLIP_MAX_LENGTH = 180;
|
||||
private static final int HLS_SEGMENT_LENGTH = 3;
|
||||
|
||||
private final StreamRepository streamRepository;
|
||||
private final UserService userService;
|
||||
private final StreamActionsService streamActionsService;
|
||||
private final DirectoryService directoryService;
|
||||
private final ClipService clipService;
|
||||
|
||||
public StreamService(StreamRepository streamRepository,
|
||||
UserService userService,
|
||||
StreamActionsService streamActionsService,
|
||||
DirectoryService directoryService,
|
||||
ClipService clipService) {
|
||||
UserService userService) {
|
||||
this.streamRepository = streamRepository;
|
||||
this.userService = userService;
|
||||
this.streamActionsService = streamActionsService;
|
||||
this.directoryService = directoryService;
|
||||
this.clipService = clipService;
|
||||
}
|
||||
|
||||
public Stream startStream(String streamKey) {
|
||||
@@ -111,92 +94,6 @@ public class StreamService {
|
||||
return streamRepository.findByUser(streamUser);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a clip of the currently authenticated user's stream between two points in time.
|
||||
* <p>
|
||||
* Locates the recorded {@code .ts} segments overlapping the requested window, computes the
|
||||
* trim offset into the first (partially-overlapping) segment and the total clip duration,
|
||||
* then delegates the actual extraction to {@link StreamActionsService#saveSection}.
|
||||
*
|
||||
* @param startTime the inclusive start of the section to save; must be before {@code endTime}
|
||||
* @param endTime the exclusive end of the section to save
|
||||
* @throws IllegalArgumentException if {@code startTime} is not before {@code endTime},
|
||||
* or if no stream segments exist in the given range
|
||||
* @throws NotAuthenticated if no user is currently authenticated
|
||||
* @throws IOException if reading the stream directory or its segments fails
|
||||
*/
|
||||
public void saveSection(Instant startTime, Instant endTime) throws IOException {
|
||||
if (startTime.isAfter(endTime)) {
|
||||
throw new IllegalArgumentException("Start time must be before end time");
|
||||
}
|
||||
|
||||
User user = userService.getLoggedInUser()
|
||||
.orElseThrow(() -> new NotAuthenticated("User is not authenticated"));
|
||||
|
||||
File streamDirectory = directoryService.getStreamDir(user.getStreamKey());
|
||||
List<File> fileSegments = getSegmentsInRange(streamDirectory.getAbsolutePath(), startTime, endTime);
|
||||
|
||||
if (fileSegments.isEmpty()) {
|
||||
throw new IllegalArgumentException("No stream segments found in the given time range");
|
||||
}
|
||||
|
||||
long firstSegmentMs = parseTimestampMs(fileSegments.getFirst());
|
||||
float trimOffset = Math.max(0f, (startTime.toEpochMilli() - firstSegmentMs) / 1000f);
|
||||
float duration = (endTime.toEpochMilli() - startTime.toEpochMilli()) / 1000f;
|
||||
|
||||
File outputFile = directoryService.getVodFile(user.getId(), UUID.randomUUID() + ".mp4");
|
||||
streamActionsService.saveSection(fileSegments, trimOffset, duration, outputFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a clip of the last {@code duration} seconds of the currently authenticated user's stream.
|
||||
* <p>
|
||||
* Computes a time window ending at the current instant and beginning {@code duration} seconds
|
||||
* earlier (with millisecond precision), then delegates to {@link #saveSection(Instant, Instant)}.
|
||||
*
|
||||
* @param duration the length of the clip in seconds, measured back from now; must be greater
|
||||
* than {@code 0} and no greater than {@link #CLIP_MAX_LENGTH}. Fractional values
|
||||
* are supported and resolved to the millisecond.
|
||||
* @throws IllegalArgumentException if {@code duration} is not in the range {@code (0, CLIP_MAX_LENGTH]},
|
||||
* or if no stream segments exist in the computed window
|
||||
* @throws NotAuthenticated if no user is currently authenticated
|
||||
* @throws IOException if reading the stream directory or its segments fails
|
||||
*/
|
||||
public void clip(float duration) throws IOException {
|
||||
if (duration <= 0 || duration > CLIP_MAX_LENGTH) {
|
||||
throw new IllegalArgumentException("Clip length must be between 0 and " + CLIP_MAX_LENGTH + " seconds");
|
||||
}
|
||||
|
||||
// minusSeconds() is possible, but only does integer seconds, not float
|
||||
Instant endTime = Instant.now();
|
||||
Instant startTime = endTime.minus(Duration.ofMillis((long) (duration * 1000)));
|
||||
|
||||
User user = userService.getLoggedInUser()
|
||||
.orElseThrow(() -> new NotAuthenticated("User is not authenticated"));
|
||||
|
||||
File streamDirectory = directoryService.getStreamDir(user.getStreamKey());
|
||||
List<File> fileSegments = getSegmentsInRange(streamDirectory.getAbsolutePath(), startTime, endTime);
|
||||
|
||||
if (fileSegments.isEmpty()) {
|
||||
throw new IllegalArgumentException("No stream segments found in the given time range");
|
||||
}
|
||||
|
||||
long firstSegmentMs = parseTimestampMs(fileSegments.getFirst());
|
||||
float trimOffset = Math.max(0f, (startTime.toEpochMilli() - firstSegmentMs) / 1000f);
|
||||
|
||||
File outputFile = directoryService.getTempOutputFile(UUID.randomUUID() + ".mp4");
|
||||
streamActionsService.saveSection(fileSegments, trimOffset, duration, outputFile)
|
||||
.thenRun(() ->
|
||||
clipService.persistClip(
|
||||
Instant.now().toString(),
|
||||
"",
|
||||
user,
|
||||
outputFile,
|
||||
outputFile.getName()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelay = 30_000)
|
||||
@Transactional
|
||||
public void endStaleStreams() {
|
||||
@@ -216,28 +113,4 @@ public class StreamService {
|
||||
return userService.getUserByStreamKey(streamKey)
|
||||
.orElseThrow(() -> new KeyNotFound("Stream key not found: " + streamKey));
|
||||
}
|
||||
|
||||
private List<File> getSegmentsInRange(String streamDirectory, Instant startTime, Instant endTime) {
|
||||
File dir = new File(streamDirectory);
|
||||
File[] tsFiles = dir.listFiles((d, name) -> name.endsWith(".ts"));
|
||||
if (tsFiles == null) return List.of();
|
||||
|
||||
long startMs = startTime.toEpochMilli();
|
||||
long endMs = endTime.toEpochMilli();
|
||||
|
||||
return Arrays.stream(tsFiles)
|
||||
.filter(f -> {
|
||||
long segMs = parseTimestampMs(f);
|
||||
return segMs < endMs && (segMs + HLS_SEGMENT_LENGTH * 1000) > startMs;
|
||||
})
|
||||
.sorted(Comparator.comparingLong(this::parseTimestampMs))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private long parseTimestampMs(File file) {
|
||||
String name = file.getName().replace(".ts", "");
|
||||
long value = Long.parseLong(name);
|
||||
// nginx hls_fragment_naming system uses seconds; convert to ms
|
||||
return value < 1_000_000_000_000L ? value * 1000L : value;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user