logo
All projects
2024 · Active

video-streaming

Adaptive video streaming platform in Java. Multipart upload to MinIO, FFmpeg transcoding to four HLS quality tiers, hot/cold storage promotion, and segment delivery via presigned redirects.

JavaSpring BootFFmpegMinIOPostgreSQL
View on GitHub
Overview

video-streaming (SyncByte) is a backend platform for ingesting large video files, transcoding them into adaptive HLS streams, and serving them efficiently. The upload flow avoids routing video bytes through the application server: clients get presigned MinIO PUT URLs per chunk, upload directly to object storage, and acknowledge each chunk's ETag back to the API. Once all chunks are confirmed, the server assembles them with MinIO's compose API and queues a transcode job. The transcoder runs FFmpeg asynchronously to produce four video quality variants and a separate audio track, each split into 6-second HLS segments. Manifests and segments live in MinIO (cold), and a promotion service moves hot content to local disk for low-latency repeated access.

Key features
01Multipart upload bypasses the app server entirely. Clients request presigned PUT URLs per chunk, upload directly to MinIO, then acknowledge each chunk's ETag. The server tracks chunk status in PostgreSQL and assembles the final object via MinIO's composeObject API.
02FFmpeg transcoding to four quality presets (360p at 800 kbps, 480p at 1.4 Mbps, 720p at 2.8 Mbps, 1080p at 5 Mbps) plus a separate AAC audio track. Each variant produces 6-second .ts segments and an HLS playlist. The master manifest is updated after each quality completes so playback can start at 360p before higher tiers are ready.
03Hot/cold storage tiering. HLS segments are mastered in MinIO (cold). PromotionService fetches the entire HLS tree from MinIO and writes it to a local disk path (hot) asynchronously when a lesson is accessed. HotStorageService serves files from disk with FileSystemResource — no object storage round-trip for warm content.
04StreamController serves manifests directly from MinIO and redirects .ts segment requests to presigned GET URLs. The client (or CDN) fetches segments directly from MinIO, keeping the API server out of the media byte path entirely.
05Upload resumability built in. getUploadStatus returns which part numbers are UPLOADED vs PENDING, so clients can skip already-completed chunks on retry. abortMultipartUpload cleans up both the chunk records in PostgreSQL and the partial objects in MinIO.

Multipart Upload

Video files are too large to upload through the application server. The upload flow has four steps: initiate, presign parts, acknowledge chunks, complete. Initiating creates an UploadSession in PostgreSQL and a VideoChunk record per part (10 MB each). Presigning generates one PUT URL per requested part number directly to MinIO. Each acknowledgment records the chunk's ETag and flips its status to UPLOADED.

On completion, the server verifies all chunks are acknowledged, then calls MinIO's composeObject to assemble the ordered chunks into the final object in one atomic operation. After assembly, statObject verifies the assembled size matches the expected total. If sizes match, chunk objects are deleted and a transcode job is queued.

MediaService.java
public CompleteMultipartResponse completeMultipartUpload(
        CompleteMultipartRequest request, User user) {

    List<VideoChunk> chunks = videoChunkRepository
            .findByUploadIdOrderByPartNumberAsc(request.uploadId());

    boolean allUploaded = chunks.stream()
            .allMatch(c -> c.getStatus() == ChunkStatus.UPLOADED);
    if (!allUploaded)
        throw new AppException("Not all chunks acknowledged", BAD_REQUEST);

    // Assemble in MinIO — one atomic compose from ordered chunk objects
    List<ComposeSource> sources = chunks.stream()
            .map(c -> ComposeSource.builder()
                    .bucket(session.getBucket())
                    .object(buildChunkKey(session.getObjectKey(),
                                         session.getUploadId(), c.getPartNumber()))
                    .build())
            .collect(Collectors.toList());
    minioClient.composeObject(ComposeObjectArgs.builder()
            .bucket(session.getBucket())
            .object(session.getObjectKey())
            .sources(sources).build());

    transcodeService.queueJob(lesson.getId(),
            lesson.getCourse().getId(), session.getObjectKey());
}

HLS Transcoding

TranscodeService runs asynchronously on a dedicated thread pool. It downloads the raw video from MinIO to a temp directory, then runs FFmpeg once per quality preset and once for the audio track. Video variants use libx264 with a fixed bitrate ceiling and buffer size; the audio track is extracted separately as AAC at 128 kbps. Each pass produces 6-second .ts segments and an HLS playlist.

The master manifest is written and uploaded to MinIO after each quality completes — not only at the end. This means a player can start streaming at 360p within minutes of upload completion while 720p and 1080p are still encoding. Lesson status is set to READY as soon as the first quality (360p) is available. If a quality fails, its partial segments are deleted and the manifest continues without it. The job retries up to three times before being marked permanently failed.

TranscodeService.java
private static final List<QualityPreset> VIDEO_PRESETS = List.of(
    new QualityPreset("360p",   640,  360,  "800k",  "1600k",   800_000),
    new QualityPreset("480p",   842,  480,  "1400k", "2800k", 1_400_000),
    new QualityPreset("720p",  1280,  720,  "2800k", "5600k", 2_800_000),
    new QualityPreset("1080p", 1920, 1080,  "5000k","10000k", 5_000_000)
);

List<String> readyQualities = new ArrayList<>();
for (QualityPreset preset : VIDEO_PRESETS) {
    boolean ready = transcodeVideoQuality(lessonId, inputPath, tempDir, hlsBase, preset);
    if (ready) {
        readyQualities.add(preset.name());
        uploadMasterManifest(tempDir, hlsBase, readyQualities, audioReady);
        if (readyQualities.size() == 1) {
            setLessonStatusAndHlsKey(lessonId, VideoStatus.READY, hlsBase);
        }
    }
}

Storage Tiering & Segment Delivery

After transcoding, HLS content lives in MinIO (cold tier). StreamController serves manifests by fetching bytes directly from MinIO and returning them in the response. Segment requests are handled differently: the controller generates a presigned GET URL for the .ts object and returns HTTP 302, letting the player or CDN fetch the segment bytes directly from MinIO without touching the API server.

PromotionService moves content to hot (local disk) asynchronously when a lesson is accessed. It lists all objects under the HLS prefix in MinIO, streams each one to disk via HotStorageService.writeStream, and updates the storage tier in PostgreSQL. On subsequent requests, HotStorageService.getResource returns a FileSystemResource from disk. DiskMonitorJob watches disk usage and triggers DemotionJob to evict cold-accessed content back to MinIO-only when the disk threshold is exceeded.

StreamController.java
// Manifests: fetch from MinIO, return inline
@GetMapping("/{lessonId}/master.m3u8")
public ResponseEntity<byte[]> masterManifest(@PathVariable UUID lessonId) {
    Lesson lesson = getReadyLesson(lessonId);
    byte[] content = fetchFromMinio(lesson.getHlsKey() + "/master.m3u8");
    return ResponseEntity.ok()
            .header(CACHE_CONTROL, "no-cache")
            .contentType(MediaType.parseMediaType("application/vnd.apple.mpegurl"))
            .body(content);
}

// Segments: redirect to presigned URL — API never touches the bytes
@GetMapping("/{lessonId}/{quality}/{segment}")
public ResponseEntity<Void> segment(...) {
    String objectKey = lesson.getHlsKey() + "/" + quality + "/" + segment;
    String presignedUrl = presignGet(objectKey);
    return ResponseEntity.status(HttpStatus.FOUND)
            .header(HttpHeaders.LOCATION, presignedUrl)
            .build();
}