From f206a47b387f08865f0c6bb2f2349fff22b5936a Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 9 Apr 2026 11:15:09 +0200 Subject: [PATCH 1/2] feat: support exact media selection in job options --- .../typetype/downloader/models/JobOptions.kt | 8 +++ .../services/JobOptionsNormalizer.kt | 52 +++++++++++++++++-- .../services/YtDlpOptionResolver.kt | 50 ++++++++++++++++-- .../downloader/services/YtDlpService.kt | 10 ++-- .../downloader/services/JobCacheKeyTest.kt | 27 +++------- .../services/JobOptionsNormalizerTest.kt | 26 +++++++++- .../services/YtDlpOptionResolverTest.kt | 23 ++++++-- 7 files changed, 160 insertions(+), 36 deletions(-) diff --git a/src/main/kotlin/dev/typetype/downloader/models/JobOptions.kt b/src/main/kotlin/dev/typetype/downloader/models/JobOptions.kt index d84285c..5814b2e 100644 --- a/src/main/kotlin/dev/typetype/downloader/models/JobOptions.kt +++ b/src/main/kotlin/dev/typetype/downloader/models/JobOptions.kt @@ -7,6 +7,14 @@ data class JobOptions( val mode: DownloadMode = DownloadMode.VIDEO, val quality: String = "best", val format: String = "", + val videoItag: String = "", + val audioItag: String = "", + val height: Int? = null, + val fps: Int? = null, + val videoCodec: String = "", + val audioCodec: String = "", + val bitrate: Int? = null, + val allowQualityFallback: Boolean = false, val sponsorBlock: Boolean = false, val sponsorBlockCategories: List = emptyList(), val thumbnailOnly: Boolean = false, diff --git a/src/main/kotlin/dev/typetype/downloader/services/JobOptionsNormalizer.kt b/src/main/kotlin/dev/typetype/downloader/services/JobOptionsNormalizer.kt index d4e3c21..f5bc6b6 100644 --- a/src/main/kotlin/dev/typetype/downloader/services/JobOptionsNormalizer.kt +++ b/src/main/kotlin/dev/typetype/downloader/services/JobOptionsNormalizer.kt @@ -24,27 +24,54 @@ object JobOptionsNormalizer { private val allowedSponsorBlockCategories = defaultSponsorBlockCategories.toSet() fun normalize(options: JobOptions): JobOptions { - val quality = normalizeQuality(options) val format = normalizeFormat(options) + val quality = normalizeQuality(options) + val videoItag = normalizeItag(options.videoItag) + val audioItag = normalizeItag(options.audioItag) + val height = normalizePositive(options.height, max = 4320) + val fps = normalizePositive(options.fps, max = 240) + val bitrate = normalizePositive(options.bitrate, max = 500_000) + val videoCodec = normalizeCodec(options.videoCodec) + val audioCodec = normalizeCodec(options.audioCodec) val subtitles = normalizeSubtitles(options.subtitles) val sponsorBlockCategories = normalizeSponsorBlockCategories(options) + if (options.thumbnailOnly) { + return options.copy( + quality = "best", + format = "", + videoItag = "", + audioItag = "", + height = null, + fps = null, + videoCodec = "", + audioCodec = "", + bitrate = null, + subtitles = SubtitlesOptions(), + sponsorBlockCategories = emptyList(), + ) + } return options.copy( quality = quality, format = format, + videoItag = videoItag, + audioItag = audioItag, + height = height, + fps = fps, + videoCodec = videoCodec, + audioCodec = audioCodec, + bitrate = bitrate, subtitles = subtitles, sponsorBlockCategories = sponsorBlockCategories, ) } private fun normalizeQuality(options: JobOptions): String { - if (options.thumbnailOnly) return "best" val raw = options.quality.trim().lowercase().ifBlank { "best" } val allowed = if (options.mode == DownloadMode.AUDIO) allowedAudioQualities else allowedVideoQualities return if (raw in allowed) raw else "best" } private fun normalizeFormat(options: JobOptions): String { - if (options.thumbnailOnly) return "" val raw = options.format.trim().lowercase() if (options.mode == DownloadMode.AUDIO) { if (raw.isBlank()) return "mp3" @@ -54,6 +81,23 @@ object JobOptionsNormalizer { return if (raw in allowedVideoFormats) raw else "mp4" } + private fun normalizeItag(raw: String): String { + val value = raw.trim() + return if (value.all { it.isDigit() }) value else "" + } + + private fun normalizeCodec(raw: String): String { + val value = raw.trim().lowercase() + if (value.isBlank()) return "" + return if (value.all { it.isLetterOrDigit() || it == '.' || it == '_' || it == '-' }) value else "" + } + + private fun normalizePositive(value: Int?, max: Int): Int? { + val current = value ?: return null + if (current <= 0) return null + return if (current > max) max else current + } + private fun normalizeSubtitles(input: SubtitlesOptions): SubtitlesOptions { if (!input.enabled) return SubtitlesOptions() val langs = input.languages.map { it.trim() }.filter { it.isNotBlank() }.ifEmpty { listOf("en") } @@ -62,7 +106,7 @@ object JobOptionsNormalizer { } private fun normalizeSponsorBlockCategories(options: JobOptions): List { - if (!options.sponsorBlock) return emptyList() + if (!options.sponsorBlock || options.thumbnailOnly) return emptyList() val custom = options.sponsorBlockCategories .map { it.trim().lowercase() } .filter { it in allowedSponsorBlockCategories } diff --git a/src/main/kotlin/dev/typetype/downloader/services/YtDlpOptionResolver.kt b/src/main/kotlin/dev/typetype/downloader/services/YtDlpOptionResolver.kt index 604120f..ad02eee 100644 --- a/src/main/kotlin/dev/typetype/downloader/services/YtDlpOptionResolver.kt +++ b/src/main/kotlin/dev/typetype/downloader/services/YtDlpOptionResolver.kt @@ -7,12 +7,52 @@ object YtDlpOptionResolver { private val audioFormats = setOf("mp3", "m4a", "aac", "opus", "flac", "wav") private val videoFormats = setOf("mp4", "webm", "mkv", "mov") - fun audioSelector(quality: String): String = if (quality.lowercase() == "worst") "worstaudio/worst" else "bestaudio/best" + fun audioSelector(options: JobOptions): String { + val audioItag = options.audioItag + if (audioItag.isNotBlank()) { + val strict = "ba[format_id=$audioItag]/b[format_id=$audioItag]" + return if (options.allowQualityFallback) "$strict/bestaudio/best" else strict + } + return if (options.quality.lowercase() == "worst") "worstaudio/worst" else "bestaudio/best" + } + + fun videoSelector(options: JobOptions): String { + if (options.videoItag.isNotBlank() || options.audioItag.isNotBlank() || hasTupleSelection(options)) { + val exact = exactVideoSelector(options) + if (!options.allowQualityFallback) return exact + return "$exact/${qualityVideoSelector(options.quality, allowFallback = true)}" + } + return qualityVideoSelector(options.quality, options.allowQualityFallback) + } + + private fun hasTupleSelection(options: JobOptions): Boolean { + return options.height != null || options.fps != null || options.videoCodec.isNotBlank() || + options.audioCodec.isNotBlank() || options.bitrate != null + } + + private fun exactVideoSelector(options: JobOptions): String { + val videoFilters = mutableListOf() + val audioFilters = mutableListOf() + options.videoItag.takeIf { it.isNotBlank() }?.let { videoFilters += "[format_id=$it]" } + options.audioItag.takeIf { it.isNotBlank() }?.let { audioFilters += "[format_id=$it]" } + options.height?.let { videoFilters += "[height=$it]" } + options.fps?.let { videoFilters += "[fps=$it]" } + options.videoCodec.takeIf { it.isNotBlank() }?.let { videoFilters += "[vcodec^=$it]" } + options.audioCodec.takeIf { it.isNotBlank() }?.let { audioFilters += "[acodec^=$it]" } + options.bitrate?.let { videoFilters += "[tbr>=$it][tbr<=${it + 300}]" } + val video = "bv*${videoFilters.joinToString("")}" + val audio = "ba${audioFilters.joinToString("")}" + return if (options.videoItag.isNotBlank() && options.audioItag.isBlank()) { + "$video+$audio/b[format_id=${options.videoItag}]" + } else { + "$video+$audio" + } + } - fun videoSelector(quality: String): String = when (quality.lowercase()) { - "1080p" -> "bv*[height<=1080]+ba/b[height<=1080]" - "720p" -> "bv*[height<=720]+ba/b[height<=720]" - "480p" -> "bv*[height<=480]+ba/b[height<=480]" + private fun qualityVideoSelector(quality: String, allowFallback: Boolean): String = when (quality.lowercase()) { + "1080p" -> if (allowFallback) "bv*[height<=1080]+ba/b[height<=1080]" else "bv*[height=1080]+ba/b[height=1080]" + "720p" -> if (allowFallback) "bv*[height<=720]+ba/b[height<=720]" else "bv*[height=720]+ba/b[height=720]" + "480p" -> if (allowFallback) "bv*[height<=480]+ba/b[height<=480]" else "bv*[height=480]+ba/b[height=480]" "worst" -> "worst" else -> "bv*+ba/b" } diff --git a/src/main/kotlin/dev/typetype/downloader/services/YtDlpService.kt b/src/main/kotlin/dev/typetype/downloader/services/YtDlpService.kt index 047ccaa..0ddddc1 100644 --- a/src/main/kotlin/dev/typetype/downloader/services/YtDlpService.kt +++ b/src/main/kotlin/dev/typetype/downloader/services/YtDlpService.kt @@ -34,7 +34,11 @@ class YtDlpService(private val config: AppConfig) { val output = process.inputStream.bufferedReader().readLines() if (process.exitValue() != 0) { deleteDirectory(workDir) - val error = output.lastOrNull { it.isNotBlank() } ?: "yt-dlp failed" + val error = if (output.any { it.contains("Requested format is not available", ignoreCase = true) }) { + "exact selection unavailable: requested format is not available" + } else { + output.lastOrNull { it.isNotBlank() } ?: "yt-dlp failed" + } return YtDlpResult(title = "", filePath = null, error = error) } val filePath = selectOutputFile(workDir, options) @@ -61,12 +65,12 @@ class YtDlpService(private val config: AppConfig) { when { options.thumbnailOnly -> command.addAll(listOf("--skip-download", "--write-thumbnail")) options.mode == DownloadMode.AUDIO -> { - val selector = YtDlpOptionResolver.audioSelector(options.quality) + val selector = YtDlpOptionResolver.audioSelector(options) val audioFormat = YtDlpOptionResolver.audioFormat(options.format) command.addAll(listOf("-f", selector, "--extract-audio", "--audio-format", audioFormat)) } else -> { - val selector = YtDlpOptionResolver.videoSelector(options.quality) + val selector = YtDlpOptionResolver.videoSelector(options) val videoFormat = YtDlpOptionResolver.videoFormat(options.format) command.addAll(listOf("-f", selector, "--merge-output-format", videoFormat)) } diff --git a/src/test/kotlin/dev/typetype/downloader/services/JobCacheKeyTest.kt b/src/test/kotlin/dev/typetype/downloader/services/JobCacheKeyTest.kt index 04b44cf..b15b282 100644 --- a/src/test/kotlin/dev/typetype/downloader/services/JobCacheKeyTest.kt +++ b/src/test/kotlin/dev/typetype/downloader/services/JobCacheKeyTest.kt @@ -1,34 +1,23 @@ package dev.typetype.downloader.services -import dev.typetype.downloader.models.DownloadMode import dev.typetype.downloader.models.JobOptions import kotlin.test.Test -import kotlin.test.assertEquals import kotlin.test.assertNotEquals class JobCacheKeyTest { @Test - fun `same URL yields same key`() { + fun `cache key isolates quality selections`() { val url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ" - val options = JobOptionsCodec.encode(JobOptions()) - val first = JobCacheKey.from(url, options) - val second = JobCacheKey.from(url, options) - assertEquals(first, second) + val key720 = JobCacheKey.from(url, JobOptionsCodec.encode(JobOptionsNormalizer.normalize(JobOptions(quality = "720p")))) + val key1080 = JobCacheKey.from(url, JobOptionsCodec.encode(JobOptionsNormalizer.normalize(JobOptions(quality = "1080p")))) + assertNotEquals(key720, key1080) } @Test - fun `trimmed URL keeps same key`() { - val options = JobOptionsCodec.encode(JobOptions()) - val clean = JobCacheKey.from("https://www.youtube.com/watch?v=dQw4w9WgXcQ", options) - val padded = JobCacheKey.from(" https://www.youtube.com/watch?v=dQw4w9WgXcQ ", options) - assertEquals(clean, padded) - } - - @Test - fun `different options yield different keys`() { + fun `cache key isolates exact itag selections`() { val url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ" - val first = JobCacheKey.from(url, JobOptionsCodec.encode(JobOptions(mode = DownloadMode.VIDEO))) - val second = JobCacheKey.from(url, JobOptionsCodec.encode(JobOptions(mode = DownloadMode.AUDIO))) - assertNotEquals(first, second) + val key137 = JobCacheKey.from(url, JobOptionsCodec.encode(JobOptionsNormalizer.normalize(JobOptions(videoItag = "137")))) + val key136 = JobCacheKey.from(url, JobOptionsCodec.encode(JobOptionsNormalizer.normalize(JobOptions(videoItag = "136")))) + assertNotEquals(key137, key136) } } diff --git a/src/test/kotlin/dev/typetype/downloader/services/JobOptionsNormalizerTest.kt b/src/test/kotlin/dev/typetype/downloader/services/JobOptionsNormalizerTest.kt index d4ad818..4c094e8 100644 --- a/src/test/kotlin/dev/typetype/downloader/services/JobOptionsNormalizerTest.kt +++ b/src/test/kotlin/dev/typetype/downloader/services/JobOptionsNormalizerTest.kt @@ -59,10 +59,12 @@ class JobOptionsNormalizerTest { @Test fun `thumbnail only ignores quality and format`() { val normalized = JobOptionsNormalizer.normalize( - JobOptions(thumbnailOnly = true, quality = "1080p", format = "webm"), + JobOptions(thumbnailOnly = true, quality = "1080p", format = "webm", videoItag = "137", height = 1080), ) assertEquals("best", normalized.quality) assertEquals("", normalized.format) + assertEquals("", normalized.videoItag) + assertEquals(null, normalized.height) } @Test @@ -74,4 +76,26 @@ class JobOptionsNormalizerTest { ) assertEquals(SubtitlesOptions(), normalized.subtitles) } + + @Test + fun `normalizes exact selection fields`() { + val normalized = JobOptionsNormalizer.normalize( + JobOptions( + videoItag = " 137 ", + audioItag = "abc", + height = 1080, + fps = -5, + videoCodec = " avc1.640028 ", + audioCodec = " mp4a.40.2 ", + bitrate = 0, + ), + ) + assertEquals("137", normalized.videoItag) + assertEquals("", normalized.audioItag) + assertEquals(1080, normalized.height) + assertEquals(null, normalized.fps) + assertEquals("avc1.640028", normalized.videoCodec) + assertEquals("mp4a.40.2", normalized.audioCodec) + assertEquals(null, normalized.bitrate) + } } diff --git a/src/test/kotlin/dev/typetype/downloader/services/YtDlpOptionResolverTest.kt b/src/test/kotlin/dev/typetype/downloader/services/YtDlpOptionResolverTest.kt index 703f686..4494800 100644 --- a/src/test/kotlin/dev/typetype/downloader/services/YtDlpOptionResolverTest.kt +++ b/src/test/kotlin/dev/typetype/downloader/services/YtDlpOptionResolverTest.kt @@ -8,16 +8,31 @@ import kotlin.test.assertEquals class YtDlpOptionResolverTest { @Test fun `resolves audio selector and format defaults`() { - assertEquals("worstaudio/worst", YtDlpOptionResolver.audioSelector("worst")) - assertEquals("bestaudio/best", YtDlpOptionResolver.audioSelector("1080p")) + assertEquals("worstaudio/worst", YtDlpOptionResolver.audioSelector(JobOptions(mode = DownloadMode.AUDIO, quality = "worst"))) + assertEquals("bestaudio/best", YtDlpOptionResolver.audioSelector(JobOptions(mode = DownloadMode.AUDIO, quality = "best"))) + assertEquals( + "ba[format_id=251]/b[format_id=251]", + YtDlpOptionResolver.audioSelector(JobOptions(mode = DownloadMode.AUDIO, audioItag = "251")), + ) assertEquals("mp3", YtDlpOptionResolver.audioFormat("avi")) assertEquals("m4a", YtDlpOptionResolver.audioFormat(" M4A ")) } @Test fun `resolves video selector and format defaults`() { - assertEquals("bv*[height<=720]+ba/b[height<=720]", YtDlpOptionResolver.videoSelector("720p")) - assertEquals("bv*+ba/b", YtDlpOptionResolver.videoSelector("best")) + assertEquals( + "bv*[height=720]+ba/b[height=720]", + YtDlpOptionResolver.videoSelector(JobOptions(mode = DownloadMode.VIDEO, quality = "720p")), + ) + assertEquals( + "bv*[height<=720]+ba/b[height<=720]", + YtDlpOptionResolver.videoSelector(JobOptions(mode = DownloadMode.VIDEO, quality = "720p", allowQualityFallback = true)), + ) + assertEquals("bv*+ba/b", YtDlpOptionResolver.videoSelector(JobOptions(mode = DownloadMode.VIDEO, quality = "best"))) + assertEquals( + "bv*[format_id=137]+ba[format_id=140]", + YtDlpOptionResolver.videoSelector(JobOptions(mode = DownloadMode.VIDEO, videoItag = "137", audioItag = "140")), + ) assertEquals("mp4", YtDlpOptionResolver.videoFormat("unknown")) assertEquals("webm", YtDlpOptionResolver.videoFormat(" WEBM ")) } From a16424183c33e48260361942a8794bcdc34a5cd9 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 9 Apr 2026 11:15:21 +0200 Subject: [PATCH 2/2] feat: expose resolved output and progress metadata --- .../typetype/downloader/models/JobResponse.kt | 7 ++ .../downloader/models/ResolvedOutput.kt | 16 ++++ .../services/GarageStorageService.kt | 8 +- .../downloader/services/JobService.kt | 25 +---- .../downloader/services/JobViewBuilder.kt | 93 +++++++++++++++++++ .../downloader/services/JobViewBuilderTest.kt | 42 +++++++++ 6 files changed, 165 insertions(+), 26 deletions(-) create mode 100644 src/main/kotlin/dev/typetype/downloader/models/ResolvedOutput.kt create mode 100644 src/main/kotlin/dev/typetype/downloader/services/JobViewBuilder.kt create mode 100644 src/test/kotlin/dev/typetype/downloader/services/JobViewBuilderTest.kt diff --git a/src/main/kotlin/dev/typetype/downloader/models/JobResponse.kt b/src/main/kotlin/dev/typetype/downloader/models/JobResponse.kt index 574179e..3cf4858 100644 --- a/src/main/kotlin/dev/typetype/downloader/models/JobResponse.kt +++ b/src/main/kotlin/dev/typetype/downloader/models/JobResponse.kt @@ -10,6 +10,13 @@ data class JobResponse( val durationMs: Long, val title: String, val error: String? = null, + val errorCode: String? = null, val artifactUrl: String? = null, val artifactExpiresAt: String? = null, + val resolved: ResolvedOutput? = null, + val progressPercent: Int? = null, + val downloadedBytes: Long? = null, + val totalBytes: Long? = null, + val etaSeconds: Long? = null, + val stage: String? = null, ) diff --git a/src/main/kotlin/dev/typetype/downloader/models/ResolvedOutput.kt b/src/main/kotlin/dev/typetype/downloader/models/ResolvedOutput.kt new file mode 100644 index 0000000..3e11f29 --- /dev/null +++ b/src/main/kotlin/dev/typetype/downloader/models/ResolvedOutput.kt @@ -0,0 +1,16 @@ +package dev.typetype.downloader.models + +import kotlinx.serialization.Serializable + +@Serializable +data class ResolvedOutput( + val videoItag: String? = null, + val audioItag: String? = null, + val height: Int? = null, + val fps: Int? = null, + val videoCodec: String? = null, + val audioCodec: String? = null, + val container: String? = null, + val bitrate: Int? = null, + val fileName: String? = null, +) diff --git a/src/main/kotlin/dev/typetype/downloader/services/GarageStorageService.kt b/src/main/kotlin/dev/typetype/downloader/services/GarageStorageService.kt index 474a02a..f2bf149 100644 --- a/src/main/kotlin/dev/typetype/downloader/services/GarageStorageService.kt +++ b/src/main/kotlin/dev/typetype/downloader/services/GarageStorageService.kt @@ -75,8 +75,12 @@ class GarageStorageService(config: AppConfig) { s3.putObject(request, RequestBody.fromFile(filePath)) } - fun presignGet(objectKey: String, duration: Duration): String { - val getRequest = GetObjectRequest.builder().bucket(bucket).key(objectKey).build() + fun presignGet(objectKey: String, duration: Duration, fileName: String? = null): String { + val requestBuilder = GetObjectRequest.builder().bucket(bucket).key(objectKey) + if (!fileName.isNullOrBlank()) { + requestBuilder.responseContentDisposition("attachment; filename=\"$fileName\"") + } + val getRequest = requestBuilder.build() val presignRequest = GetObjectPresignRequest.builder().signatureDuration(duration).getObjectRequest(getRequest).build() return presigner.presignGetObject(presignRequest).url().toString() } diff --git a/src/main/kotlin/dev/typetype/downloader/services/JobService.kt b/src/main/kotlin/dev/typetype/downloader/services/JobService.kt index 99bb46a..2ea44e2 100644 --- a/src/main/kotlin/dev/typetype/downloader/services/JobService.kt +++ b/src/main/kotlin/dev/typetype/downloader/services/JobService.kt @@ -1,7 +1,6 @@ package dev.typetype.downloader.services import dev.typetype.downloader.config.AppConfig -import dev.typetype.downloader.db.JobRow import dev.typetype.downloader.db.JobsRepository import dev.typetype.downloader.models.CreateJobResponse import dev.typetype.downloader.models.JobOptions @@ -9,8 +8,6 @@ import dev.typetype.downloader.models.JobResponse import dev.typetype.downloader.models.JobStatus import redis.clients.jedis.JedisPooled import java.net.URI -import java.time.Duration -import java.time.Instant import java.util.UUID enum class CancelJobResult { NOT_FOUND, NOT_CANCELLABLE, CANCELLED } @@ -49,7 +46,7 @@ class JobService( fun get(id: String): JobResponse? { val row = jobsRepository.getById(id) ?: return null - return row.toResponse(presignUrl(row), row.artifactExpiresAt?.toString()) + return JobViewBuilder.build(row, storageService) } fun cancel(id: String): CancelJobResult { @@ -97,24 +94,4 @@ class JobService( } private fun redisJobKey(id: String): String = "downloader:job:$id" - - private fun presignUrl(row: JobRow): String? { - val key = row.artifactKey ?: return null - val expiresAt = row.artifactExpiresAt ?: return null - val now = Instant.now() - if (expiresAt <= now) return null - val seconds = Duration.between(now, expiresAt).seconds.coerceIn(1, 900) - return storageService.presignGet(key, Duration.ofSeconds(seconds)) - } - - private fun JobRow.toResponse(artifactUrl: String?, artifactExpiresAt: String?): JobResponse = JobResponse( - id = id, - url = url, - status = status, - durationMs = durationMs, - title = title, - error = error, - artifactUrl = artifactUrl, - artifactExpiresAt = artifactExpiresAt, - ) } diff --git a/src/main/kotlin/dev/typetype/downloader/services/JobViewBuilder.kt b/src/main/kotlin/dev/typetype/downloader/services/JobViewBuilder.kt new file mode 100644 index 0000000..a59fa78 --- /dev/null +++ b/src/main/kotlin/dev/typetype/downloader/services/JobViewBuilder.kt @@ -0,0 +1,93 @@ +package dev.typetype.downloader.services + +import dev.typetype.downloader.db.JobRow +import dev.typetype.downloader.models.JobOptions +import dev.typetype.downloader.models.JobResponse +import dev.typetype.downloader.models.JobStatus +import dev.typetype.downloader.models.ResolvedOutput +import java.time.Duration +import java.time.Instant + +object JobViewBuilder { + fun build(row: JobRow, storageService: GarageStorageService): JobResponse { + val options = runCatching { JobOptionsCodec.decode(row.optionsJson) }.map(JobOptionsNormalizer::normalize).getOrElse { JobOptions() } + val fileName = stableFileName(row, options) + val artifactUrl = presignUrl(storageService, row, fileName) + return JobResponse( + id = row.id, + url = row.url, + status = row.status, + durationMs = row.durationMs, + title = row.title, + error = row.error, + errorCode = errorCode(row.error), + artifactUrl = artifactUrl, + artifactExpiresAt = row.artifactExpiresAt?.toString(), + resolved = resolved(row, options, fileName), + progressPercent = progressPercent(row.status), + downloadedBytes = null, + totalBytes = null, + etaSeconds = null, + stage = stage(row.status), + ) + } + + private fun presignUrl(storageService: GarageStorageService, row: JobRow, fileName: String?): String? { + val key = row.artifactKey ?: return null + val expiresAt = row.artifactExpiresAt ?: return null + val now = Instant.now() + if (expiresAt <= now) return null + val seconds = Duration.between(now, expiresAt).seconds.coerceIn(1, 900) + return storageService.presignGet(key, Duration.ofSeconds(seconds), fileName) + } + + private fun resolved(row: JobRow, options: JobOptions, fileName: String?): ResolvedOutput = ResolvedOutput( + videoItag = options.videoItag.ifBlank { null }, + audioItag = options.audioItag.ifBlank { null }, + height = options.height ?: qualityHeight(options.quality), + fps = options.fps, + videoCodec = options.videoCodec.ifBlank { null }, + audioCodec = options.audioCodec.ifBlank { null }, + container = row.artifactKey?.substringAfterLast('.', "").orEmpty().ifBlank { null }, + bitrate = options.bitrate, + fileName = fileName, + ) + + private fun stableFileName(row: JobRow, options: JobOptions): String? { + val extension = row.artifactKey?.substringAfterLast('.', "")?.ifBlank { null } ?: return null + val base = row.title.takeIf { it.isNotBlank() } ?: row.id + val clean = base.map { if (it.isLetterOrDigit() || it == '-' || it == '_') it else '_' } + .joinToString("") + .replace("__", "_") + .trim('_') + .ifBlank { row.id } + val name = if (options.thumbnailOnly) "${clean}_thumbnail" else clean + return "$name.$extension" + } + + private fun qualityHeight(quality: String): Int? = when (quality.lowercase()) { + "1080p" -> 1080 + "720p" -> 720 + "480p" -> 480 + else -> null + } + + private fun stage(status: JobStatus): String = when (status) { + JobStatus.QUEUED -> "queued" + JobStatus.RUNNING -> "downloading" + JobStatus.DONE -> "finalizing" + JobStatus.FAILED -> "failed" + } + + private fun progressPercent(status: JobStatus): Int? = when (status) { + JobStatus.QUEUED -> 0 + JobStatus.RUNNING -> 50 + JobStatus.DONE -> 100 + JobStatus.FAILED -> null + } + + private fun errorCode(error: String?): String? { + val value = error?.lowercase() ?: return null + return if ("exact selection unavailable" in value) "exact_selection_unavailable" else null + } +} diff --git a/src/test/kotlin/dev/typetype/downloader/services/JobViewBuilderTest.kt b/src/test/kotlin/dev/typetype/downloader/services/JobViewBuilderTest.kt new file mode 100644 index 0000000..760069e --- /dev/null +++ b/src/test/kotlin/dev/typetype/downloader/services/JobViewBuilderTest.kt @@ -0,0 +1,42 @@ +package dev.typetype.downloader.services + +import dev.typetype.downloader.db.JobRow +import dev.typetype.downloader.models.JobOptions +import dev.typetype.downloader.models.JobStatus +import io.mockk.every +import io.mockk.mockk +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class JobViewBuilderTest { + @Test + fun `build exposes resolved fields and progress`() { + val storage = mockk() + every { storage.presignGet(any(), any(), any()) } returns "http://garage:3900/signed" + val options = JobOptions(quality = "1080p", format = "mp4", videoItag = "137", audioItag = "140") + val row = JobRow( + id = "job-1", + url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + cacheKey = "cache", + optionsJson = JobOptionsCodec.encode(options), + status = JobStatus.QUEUED, + durationMs = 0, + title = "Never Gonna Give You Up", + error = null, + artifactKey = "cache/file.mp4", + artifactExpiresAt = java.time.Instant.now().plusSeconds(600), + ) + + val response = JobViewBuilder.build(row, storage) + assertEquals("queued", response.stage) + assertEquals(0, response.progressPercent) + assertNotNull(response.resolved) + assertEquals("137", response.resolved.videoItag) + assertEquals("140", response.resolved.audioItag) + assertEquals(1080, response.resolved.height) + assertEquals("mp4", response.resolved.container) + assertTrue(response.resolved.fileName!!.endsWith(".mp4")) + } +}