Skip to content

Commit

Permalink
Add basic playlist support (#116)
Browse files Browse the repository at this point in the history
First steps to good progress support.

What works: 
- render playlist entries
- download individual playlist entries

For todos see
#117
  • Loading branch information
StefanLobbenmeier authored Dec 30, 2024
1 parent cd72b54 commit 9a22a03
Show file tree
Hide file tree
Showing 11 changed files with 290 additions and 102 deletions.
26 changes: 26 additions & 0 deletions src/main/kotlin/de/lobbenmeier/stefan/common/ui/ReloadUi.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package de.lobbenmeier.stefan.common.ui

import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import kotlin.time.Duration
import kotlinx.coroutines.delay

@Composable
fun reloadUiEvery(interval: Duration): State<Long> {
val currentTime = remember { mutableStateOf(System.currentTimeMillis()) }
currentTimeEvery(interval) { currentTime.value = it }
return currentTime
}

@Composable
fun currentTimeEvery(interval: Duration, onIntervalPassed: (Long) -> Unit) {
LaunchedEffect(Unit) {
while (true) {
delay(interval)
onIntervalPassed(System.currentTimeMillis())
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,46 +20,61 @@ class DownloadItem(
val url: String = "https://www.youtube.com/watch?v=CBB75zjxTR4"
) {

val logger = KotlinLogging.logger {}
val key = "$url ${System.currentTimeMillis()}"
val metadata = MutableStateFlow<VideoMetadata?>(null)
val metadataFile = MutableStateFlow<File?>(null)
val downloadProgress = MutableStateFlow<VideoDownloadProgress?>(null)
val targetFile = MutableStateFlow<File?>(null)
val format = DownloadItemFormat()

private val logger = KotlinLogging.logger {}
private val metadataFile = MutableStateFlow<File?>(null)
private val targetFile = mutableMapOf<Int, MutableStateFlow<File?>>()
private val downloadProgress = mutableMapOf<Int, MutableStateFlow<VideoDownloadProgress?>>()

companion object {
private const val PROGRESS_PREFIX = "[download-progress]"
private const val PROGRESS_TEMPLATE = "$PROGRESS_PREFIX%(progress)j"
private const val VIDEO_METADATA_JSON_PREFIX = "[video-metadata-json]"
}

fun download(selectedVideoOption: Format?, selectedAudioOption: Format?) {
doDownload(
*selectFormats(selectedVideoOption, selectedAudioOption),
progressFlow = getProgress(),
targetFile = getTargetFile(),
)
}

fun downloadPlaylistEntry(index: Int) {
val indexForYtDlp = index + 1
doDownload(
"--playlist-items",
"$indexForYtDlp",
progressFlow = getProgress(index),
targetFile = getTargetFile(index),
)
}

private fun doDownload(
vararg extraOptions: String,
progressFlow: MutableStateFlow<VideoDownloadProgress?>,
targetFile: MutableStateFlow<File?>,
) {
CoroutineScope(Dispatchers.IO).launch {
downloadProgress.emit(DownloadStarted)
progressFlow.emit(DownloadStarted)
targetFile.emit(null)
var videoMetadata: VideoMetadata? = null
try {
ytDlp.runAsync(
true,
// Print the whole object again so we get the filename
"--print",
"$VIDEO_METADATA_JSON_PREFIX%()j",
// Required because of the print
"--no-simulate",
"--no-quiet",
*selectFormats(selectedVideoOption, selectedAudioOption),
*useCachedMetadata(),
"--progress-template",
PROGRESS_TEMPLATE,
url,
) { log ->
options = downloadOptions(*extraOptions),
) { log, _ ->
when {
log.startsWith(PROGRESS_PREFIX) -> {
val progressJson = log.removePrefix(PROGRESS_PREFIX)
try {
val progress =
YtDlpJson.decodeFromString<YtDlpDownloadProgress>(progressJson)
downloadProgress.emit(progress)
progressFlow.emit(progress)
} catch (e: Exception) {
logger.warn(e) { "Failed to parse progressJson $progressJson" }
}
Expand All @@ -78,15 +93,32 @@ class DownloadItem(
}
}
}
downloadProgress.emit(DownloadCompleted)
progressFlow.emit(DownloadCompleted)
videoMetadata?.filename?.let { targetFile.emit(File(it)) }
} catch (e: Exception) {
e.printStackTrace()
downloadProgress.emit(DownloadFailed(e))
progressFlow.emit(DownloadFailed(e))
}
}
}

private fun downloadOptions(vararg extraOptions: String = arrayOf()) =
arrayOf(
"--print",
"$VIDEO_METADATA_JSON_PREFIX%()j",
// Required because of the print
"--no-simulate",
"--no-quiet",
*useCachedMetadata(),

// --no-clean-info-json allows you to reuse json for playlists
"--no-clean-info-json",
"--progress-template",
PROGRESS_TEMPLATE,
*extraOptions,
url,
)

private fun selectFormats(
selectedVideoOption: Format?,
selectedAudioOption: Format?
Expand All @@ -106,22 +138,30 @@ class DownloadItem(
?: emptyArray<String>()
}

fun getProgress(index: Int? = null): MutableStateFlow<VideoDownloadProgress?> {
val key = index ?: -1
return downloadProgress.computeIfAbsent(key) { MutableStateFlow(null) }
}

fun getTargetFile(index: Int? = null): MutableStateFlow<File?> {
val key = index ?: -1
return targetFile.computeIfAbsent(key) { MutableStateFlow(null) }
}

fun gatherMetadata() {
CoroutineScope(Dispatchers.IO).launch {
ytDlp.runAsync(
false,
"--print",
"$VIDEO_METADATA_JSON_PREFIX%()j",
"--dump-single-json",
"--no-clean-info-json",
"--flat-playlist",
url,
) { log ->
when {
log.startsWith(VIDEO_METADATA_JSON_PREFIX) -> {
val videoMedataJson = log.removePrefix(VIDEO_METADATA_JSON_PREFIX)
val videoMetadata =
YtDlpJson.decodeFromString<VideoMetadata>(videoMedataJson)
) { log, logLevel ->
when (logLevel) {
LogLevel.STDOUT -> {
val videoMetadata = YtDlpJson.decodeFromString<VideoMetadata>(log)
metadata.value = videoMetadata
async { writeMetadataToFile(videoMedataJson) }
async { writeMetadataToFile(log) }

val requestedFormats = videoMetadata.requestedFormats
if (requestedFormats != null) {
Expand All @@ -137,7 +177,7 @@ class DownloadItem(
}
}
}
else -> {
LogLevel.STDERR -> {
logger.info { log }
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package de.lobbenmeier.stefan.downloadlist.business

enum class LogLevel {
STDOUT,
STDERR,
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package de.lobbenmeier.stefan.downloadlist.business

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class VideoMetadata(
val id: String?,
val duration: Double?,
val filename: String?,
val formats: List<Format>?,
Expand All @@ -12,11 +14,18 @@ data class VideoMetadata(
val thumbnails: List<Thumbnail>?,
val title: String?,
val webpageUrl: String?,

// playlist and playlist items only
@SerialName("_type") val type: String?,
val entries: List<VideoMetadata>?,
)

val VideoMetadata.thumbnailWithFallBack
get() = thumbnail ?: thumbnails?.lastOrNull()?.url

val VideoMetadata.entryThumbnail
get() = thumbnails?.firstOrNull()?.url ?: thumbnail

@Serializable
data class Format(
val formatId: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ class YtDlp(private val binaries: Binaries, private val settings: Settings) {
suspend fun runAsync(
isDownloadJob: Boolean,
vararg options: String,
consumer: suspend (String) -> Unit = { line -> println("process $line") }
consumer: suspend (String, LogLevel) -> Unit = { line, _ -> println("process $line") }
) {
val ytDlpBinary = binaries.ytDlp.pathString
val ffmpegBinary = binaries.ffmpeg.pathString
Expand All @@ -43,8 +43,8 @@ class YtDlp(private val binaries: Binaries, private val settings: Settings) {
process(
ytDlpBinary,
*fullOptions,
stdout = Redirect.Consume { it.collect(consumer) },
stderr = Redirect.Consume { it.collect(consumer) }
stdout = Redirect.Consume { it.collect { consumer(it, LogLevel.STDOUT) } },
stderr = Redirect.Consume { it.collect { consumer(it, LogLevel.STDERR) } }
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package de.lobbenmeier.stefan.downloadlist.ui

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import compose.icons.FeatherIcons
import compose.icons.feathericons.Download
import de.lobbenmeier.stefan.downloadlist.business.DownloadItem
import de.lobbenmeier.stefan.downloadlist.business.VideoMetadata
import de.lobbenmeier.stefan.downloadlist.business.entryThumbnail

private val entryHeight = 50.dp

@Composable
fun DownloadItemPlaylistEntriesView(downloadItem: DownloadItem) {
val metadata = downloadItem.metadata.collectAsState().value

if (metadata == null || metadata.type != "playlist") {
return
}

if (metadata.entries.isNullOrEmpty()) {
return Text(text = "Playlist is empty")
}

LazyColumn(
modifier = Modifier.height(entryHeight * minOf(metadata.entries.size, 5)),
userScrollEnabled = true
) {
itemsIndexed(
metadata.entries,
itemContent = { index, entry -> PlaylistEntryView(downloadItem, index, entry) }
)
}
}

@Composable
fun PlaylistEntryView(downloadItem: DownloadItem, index: Int, entry: VideoMetadata) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(all = 4.dp).height(entryHeight)
) {
Thumbnail(entry.entryThumbnail)
Spacer(Modifier.width(4.dp))

Column(
modifier = Modifier.fillMaxHeight().weight(1f),
verticalArrangement = Arrangement.SpaceAround,
) {
Text(
text = entry.title ?: "No Title",
fontWeight = FontWeight.Bold,
maxLines = 1,
)

val progressNonFinal by downloadItem.getProgress(index).collectAsState()
val progress = progressNonFinal
if (progress != null) {
DownloadProgressIndicator(progress, modifier = Modifier.height(5.dp))
}
}

IconButton(onClick = { downloadItem.downloadPlaylistEntry(index) }) {
Icon(FeatherIcons.Download, "Download")
}
val targetFile = downloadItem.getTargetFile(index).collectAsState().value
if (targetFile != null) {
BrowseFileButton(targetFile)
}
}
}
Loading

0 comments on commit 9a22a03

Please sign in to comment.