YouTube-Downloader ueber den Baka-Proxy inkl. Anmeldung und MediaStore

Neuer YouTube-Bereich im Online-Tab: Adresse einfuegen, herunterladen, der
Titel landet in Meine Musik.

- BakaAuth: Anmeldung gegen baka-net.de/auth, Token verschluesselt auf dem
  Geraet. Der Server meldet Fehler mit HTTP 200 und Text im Rumpf, deshalb
  wird der Inhalt geprueft statt nur der Statuscode.
- YtDownloadService: Proxy fragen, MP3 abholen, halbe Dateien aufraeumen.
  Kuenstler nur aus "Kuenstler - Lied", sonst leer statt geraten.
- MediaStoreBridge (Kotlin): legt die Datei in Music/Melo ab und meldet sie
  dem MediaStore. Noetig, weil der Android-Scan sonst beim naechsten Lauf
  alles wieder als verschwunden markiert.
- SubTabs nach shared/ gezogen (jetzt in Meine Musik und Online genutzt).

177 Tests gruen, flutter analyze ohne Befund, Release-APK baut.
Der Weg ueber den echten Proxy ist noch nicht auf dem Geraet erprobt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018NLW1q1hzEC9goDQ1dJ98d
This commit is contained in:
Hermes (Server)
2026-08-20 19:13:55 +02:00
co-authored by Claude Opus 5
parent a625d70da8
commit 6200c77614
17 changed files with 1210 additions and 51 deletions
+4
View File
@@ -13,6 +13,10 @@
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32"/>
<!-- Heruntergeladene Musik in Music/Melo ablegen. Ab Android 10 uebernimmt
das der MediaStore ohne Berechtigung, davor braucht es Schreibrechte. -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28"/>
<application
android:label="melo"
android:name="${applicationName}"
@@ -15,6 +15,12 @@ class MainActivity : AudioServiceActivity() {
flutterEngine.dartExecutor.binaryMessenger,
AudioEffectsBridge.CHANNEL
).setMethodCallHandler(bridge::handle)
val mediaStore = MediaStoreBridge(applicationContext)
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
MediaStoreBridge.CHANNEL
).setMethodCallHandler(mediaStore::handle)
}
override fun onDestroy() {
@@ -0,0 +1,130 @@
package de.baka.melo
import android.content.ContentValues
import android.content.Context
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.io.File
/**
* Traegt heruntergeladene Musik in den oeffentlichen Musikordner ein
* (Music/Melo) und meldet sie dem MediaStore.
*
* Warum ueberhaupt: der Bibliotheks-Scan liest auf Android den MediaStore.
* Eine Datei, die dort nicht steht, waere beim naechsten Scan wieder weg.
*/
class MediaStoreBridge(private val context: Context) {
companion object {
const val CHANNEL = "de.baka.melo/media_store"
private const val UNTERORDNER = "Melo"
}
fun handle(call: MethodCall, result: MethodChannel.Result) {
if (call.method != "publishAudio") {
result.notImplemented()
return
}
val quelle = call.argument<String>("sourcePath")
if (quelle == null) {
result.error("args", "sourcePath fehlt", null)
return
}
try {
result.success(
publish(
File(quelle),
call.argument<String>("title") ?: File(quelle).nameWithoutExtension,
call.argument<String>("artist")
)
)
} catch (e: Exception) {
result.error("io", e.message, null)
}
}
private fun publish(quelle: File, titel: String, kuenstler: String?): String {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
publishViaMediaStore(quelle, titel, kuenstler)
} else {
publishViaDateisystem(quelle, titel, kuenstler)
}
}
/** Android 10+: Scoped Storage — der MediaStore vergibt den Platz. */
private fun publishViaMediaStore(quelle: File, titel: String, kuenstler: String?): String {
val werte = ContentValues().apply {
put(MediaStore.Audio.Media.DISPLAY_NAME, quelle.name)
put(MediaStore.Audio.Media.MIME_TYPE, "audio/mpeg")
put(MediaStore.Audio.Media.TITLE, titel)
put(MediaStore.Audio.Media.IS_MUSIC, 1)
kuenstler?.takeIf { it.isNotBlank() }
?.let { put(MediaStore.Audio.Media.ARTIST, it) }
put(
MediaStore.Audio.Media.RELATIVE_PATH,
"${Environment.DIRECTORY_MUSIC}/$UNTERORDNER"
)
// Solange 1, sieht keine andere App die halbfertige Datei.
put(MediaStore.Audio.Media.IS_PENDING, 1)
}
val aufloeser = context.contentResolver
val ziel = aufloeser.insert(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, werte)
?: throw IllegalStateException("MediaStore nahm den Eintrag nicht an")
try {
aufloeser.openOutputStream(ziel).use { aus ->
requireNotNull(aus) { "Kein Schreibzugriff auf den Eintrag" }
quelle.inputStream().use { ein -> ein.copyTo(aus) }
}
} catch (e: Exception) {
aufloeser.delete(ziel, null, null)
throw e
}
aufloeser.update(
ziel,
ContentValues().apply { put(MediaStore.Audio.Media.IS_PENDING, 0) },
null,
null
)
quelle.delete()
// Der Scan gleicht ueber den Dateipfad ab, nicht ueber die URI —
// deshalb den echten Pfad nachschlagen.
aufloeser.query(ziel, arrayOf(MediaStore.Audio.Media.DATA), null, null, null)
?.use { c ->
if (c.moveToFirst()) {
c.getString(0)?.let { return it }
}
}
return ziel.toString()
}
/** Android 9 und aelter: direkt in den Musikordner, dann scannen lassen. */
private fun publishViaDateisystem(quelle: File, titel: String, kuenstler: String?): String {
val ordner = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC),
UNTERORDNER
)
ordner.mkdirs()
val ziel = File(ordner, quelle.name)
quelle.copyTo(ziel, overwrite = true)
quelle.delete()
val werte = ContentValues().apply {
put(MediaStore.Audio.Media.DATA, ziel.absolutePath)
put(MediaStore.Audio.Media.DISPLAY_NAME, ziel.name)
put(MediaStore.Audio.Media.MIME_TYPE, "audio/mpeg")
put(MediaStore.Audio.Media.TITLE, titel)
put(MediaStore.Audio.Media.IS_MUSIC, 1)
kuenstler?.takeIf { it.isNotBlank() }
?.let { put(MediaStore.Audio.Media.ARTIST, it) }
}
context.contentResolver.insert(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, werte)
return ziel.absolutePath
}
}