Skip to content

Commit e11aa38

Browse files
feat: download subtitles
1 parent d560ac3 commit e11aa38

16 files changed

Lines changed: 361 additions & 258 deletions

File tree

android/src/main/kotlin/project/pipepipe/app/download/DownloadWorker.kt

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -136,13 +136,18 @@ class DownloadWorker(
136136

137137
val request = YoutubeDLRequest(download.url).apply {
138138
// Format selection
139-
addOption("-f", download.format_id)
140-
141-
println(download.codec)
142-
if (download.download_type == "AUDIO") {
143-
addOption("--remux-video", "webm>opus")
139+
if (download.download_type == "SUBTITLE") {
140+
addOption("--skip-download")
141+
addOption("--write-sub")
142+
addOption("--write-auto-sub")
143+
addOption("--sub-lang", download.format_id)
144+
} else {
145+
addOption("-f", download.format_id)
146+
addOption("--embed-thumbnail")
147+
if (download.download_type == "AUDIO") {
148+
addOption("--remux-video", "webm>opus")
149+
}
144150
}
145-
146151
// Output configuration
147152
addOption("-P", cacheDir.absolutePath)
148153
addOption("-o", "%(title).200B.%(ext)s")
@@ -158,10 +163,6 @@ class DownloadWorker(
158163

159164
// Quiet mode to reduce output noise
160165
addOption("--no-warnings")
161-
162-
addOption("--embed-thumbnail")
163-
addOption("--embed-subs")
164-
addOption("--compat-options", "no-live-chat")
165166
}
166167

167168
Log.d(TAG, "Download request built for format: ${download.format_id}")
@@ -253,7 +254,7 @@ class DownloadWorker(
253254
// Determine final destination based on download type
254255
val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
255256
val appSubDir = when (DownloadType.valueOf(download.download_type)) {
256-
DownloadType.VIDEO -> "PipePipe/Videos"
257+
DownloadType.VIDEO, DownloadType.SUBTITLE -> "PipePipe/Videos"
257258
DownloadType.AUDIO -> "PipePipe/Audio"
258259
}
259260

android/src/main/kotlin/project/pipepipe/app/download/YtDlpFormatHelper.kt

Lines changed: 59 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,18 @@ package project.pipepipe.app.download
33
import android.util.Log
44
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
55
import com.fasterxml.jackson.annotation.JsonProperty
6+
import com.fasterxml.jackson.core.type.TypeReference
67
import com.yausername.youtubedl_android.YoutubeDL
78
import com.yausername.youtubedl_android.YoutubeDLRequest
89
import kotlinx.coroutines.Dispatchers
910
import kotlinx.coroutines.withContext
1011
import org.w3c.dom.Element
1112
import project.pipepipe.app.SharedContext
13+
import project.pipepipe.app.helper.CookieManager
1214
import project.pipepipe.app.helper.FormatHelper
15+
import project.pipepipe.app.helper.isLoggedInCookie
1316
import project.pipepipe.app.ui.component.Format
17+
import project.pipepipe.shared.utils.json.requireString
1418
import java.io.ByteArrayInputStream
1519
import javax.xml.parsers.DocumentBuilderFactory
1620
import kotlin.math.min
@@ -85,20 +89,12 @@ object YtDlpFormatHelper {
8589
fun getFileSize(): Long? = filesize ?: filesizeApprox
8690
}
8791

88-
@JsonIgnoreProperties(ignoreUnknown = true)
89-
data class YtDlpVideoInfo(
90-
@JsonProperty("id") val id: String = "",
91-
@JsonProperty("title") val title: String = "",
92-
@JsonProperty("formats") val formats: List<YtDlpFormat>? = null,
93-
@JsonProperty("requested_formats") val requestedFormats: List<YtDlpFormat>? = null,
94-
@JsonProperty("duration") val duration: Float? = null,
95-
@JsonProperty("thumbnail") val thumbnail: String? = null
96-
)
97-
9892
data class FormatsResult(
9993
val videoFormats: List<YtDlpFormat>,
10094
val audioFormats: List<YtDlpFormat>,
101-
val combinedFormats: List<YtDlpFormat>
95+
val combinedFormats: List<YtDlpFormat>,
96+
val subtitles: List<String>,
97+
val autoCaption: String? = null // Preferred language that only exists in auto_captions
10298
)
10399

104100
/**
@@ -124,11 +120,48 @@ object YtDlpFormatHelper {
124120
return@withContext Result.failure(Exception("Failed to fetch formats: ${response.err}"))
125121
}
126122

127-
val videoInfo = objectMapper.readValue(response.out, YtDlpVideoInfo::class.java)
128-
val allFormats = videoInfo.formats ?: emptyList()
123+
// Parse JSON once
124+
val jsonNode = objectMapper.readTree(response.out)
125+
126+
// Get formats list
127+
val formatsNode = jsonNode.get("formats")
128+
val allFormats = if (formatsNode != null && formatsNode.isArray) {
129+
objectMapper.convertValue(formatsNode, object : TypeReference<List<YtDlpFormat>>() {})
130+
} else {
131+
emptyList()
132+
}
129133

130134
Log.d(TAG, "Found ${allFormats.size} total formats")
131135

136+
// Get preferred subtitle language
137+
val preferredLang = SharedContext.settingsManager.getString("preferred_subtitle_language_key", "en")
138+
139+
// Get subtitles keys (only read field names, not full content)
140+
val subtitlesKeys = jsonNode.get("subtitles")
141+
?.fieldNames()
142+
?.asSequence()
143+
?.toList() ?: emptyList()
144+
145+
// Determine autoCaption based on preference
146+
var autoCaption = if (preferredLang !in subtitlesKeys) {
147+
// Preferred language not in manual subtitles, check auto captions
148+
jsonNode.get("automatic_captions")
149+
?.fieldNames()
150+
?.asSequence()
151+
?.toList()
152+
?.find { it == preferredLang }
153+
} else {
154+
// Preferred language exists in manual subtitles, no need for auto caption
155+
null
156+
}
157+
158+
if (jsonNode.requireString("webpage_url_domain").contains("youtube", ignoreCase = true) &&
159+
CookieManager.getCookie(0)?.isLoggedInCookie() == null) {
160+
autoCaption = null
161+
}
162+
163+
Log.d(TAG, "Preferred subtitle: $preferredLang, autoCaption: $autoCaption")
164+
132165
// Separate formats by type
133166
val videoOnly = allFormats.filter { it.isVideoOnly() }
134167
.sortedWith(
@@ -161,7 +194,9 @@ object YtDlpFormatHelper {
161194
FormatsResult(
162195
videoFormats = videoOnly,
163196
audioFormats = audioOnly,
164-
combinedFormats = combined
197+
combinedFormats = combined,
198+
subtitles = subtitlesKeys,
199+
autoCaption = autoCaption
165200
)
166201
)
167202
} catch (e: Exception) {
@@ -172,10 +207,12 @@ object YtDlpFormatHelper {
172207

173208
/**
174209
* Parse formats from DASH manifest XML
210+
* Returns: Pair of (videoFormats, audioFormats, subtitleIds)
175211
*/
176-
fun parseFormatsFromDashManifest(dashManifest: String, url: String): Pair<List<Format>, List<Format>> {
212+
fun parseFormatsFromDashManifest(dashManifest: String, url: String): Triple<List<Format>, List<Format>, List<String>> {
177213
val videoFormats = mutableListOf<Format>()
178214
val audioFormats = mutableListOf<Format>()
215+
val subtitleIds = mutableListOf<String>()
179216

180217
try {
181218
val factory = DocumentBuilderFactory.newInstance()
@@ -234,6 +271,12 @@ object YtDlpFormatHelper {
234271
)
235272
)
236273
}
274+
"text" -> {
275+
// Extract subtitle id
276+
if (repId.isNotEmpty()) {
277+
subtitleIds.add(repId)
278+
}
279+
}
237280
}
238281
}
239282
}
@@ -258,6 +301,6 @@ object YtDlpFormatHelper {
258301
)
259302
.distinctBy { "${it.codec}_${it.bitrate}" }
260303

261-
return sortedVideos to sortedAudios
304+
return Triple(sortedVideos, sortedAudios, subtitleIds)
262305
}
263306
}

android/src/main/kotlin/project/pipepipe/app/ui/component/DownloadFormatDialog.kt

Lines changed: 102 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import project.pipepipe.app.database.DatabaseOperations
2424
import project.pipepipe.app.download.DownloadManagerHolder
2525
import project.pipepipe.app.helper.FormatHelper
2626
import project.pipepipe.app.download.YtDlpFormatHelper
27+
import project.pipepipe.app.helper.LanguageHelper.getLocalizedLanguageName
2728
import project.pipepipe.app.uistate.DownloadType
2829
import project.pipepipe.shared.infoitem.StreamInfo
2930

@@ -69,6 +70,9 @@ fun DownloadFormatDialog(
6970
var loadError by remember { mutableStateOf<String?>(null) }
7071
var videoFormats by remember { mutableStateOf<List<Format>>(emptyList()) }
7172
var audioFormats by remember { mutableStateOf<List<Format>>(emptyList()) }
73+
var subtitleFormats by remember { mutableStateOf<List<Format>>(emptyList()) }
74+
75+
val autoCaptionMsg = stringResource(MR.strings.player_subtitle_auto_generated)
7276

7377
// State for permission handling
7478
var showPermissionDialog by remember { mutableStateOf(false) }
@@ -101,9 +105,22 @@ fun DownloadFormatDialog(
101105
try {
102106
if (!streamInfo.dashManifest.isNullOrEmpty()) {
103107
// Parse from dashManifest
104-
val (videos, audios) = YtDlpFormatHelper.parseFormatsFromDashManifest(streamInfo.dashManifest!!, streamInfo.url)
108+
val (videos, audios, subtitleIds) = YtDlpFormatHelper.parseFormatsFromDashManifest(streamInfo.dashManifest!!, streamInfo.url)
105109
videoFormats = videos
106110
audioFormats = audios
111+
// Build subtitle formats from subtitleIds
112+
subtitleFormats = subtitleIds
113+
.map { it.substringAfterLast('.') }
114+
.distinct()
115+
.map { lang ->
116+
Format(
117+
id = lang,
118+
url = "",
119+
displayLabel = getLocalizedLanguageName(lang),
120+
codec = "vtt",
121+
filesize = null
122+
)
123+
}
107124
isLoadingFormats = false
108125
} else {
109126
// Fetch from yt-dlp
@@ -138,6 +155,31 @@ fun DownloadFormatDialog(
138155
)
139156
}.distinctBy { it.displayLabel }
140157

158+
// Build subtitle formats from subtitles list and autoCaption
159+
val subtitleList = formatsResult.subtitles.map { lang ->
160+
Format(
161+
id = lang,
162+
url = "",
163+
displayLabel = getLocalizedLanguageName(lang),
164+
codec = "srt",
165+
filesize = null
166+
)
167+
}
168+
val autoCaptionFormat = formatsResult.autoCaption?.let { lang ->
169+
Format(
170+
id = lang,
171+
url = "",
172+
displayLabel = "${getLocalizedLanguageName(lang)} ($autoCaptionMsg)",
173+
codec = "srt",
174+
filesize = null
175+
)
176+
}
177+
subtitleFormats = if (autoCaptionFormat != null) {
178+
subtitleList + autoCaptionFormat
179+
} else {
180+
subtitleList
181+
}
182+
141183
isLoadingFormats = false
142184
}.onFailure { e ->
143185
loadError = "Failed to load formats: ${e.message}"
@@ -154,8 +196,10 @@ fun DownloadFormatDialog(
154196
var selectedType by remember { mutableStateOf(DownloadType.VIDEO) }
155197
var selectedVideoFormat by remember { mutableStateOf<Format?>(null) }
156198
var selectedAudioFormat by remember { mutableStateOf<Format?>(null) }
199+
var selectedSubtitleFormat by remember { mutableStateOf<Format?>(null) }
157200
var videoDropdownExpanded by remember { mutableStateOf(false) }
158201
var audioDropdownExpanded by remember { mutableStateOf(false) }
202+
var subtitleDropdownExpanded by remember { mutableStateOf(false) }
159203

160204
// Get best audio format for calculating total video download size
161205
val bestAudioFormat = remember(audioFormats) {
@@ -175,6 +219,12 @@ fun DownloadFormatDialog(
175219
}
176220
}
177221

222+
LaunchedEffect(subtitleFormats) {
223+
if (subtitleFormats.isNotEmpty() && selectedSubtitleFormat == null) {
224+
selectedSubtitleFormat = subtitleFormats.first()
225+
}
226+
}
227+
178228
// Perform download with selected format
179229
val performDownload: (Format, DownloadType) -> Unit = { format, type ->
180230
val finalFormatId = if (type == DownloadType.VIDEO && format.isVideoOnly) {
@@ -187,10 +237,10 @@ fun DownloadFormatDialog(
187237
url = streamInfo.url,
188238
title = streamInfo.name ?: "Unknown",
189239
imageUrl = streamInfo.thumbnailUrl,
190-
duration = streamInfo.duration?.toInt() ?: 0,
240+
duration = if (type == DownloadType.SUBTITLE) 0 else streamInfo.duration?.toInt() ?: 0,
191241
downloadType = type,
192242
quality = format.displayLabel,
193-
codec = FormatHelper.parseCodecName(format.codec),
243+
codec = if (type == DownloadType.SUBTITLE) "srt" else FormatHelper.parseCodecName(format.codec),
194244
formatId = finalFormatId
195245
)
196246
}
@@ -214,7 +264,7 @@ fun DownloadFormatDialog(
214264
overflow = TextOverflow.Ellipsis
215265
)
216266

217-
// Download Type Selection (Video/Audio)
267+
// Download Type Selection (Video/Audio/Subtitle)
218268
Row(
219269
modifier = Modifier
220270
.fillMaxWidth()
@@ -236,6 +286,14 @@ fun DownloadFormatDialog(
236286
modifier = Modifier.weight(1f),
237287
enabled = !isLoadingFormats && loadError == null && audioFormats.isNotEmpty()
238288
)
289+
290+
FilterChip(
291+
selected = selectedType == DownloadType.SUBTITLE,
292+
onClick = { selectedType = DownloadType.SUBTITLE },
293+
label = { Text(stringResource(MR.strings.download_subtitles)) },
294+
modifier = Modifier.weight(1f),
295+
enabled = !isLoadingFormats && loadError == null && subtitleFormats.isNotEmpty()
296+
)
239297
}
240298

241299
// Content area based on state
@@ -382,6 +440,43 @@ fun DownloadFormatDialog(
382440
}
383441
}
384442
}
443+
444+
DownloadType.SUBTITLE -> {
445+
if (subtitleFormats.isNotEmpty()) {
446+
ExposedDropdownMenuBox(
447+
expanded = subtitleDropdownExpanded,
448+
onExpandedChange = { subtitleDropdownExpanded = it }
449+
) {
450+
OutlinedTextField(
451+
value = selectedSubtitleFormat?.displayLabel
452+
?: "Select subtitle",
453+
onValueChange = {},
454+
readOnly = true,
455+
label = { Text(stringResource(MR.strings.download_subtitles)) },
456+
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = subtitleDropdownExpanded) },
457+
modifier = Modifier
458+
.fillMaxWidth()
459+
.menuAnchor()
460+
)
461+
ExposedDropdownMenu(
462+
expanded = subtitleDropdownExpanded,
463+
onDismissRequest = { subtitleDropdownExpanded = false }
464+
) {
465+
subtitleFormats.forEach { format ->
466+
DropdownMenuItem(
467+
text = {
468+
Text(format.displayLabel)
469+
},
470+
onClick = {
471+
selectedSubtitleFormat = format
472+
subtitleDropdownExpanded = false
473+
}
474+
)
475+
}
476+
}
477+
}
478+
}
479+
}
385480
}
386481
}
387482
}
@@ -393,6 +488,7 @@ fun DownloadFormatDialog(
393488
val selectedFormat = when (selectedType) {
394489
DownloadType.VIDEO -> selectedVideoFormat
395490
DownloadType.AUDIO -> selectedAudioFormat
491+
DownloadType.SUBTITLE -> selectedSubtitleFormat
396492
}
397493

398494
selectedFormat?.let { format ->
@@ -444,6 +540,7 @@ fun DownloadFormatDialog(
444540
enabled = when (selectedType) {
445541
DownloadType.VIDEO -> selectedVideoFormat != null
446542
DownloadType.AUDIO -> selectedAudioFormat != null
543+
DownloadType.SUBTITLE -> selectedSubtitleFormat != null
447544
}
448545
) {
449546
Text(stringResource(MR.strings.download))
@@ -477,6 +574,7 @@ fun DownloadFormatDialog(
477574
val selectedFormat = when (selectedType) {
478575
DownloadType.VIDEO -> selectedVideoFormat
479576
DownloadType.AUDIO -> selectedAudioFormat
577+
DownloadType.SUBTITLE -> selectedSubtitleFormat
480578
}
481579

482580
AlertDialog(

0 commit comments

Comments
 (0)