All work
Sideloaded onto her phone

YT Playlist Downloader

A whole playlist, offline, tagged properly.

An Android app I built for my girlfriend. Share a YouTube playlist into it and every song lands on her phone — skipping the ones she already has, and tagged with its title, artist and cover so Samsung Music shows them properly.

yt-dlp, running on the phone
Role
Design & development, solo
Built for
My girlfriend
Built
June 2026 · rebuilt end of August
Stack
Kotlin · Jetpack Compose · Python

01Why it exists

She wanted a whole playlist offline. One video at a time was never going to happen.

My girlfriend loves saving things from YouTube — music, mostly. She had a playlist she wanted on her phone so she could listen offline, and downloading it one video at a time was such a hassle.

So I made a playlist downloader. Paste the link, or share it straight from the YouTube app, and it fetches the whole thing.

It also checks what we’ve already downloaded, so nothing comes down twice, and it writes each song’s title, artist and cover into the file. It plays straight away in Samsung Music, looking like it belongs there. The short version: she doesn’t have to pay for Spotify.

The first version, in June, was one screen that dropped files into Downloads. At the end of August I rebuilt it as a proper app — downloads that carry on in the background, a library, and playlists.

02Try it

Run it once. Then run it again.

Ten songs in the playlist, six already on her phone. Press Download on the phone and follow each song through the app — then press it again. Delete a song in between and see what comes back.

YouTube linkhttps://youtube.com/playlist?list=PLq7Xc2RtB8kd

Save as

Music is saved to your Music folder, so it shows up in Samsung Music. Videos go to your Movies folder. Everything is listed in Library.

Press Download on the phone.

What the app sees

Late Night Drives · 10 videos

Already have
–
To fetch
–
Saved
–
Python → KotlinWaiting for a link.

    Last song saved

    Samsung Music · Albums

    Illustration rebuilt in HTML from the app’s own colours, layout and wording, with a mostly invented playlist — not a screenshot. The order of steps, the skip rule and the messages on the phone are the app’s; the line above the headline and the download speeds are made up. The progress text only changes every 400 ms, because that’s how often the real app reads it.

    03What it does

    Everything she needs. Nothing to sign up for.

    Share it from YouTube

    The app is a share target: tap Share in the YouTube app, pick it, and the link is pulled out of whatever text came with it. Or paste with one tap.

    Keeps going in the background

    A foreground service carries on with the screen off. A notification shows progress with a Stop button, then a summary when it’s done.

    Never the same song twice

    Every download is remembered by video and format. A song she deleted no longer counts as saved, so it comes back on the next run.

    Tagged for her music app

    Title, artist, album and cover go into every song — and a playlist stays together as one album instead of one per singer.

    Music, or video when it can

    Music is YouTube’s own AAC audio, kept as .m4a with no re-encoding. Video works when YouTube offers sound and picture in one stream; when it doesn’t, the app says so and suggests Music.

    Library and playlists

    Search, filter by music or video, five ways to sort, play with any app, delete with a confirm. Each playlist gets a 2×2 cover made from its songs.

    04How it’s built

    Kotlin outside. Python inside.

    The app is Kotlin and Jetpack Compose, but the downloading is done by yt-dlp — a Python program — running on the phone itself. About 2,370 lines of Kotlin and 300 of Python.

    PythonThe Chaquopy Gradle plugin embeds CPython 3.11; pip installs yt-dlp and mutagen at build time. Kotlin calls in with module.callAttr(…), and every Python function answers with a JSON string.
    Audiobestaudio[ext=m4a] — YouTube’s own AAC, saved as it comes. There’s no ffmpeg on the phone, so nothing is converted.
    ShapeOne activity with three tabs → a foreground service → the Python bridge → MediaStore and Room. The service and the screens share state through one StateFlow.
    FailuresFive retries, plus five per fragment. Failures are counted per song; if nothing was saved, the last reason is shown instead of a shrug.
    InstallAn APK, sideloaded onto her phone. Not on the Play Store — no accounts, no API keys, no subscription.
    downloader.py — _write_tags()
    audio["\xa9nam"] = [title]
    if artist:
        audio["\xa9ART"] = [artist]
    # Album artist is what music apps group an album by. Setting it per
    # track splits one playlist into one album per singer, so for a
    # playlist every track must share the same value.
    if album_artist:
        audio["aART"] = [album_artist]
        # Marks it a compilation, which helps players keep it together.
        audio["cpil"] = True
    elif artist:
        audio["aART"] = [artist]
    if album:
        audio["\xa9alb"] = [album]
    if cover_data:
        audio["covr"] = [MP4Cover(
            cover_data,
            imageformat=MP4Cover.FORMAT_PNG if cover_is_png
            else MP4Cover.FORMAT_JPEG,
        )]

    05Build notes

    The fiddly bits, done properly.

    01

    Progress across two languages

    yt-dlp holds Python’s interpreter lock for long stretches, so calling back into Kotlin on every chunk wasn’t an option. Instead the progress hook writes a small JSON file — atomically, at most every 0.4 s — and Kotlin polls it every 400 ms. Stop works in reverse: the hook raises on the next chunk.

    02

    Tags Samsung Music actually reads

    Android’s media index takes the artist from the file’s own tags, so setting the MediaStore column alone wasn’t enough. mutagen writes real MP4 atoms, and every song from a playlist shares one album artist so the playlist stays a single album.

    03

    Scoped storage, done properly

    Songs go into MediaStore under Music/YT Downloader, staged behind IS_PENDING=1 until the copy finishes — a crash can’t leave a half-written track that looks playable. If the insert is refused, it falls back to Download/. Filenames are capped at 150 bytes.

    04

    Dedupe that survives deletes

    A Room table with a unique index on (videoId, format) remembers every download. Before skipping one, the app checks the file is still in MediaStore — so if she deletes a song, it simply comes back next time.