Building a Modern YouTube(Yt-dlp) Downloader API: A Guide to GraphQL, Kotlin, and Ktor

Hi👋,
Welcome back, API adventurers!
In Part 1 of this series, we laid the groundwork for building a GraphQL server using the powerful trio of Kotlin, Ktor, and graphql-kotlin.We learned about GraphQL basics, set up our server, and defined our initial schema for a YouTube video tool.Now, it's time to bring that server to life!
In this article (Part 2), we're going to roll up our sleeves and integrate the renowned yt-dlp
command-line program to fetch real video information and trigger downloads. We'll focus on the backend service logic that powers our GraphQL operations.
Let's dive in!
This is a 4 Part Series (Estimated Read Time: ~18-20 minutes)
Part 1 : The core server setup using GraphQL-Kotlin Ktor Server by expediagroup .
Part 2 : Define Our Youtube Video Download API.
Part 3 : The core client setup for Kotlin Multiplatform App Android and JVM using Apollo Kotlin.
Part 4 : Consuming Our Api using Apollo Kotlin.
Recap: Where We Left OffIn Part 1,
we successfully: ✅ Set up a Ktor server. ✅ Integrated graphql-kotlin and configured it. ✅ Defined basic GraphQL Hello World Query. ✅ Learned how to test our API with GraphiQL.
Introducing yt-dlp: Our Video Supertool 🎬
is a fantastic open-source command-line program that allows you to download videos (and audio) from YouTube and hundreds of other websites. It's a fork of the popular youtube-dl with additional features and fixes.
Why yt-dlp for our project?
- Powerful & Versatile: It can extract extensive metadata, list available formats, and, of course, download content.
- Command-Line Interface (CLI): We can easily execute yt-dlp commands from our Kotlin/Ktor backend.
- Actively Maintained: It's the go-to tool for this kind of task.Assumption: You have yt-dlp installed and accessible in your system's PATH, or you know the full path to its executable. For a production server, you'd package yt-dlp with your application (e.g., in a Docker container).
We Going to build these four features in our Yt-dlp GraphQL Api:
fetchVideoInfo(url: String!)
getDownloadHistory
downloadVideo(videoInfo: VideoInfoInput!)
videoDownloadUpdates(videoId: String)
⚙️ The Service Layer: Where the Real Work Happens
In a well-structured application, your GraphQL resolvers (the functions in your Query/Mutation/Subscription classes) shouldn't contain complex business logic. Instead, they delegate to a service layer. This service layer will be responsible for interacting with yt-dlp
.
Let's design a YtDlpService
.
- Define Data Models:These are simple Kotlin data classes, often annotated with @Serializable if you're using Kotlinx Serialization. graphql-kotlin will automatically map these to GraphQL types.
In Yt-dlp if you use yt-dlp -j "videoId"
this command you will get a json
that contains all detail about the video so we need to create a data model VideoInfo
to extract the info we need for our api.
@Serializable
data class YtdlVideoInfo(
val id: String? = "",
val title: String? = "",
val description: String? = "",
val categories: List<String>? = emptyList(),
val channel: String? = "",
val thumbnail: String? = "",
val formats: List<FormatDetails>? = emptyList(),
val defaultFormatVideo: FormatDetails? = null,
val defaultFormatAudio: FormatDetails? = null,
) {
// Process formats and filter out those with "storyboard"
val filteredFormats: List<FormatDetails>
get() =
formats?.let { list -> list.filterNot { it.format.contains("storyboard") } }
?: emptyList()
// Get default video format
val defaultVideoFormat: FormatDetails?
get() =
filteredFormats.firstOrNull {
it.formatId == "137" || (
it.vcodec.contains("avc1") &&
it.resolution.contains(
"1920",
)
)
}
// Get default audio format
val defaultAudioFormat: FormatDetails?
get() =
filteredFormats.firstOrNull {
it.formatId == "140" || (
it.resolution.contains("audio") &&
it.format.contains(
"high",
)
)
}
}
fun YtdlVideoInfo.toVideoInfo(
downloadCategory: String,
downloadPath: String,
): VideoInfo =
VideoInfo(
videoID = id ?: "",
title = title ?: "",
thumbnail = thumbnail,
desc = description,
category = categories ?: emptyList(),
channelName = channel ?: "",
formats = filteredFormats,
defaultFormatVideo = defaultVideoFormat,
defaultFormatAudio = defaultAudioFormat,
downloadCategory = downloadCategory,
downloadPath = downloadPath,
status = DownloadStatus.UNKNOWN,
)
YtdlVideoInfo.Kt
@Serializable
@SerialName("VideoInfo")
data class VideoInfo(
val videoID: String,
val title: String,
val desc: String?,
val category: List<String>,
val channelName: String,
val thumbnail: String?,
val formats: List<FormatDetails>?,
val status: DownloadStatus = DownloadStatus.UNKNOWN,
val defaultFormatVideo: FormatDetails? = FormatDetails(),
val defaultFormatAudio: FormatDetails? = FormatDetails(),
val downloadPath: String = "download",
val downloadCategory: String = "Music",
)
@Serializable
@SerialName("FormatDetails")
data class FormatDetails(
val acodec: String = "",
val vcodec: String = "",
val ext: String = "",
val resolution: String = "",
val format: String = "",
val url: String = "",
@SerialName("format_id")
val formatId: String = "",
)
enum class DownloadStatus {
PENDING,
DOWNLOADING,
DUPLICATE,
COMPLETED,
FAILED,
UNKNOWN,
}
DownloadModel.kt
Now, create a new Kotlin class, say YtDlpService.kt
. This service will need to execute external processes (yt-dlp commands) and parse their output.
class YtDlpService(
private val json: Json,
private val appConfig: AppConfig,
) {
internal val LOGGER = KtorSimpleLogger("com.droidslife.dcnytloader.YtDlpService")
private val _videoQueryDetail = mutableMapOf<String, VideoInfo>()
val videoQueryDetail: List<VideoInfo> get() = _videoQueryDetail.values.toList()
suspend fun fetchVideoDetails(videoId: String): VideoInfo? {
return runCatching {
_videoQueryDetail.getOrPut(videoId) {
val option =
listOf(
"yt-dlp",
"-j",
videoId,
)
LOGGER.info("fetchVideoDetails $option")
val processBuilder = ProcessBuilder(option)
processBuilder.redirectErrorStream(true)
val process =
withContext(Dispatchers.IO) {
processBuilder.start()
}
val reader = BufferedReader(InputStreamReader(process.inputStream))
val errorReader = BufferedReader(InputStreamReader(process.errorStream))
val output = kotlin.text.StringBuilder()
reader.forEachLine { line ->
output.append(line)
}
val exitCode =
withContext(Dispatchers.IO) {
process.waitFor()
}
if (exitCode == 0) {
return@getOrPut withContext(Dispatchers.IO) {
val regex = Regex(".*?\\{")
val videoInfoJson = output.replaceFirst(regex, "{")
val result =
json
.decodeFromString(YtdlVideoInfo.serializer(), videoInfoJson)
.toVideoInfo(appConfig.downloadCategory, appConfig.downloadPath)
return@withContext result
}
} else {
println("exitCode out $exitCode")
println("is not a valid URL : $output")
throw RuntimeException(
output.toString(),
)
}
}
}.getOrNull()
}
//...
}
YtDlpService.kt
What's happening here?
- Dependencies: We need a JSON parser (like kotlinx.serialization.json.Json)
- Constructs a yt-dlp -J command to get video info as JSON.
- Uses ProcessBuilder to execute this command.
- Crucially, reads both inputStream (stdout) and errorStream (stderr) of the process. If you only read one and the other buffer fills up, the process can hang!
- Waits for the process to complete and checks the exitCode.
- If successful (exit code 0), it parses the JSON output using kotlinx.serialization. We define YtDlpJsonOutput and YtDlpFormat data classes to match the structure of yt-dlp's JSON.
- Maps the parsed data to our API's VideoInfo model.
and to download the video :
//...
private val _downloadHistory = mutableMapOf<String, DownloadHistory>()
val downloadHistory: List<DownloadHistory> get() = _downloadHistory.values.toList()
private val _progressFlow = MutableSharedFlow<DownloadUpdates>()
val progressFlow = _progressFlow.asSharedFlow()
private val downloadScopes = mutableMapOf<String, CoroutineScope>()
fun startVideoDownload(request: VideoInfo) {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
downloadScopes[request.videoID] = scope
scope.launch {
runCatching {
val historyData = request.toDownloadHistory()
if (_downloadHistory.containsKey(historyData.videoID)) {
println("duplicate download")
} else {
_downloadHistory[historyData.videoID] = historyData
}
downloadVideo(
videoId = request.videoID,
videoFormat = request.defaultFormatVideo?.formatId,
audioFormat = request.defaultFormatAudio?.formatId,
path = request.downloadPath,
category = request.downloadCategory,
)
}
}
}
private suspend fun downloadVideo(
videoId: String,
videoFormat: String?,
audioFormat: String?,
path: String,
category: String,
) {
val processBuilder: ProcessBuilder
var process: Process? = null
var reader: BufferedReader? = null
var errorReader: BufferedReader? = null
try {
val monthDir =
LocalDate.now().format(
DateTimeFormatter.ofPattern("MMMyy"),
)
val archiveDir = Paths.get(appConfig.remotePath).toFile().absoluteFile
val downloadDir =
Paths.get(appConfig.remotePath, path, category, monthDir).toFile().absoluteFile
LOGGER.info("downloadDir check>---$downloadDir")
LOGGER.info("archiveDir check>---$archiveDir")
if (!downloadDir.exists()) {
if (!downloadDir.mkdirs()) {
throw kotlin.RuntimeException("Failed to create download directory.")
}
}
val formats =
"$videoFormat+$audioFormat".takeIf { videoFormat.isNullOrBlank() && audioFormat.isNullOrBlank() }
?: "bestvideo[height=1080]+bestaudio"
val command =
listOf(
"yt-dlp",
"-N",
"8",
"-f",
formats,
"-o",
"${downloadDir.absolutePath}/%(title)s.%(ext)s",
"-ciw",
"https://www.youtube.com/watch?v=$videoId",
"--restrict-filenames",
"--download-archive",
"$archiveDir/downloaded.txt",
)
LOGGER.info("download cmd > " + command.joinToString(" "))
processBuilder = ProcessBuilder(command)
processBuilder.redirectErrorStream(true)
process =
withContext(Dispatchers.IO) {
processBuilder.start()
}
val inputStream = process.inputStream
val errorStream = process.errorStream
reader = BufferedReader(InputStreamReader(inputStream))
errorReader = BufferedReader(InputStreamReader(errorStream))
withContext(Dispatchers.IO) {
reader.forEachLine { line ->
launch(Dispatchers.IO) {
println(line)
parseVideoDownloadUpdatesInfo(videoId, line)?.let { updates ->
_progressFlow.emit(updates)
}
}
}
}
val exitCode =
withContext(Dispatchers.IO) {
process.waitFor()
}
if (exitCode != 0) {
throw kotlin.RuntimeException(errorReader.readLines().joinToString(",\n"))
}
} catch (e: Exception) {
_progressFlow.emit(
DownloadUpdates(
videoId,
0.0,
msg =
OtherInfo(
"Failed due to ${e.message}",
pc = appConfig.remoteIp,
filePath = appConfig.remotePath,
fileName = path,
),
),
)
} finally {
reader?.close()
errorReader?.close()
process?.destroyForcibly()
clearDownloadScope(videoId)
}
}
private fun parseVideoDownloadUpdatesInfo(
videoId: String,
update: String,
): DownloadUpdates? {
val upDateData = createMessageFromString(update)
try {
val msg: DownloadUpdates =
when (upDateData) {
/* is DeleteInfo -> {
DownloadUpdates(videoId, 100.00, upDateData)
}*/
is DownloadInfo -> {
val u = DownloadUpdates(videoId, upDateData.percentage, upDateData)
if (upDateData.percentage >= 99.0) {
_downloadHistory.updateByKey(videoId) {
it.copy(
downloadUpdates = u,
status = DownloadStatus.DOWNLOADING,
)
}
}
u
}
/* is MergeInfo -> {
DownloadUpdates(videoId, 100.00, upDateData)
}
*/
is OtherInfo -> {
val u =
DownloadUpdates(
videoId,
if (upDateData.status == DownloadStatus.COMPLETED) 100.0 else 0.0,
upDateData,
)
if (upDateData.status == DownloadStatus.COMPLETED) {
println("download completed")
_downloadHistory.updateByKey(videoId) {
it.copy(
downloadUpdates = u,
status = DownloadStatus.COMPLETED,
)
}
}
u
}
}
return msg
} catch (e: Exception) {
println("error during parseVideoDownloadUpdatesInfo $e")
return null
}
}
fun clearDownloadScope(downloadId: String) {
downloadScopes[downloadId]?.cancel()
downloadScopes.remove(downloadId)
}
YtDlpService.kt
- This is a more complex placeholder.
- It shows how to construct a basic download command.
- -N 8: Example for downloading up to 8 fragments concurrently.
- -f: Format selection string. This is key for letting users choose video/audio quality or for sensible defaults.
- -o: Output template, defining how downloaded files are named and where they are saved.
- --download-archive: Tells yt-dlp to keep a record of downloaded files to avoid re-downloading.
Integrating the Service with GraphQL Resolvers
Now, we update our Query, Mutation, and Subscription classes to use YtDlpService.
class VideoDownloadMutation(
private val ytDlpService: YtDlpService,
) : Mutation {
suspend fun fetchVideoInfo(url: String): DataFetcherResult<VideoInfo?> {
val videoDetails: VideoInfo? = ytDlpService.fetchVideoDetails(url)
return DataFetcherResult
.newResult<VideoInfo?>()
.data(videoDetails)
.build()
}
suspend fun downloadVideo(videoInfo: VideoInfo): DataFetcherResult<String> {
ytDlpService.startVideoDownload(videoInfo)
return DataFetcherResult
.newResult<String>()
.data("Download Started")
.build()
}
}
class VideoDownloadUpdatesSubscription(
private val ytDlpService: YtDlpService,
) : Subscription {
fun videoDownloadUpdates(videoId: String? = null): Flow<DownloadUpdates> =
flow {
ytDlpService.progressFlow.collect { update ->
try {
emit(update)
} catch (e: CancellationException) {
// Channel/session was cancelled; clean up if needed and exit gracefully
println("Progress flow emit cancelled: ${e.message}")
}
}
}
}
make sure our service is available. In your Application.kt and update configureGraphQL:
private fun Application.configureGraphQL() {
val service: YtDlpService by dependencies
install(GraphQL) {
schema {
packages =
listOf(
"com.droidslife.dcnytloader.graphql.schema",
"com.droidslife.dcnytloader.models",
)
queries =
listOf(
HelloWorldQuery(),
)
mutations =
listOf(
VideoDownloadMutation(service),
)
subscriptions =
listOf(
VideoDownloadUpdatesSubscription(service),
)
}
engine {
introspection {
enabled = true
}
// dataLoaderRegistryFactory = KotlinDataLoaderRegistryFactory(
// UniversityDataLoader, CourseDataLoader, BookDataLoader
// )
}
server {
contextFactory = CustomGraphQLContextFactory()
}
}
}
Test Driving the Real Thing! 🚀
- Ensure yt-dlp is installed and in your PATH (or update ytDlpPath in the service).
- Run your Ktor server as in Part 1.
- Open GraphiQL (http://localhost:8080/graphiql)
Try these:
•Fetch Video Info:
mutation FetchVideoInfo($url: String!) {
fetchVideoInfo(url: $url) {
title
downloadCategory
formats {
format
formatId
resolution
ext
acodec
vcodec
}
defaultFormatVideo {
format
formatId
resolution
ext
acodec
vcodec
}
}
}

Conclusion: You've Built a Truly Useful API! Congratulations!
You've successfully transformed the theoretical GraphQL server from Part 1 into a functional backend that interacts with an external tool (yt-dlp) to perform real tasks.
In this part, we specifically focused on:
✅ Designing a YtDlpService to encapsulate yt-dlp interactions.
✅ Executing yt-dlp commands to fetch video metadata and download videos.
✅ Parsing JSON output from yt-dlp.
✅ Integrating this service layer back into our GraphQL Query and Mutation resolvers.
✅ handling real-time download progress for Subscriptions.
This project now serves as a fantastic, practical example of building modern, efficient, and real-world APIs with Kotlin, Ktor, and GraphQL. The principles you've learned here – service layers, external process interaction, asynchronous programming – are applicable to a wide range of backend development scenarios.
🎁 Bonus: Level Up Your Testing with Apollo Sandbox (GraphQL Playground)
While GraphiQL is fantastic and comes out-of-the-box with graphql-kotlin-ktor-server, you might prefer a different interface or need more advanced features for exploring your API. Apollo Sandbox (the evolution of GraphQL Playground) is a popular and powerful choice!
It offers features like:
A more modern UI, Better support for exploring schemas with multiple types ,History of your queries, Setting HTTP headers easily, Support for multiple tabs.
Setting up Apollo Sandbox with Ktor:
It's surprisingly easy to host your own instance of the embeddable Apollo Sandbox alongside your Ktor server.
- Create an HTML file:
Inside your Ktor project's src/main/resources directory, create a new folder named playground (or any name you like). Inside this playground folder,
<div style="width: 100%; height: 100%;" id='embedded-sandbox'></div>
<script src="https://embeddable-sandbox.cdn.apollographql.com/_latest/embeddable-sandbox.umd.production.min.js"></script>
<script>
// Get the current hostname (e.g., "localhost:8080", "learncompose.com")
const currentHost = window.location.host;
// Get the current protocol (e.g., "http:", "https:")
const currentProtocol = window.location.protocol;
// Construct the base URL
const baseUrl = `${currentProtocol}//${currentHost}`;
// Define your GraphQL path
const graphqlPath = '/graphql'; // This is the path to your GraphQL endpoint
new window.EmbeddedSandbox({
target: '#embedded-sandbox',
// Dynamically set the initialEndpoint
initialEndpoint: `${baseUrl}${graphqlPath}`,
includeCookies: false, // Or true, depending on your needs
});
</script>
- Serve Static Content in Ktor:
Update yourconfigureRouting()
function inApplication.kt
to serve static resources from the playground directory.File:Application.kt
(inside configureRouting)
private fun Application.configureRouting() {
routing {
staticResources("playground", "playground")
graphQLGetRoute()
graphQLPostRoute()
graphQLSubscriptionsRoute("graphql")
graphiQLRoute()
graphQLSDLRoute()
}
}
What's next?
Build a web or mobile app to interact with your new API!
I hope this part-two of this series has been a valuable learning experience. The combination of Kotlin's elegance, Ktor's simplicity, and GraphQL's flexibility is truly a joy to work with.
📂 Full Project Code on GitHub!
Want to dive deeper into the code or run this project yourself? I've made the complete source code for this available on GitHub!
You'll find all the setup, service logic, GraphQL schema definitions, and configurations we've discussed in these articles. It's a great way to see how all the pieces fit together and to use it as a starting point for your own amazing GraphQL projects.
➡️ Check out the repository here: DCNYtLoader
Feel free to fork it, star it if you find it helpful, and explore!
share your experiences, challenges, or any cool features you've added in the comments below! Happy Hacking! 👇