How Apps Like Meld, Musify, and Others Stream Music: A Look Inside the Playback Pipeline
An engineering deep dive into music sources, metadata matching, audio extraction, synchronized lyrics, app architecture, hosting costs, and the legal realities of third-party music players.
Open a third-party music app, search for a song, tap it, and audio starts a second or two later. It feels like one thing happening. It isn’t. Somewhere between your tap and your speaker, at least four separate problems get solved: what is this song, where is a recording of it, how do I get bytes I’m allowed to play, and how do I turn those bytes into sound without freezing the UI.
This post is my attempt to pull those problems apart, using Meld (an Android client that pairs Spotify data with YouTube Music playback) as the main specimen, with Metrolist (the project Meld is forked from) and the official platform docs as cross-references.
How to read this article
Writing about this topic has an obvious failure mode: mixing up what the code does, what the docs promise, and what I’m guessing. So every non-trivial claim below carries one of four labels:
- [Code] — read directly in a public repository. I inspected Meld at commit
9d97fb6(14 October 2026), and I link permalinks to that commit. Line numbers drift, so I cite files and function names. - [Docs] — stated in official documentation or policy, as of 15 October 2026. Policies change; check the linked page before relying on anything here.
- [Reading] — my interpretation of code or docs. Reasonable, but I could be wrong.
- [Unverified] — I couldn’t confirm it, or it may change without notice.
A few limits on method, stated up front:
- I read documentation through a fetch tool that returns processed extracts of pages, not necessarily every word. For the policies that matter most (YouTube developer policies, Spotify developer terms, Google Play’s intellectual property policy), read the linked originals yourself.
- I could not retrieve the text of Apple’s App Review Guideline 5.2 (the page was truncated before that section) or YouTube’s general Terms of Service. I don’t summarise either; see sections 12 and 14.
- I didn’t read the internals of the extraction library Meld calls (the
innertubexpackage imported byInnerTubeXPlayer.kt), only the code in the app that calls it. - This is general information, not legal advice. Section 14 says more.
1. Introduction: Where Does the Music Actually Come From?
Here’s the question that started this: when an app shows you “Artist – Title” with album art and a duration, and then plays it, is the audio coming from the same service as the metadata?
Not necessarily. Often not. And once you see that, a lot of confusing things about these apps make sense: why a play can start slowly the first time and fast afterwards, why a wrong version of a song occasionally plays, why an app can break overnight with no update.
Some vocabulary, because the rest of the article leans on it:
| Term | What it is | Example |
|---|---|---|
| Metadata | Descriptive data about a recording | title, artists, album, duration, artwork URL |
| Identifier | A key that names something inside one service | a Spotify track ID, a YouTube video ID, an ISRC |
| Manifest | A document listing available renditions/segments of media | an HLS or DASH playlist |
| Stream URL | A (usually time-limited) address that serves media bytes | an HTTPS URL a player can request with Range headers |
| Decoded audio | PCM samples produced by a codec from compressed audio | what actually goes to the audio hardware |
A music streaming platform (Spotify, Apple Music, YouTube Music) owns a catalogue, licences it, and serves audio to its own clients. A metadata or discovery API (the Spotify Web API, MusicBrainz) describes music without serving it. A video platform can host recordings, but its primary product is video. Local files and self-hosted libraries are audio you already have. And licensed streaming APIs and openly licensed or public-domain catalogues are the cases where a third-party app gets explicit permission to play audio.
The thing to hold on to for the whole article is this distinction:
Finding a track and obtaining playable audio are different problems, with different rules. The first is mostly data modelling and search. The second is where licensing, platform terms, and fragile engineering all pile up.
2. Spotify Is Not the Only Possible Source
Different source types do different jobs, and they aren’t interchangeable audio backends just because they all have a “play” button in their own apps.
| Source | Good as metadata? | Good for discovery? | Playable audio for a third-party app? |
|---|---|---|---|
| Spotify | Yes, via its Web API | Yes (search, playlists, recommendations of its own) | Not as raw audio. The API can control Spotify’s own playback, but doesn’t hand you a file or stream. See section 3. |
| YouTube / YouTube Music | Partly (titles, channels, durations) | Yes | The official Data API doesn’t provide raw audio. See section 5. |
| SoundCloud | Has its own API and terms | Yes | Depends on its terms and the uploader’s permissions. I haven’t reviewed them for this post. |
| Local files | Embedded tags | No | Yes, if you have the rights to the files. |
| Self-hosted library | Whatever you tagged | Limited to your collection | Yes, for audio you may lawfully store and serve. |
| Licensed catalogues / open catalogues | Usually | Yes | Yes, within each provider’s terms (more in section 16). |
So the useful framing is three roles, not one:
- A source of metadata tells you what a recording is.
- A source of discovery tells you what to listen to (search, charts, taste profiles).
- A source of playable audio gives you bytes to decode, under terms that say you may.
An app can use a different source for each role. Meld’s README describes exactly that split: Spotify for search, home content and recommendations, YouTube Music for the audio [Code: README]. Whether that split is permitted is a separate question from whether it’s technically possible, and the rest of the article keeps those two apart.
3. What Data Can an App Obtain From Spotify?
The official Web API
The Spotify Web API is a REST API. For a single track, the relevant endpoint is GET /v1/tracks/{id}. [Docs] It uses OAuth 2.0, takes an optional market parameter, and returns fields including the track id, name, an artists array, an album object with images (URL, height, width), duration_ms, external_urls.spotify, and external_ids (which carries the ISRC). If track relinking applies, an is_playable flag appears.
Here’s an example request. This is a simplified illustration built from the field list in the reference, not a captured response:
# Official Spotify Web API — metadata only
curl -s "https://api.spotify.com/v1/tracks/11dFghVXANMlKmJXsNCbNl?market=US" \
-H "Authorization: Bearer $SPOTIFY_ACCESS_TOKEN"
{
"id": "11dFghVXANMlKmJXsNCbNl",
"name": "Example Track",
"artists": [
{ "id": "…", "name": "Example Artist", "external_urls": { "spotify": "https://open.spotify.com/artist/…" } }
],
"album": {
"images": [
{ "url": "https://…/large.jpg", "height": 640, "width": 640 },
{ "url": "https://…/small.jpg", "height": 64, "width": 64 }
]
},
"duration_ms": 215000,
"external_urls": { "spotify": "https://open.spotify.com/track/11dFghVXANMlKmJXsNCbNl" },
"external_ids": { "isrc": "XXXXXXXXXXXX" }
}
(The track ID is the sample ID used in Spotify’s own reference page; the rest are placeholders, and there are more fields than I’ve shown.)
Notice what’s missing: there is no audio field. The track object has a preview_url for a 30-second MP3 clip, but the reference marks it deprecated, and says previews can’t be offered as a standalone service [Docs]. The API describes a recording; it does not deliver one. The overview page lists “control playback” among its capabilities, and that means telling a Spotify player to start, seek or inspect its queue, not receiving the audio yourself [Reading].
Access isn’t stable
This is the part I’d tell any developer to read twice. Spotify’s access rules have been tightening in public:
- In April 2025, Spotify announced stricter criteria for “extended” Web API access, effective 15 May 2025. Development mode stays available for experimentation and personal use. Spotify’s stated reason was that most applications failed basic security, privacy and licensing standards [Docs]; that’s Spotify’s claim, and I haven’t independently assessed it.
- In February 2026 a changelog removed a batch of endpoints (batch “get several” lookups, artist top tracks, new releases, and more), removed several fields, and cut the search
limitmaximum from 50 to 10 (default 20 to 5) [Docs]. - The February 2026 migration guide says the app owner needs an active Spotify Premium subscription for a development-mode app to keep working, and that new development-mode apps are limited to 5 users [Docs].
- A reversal worth noting: the February changelog listed
external_ids(the field with the ISRC) for removal on tracks, but the March 2026 changelog says it stays [Docs]. That’s a good example of why you shouldn’t code against what a blog post said last quarter.
And the Developer Terms are explicit about the audio side: they define “Streaming” and allow streaming apps only for private personal use on approved devices, they prohibit facilitating stream ripping, and they restrict caching of Spotify content to narrow cases [Docs]. (I’m paraphrasing from section references I read as II.14, III.1.a, IV.2.b.iii and IV.3.b; check the originals for exact wording.)
What Meld actually does with Spotify
Here the repository and the docs diverge, and it matters.
[Code] Meld doesn’t use the OAuth Web API as its main path. The header comment in Spotify.kt says the client uses Spotify’s internal GraphQL API at api-partner.spotify.com for most operations, falling back to the public REST API only for endpoints without a GraphQL equivalent (top tracks and top artists). Authentication, per SpotifyAuth.kt, goes through the web player’s own token endpoint using a session cookie captured from an in-app WebView login, plus a time-based one-time password derived from a secret that, per the file’s comments, Spotify rotates and that the app fetches from a community-maintained Gist.
[Code] The repo’s AGENTS.md adds that each GraphQL operation needs a SHA-256 hash that Spotify rotates periodically, and that a daily GitHub Actions workflow checks for rotations and publishes the result through GitHub Pages.
[Code] The README is candid about the consequences: it says using unofficial API clients technically falls outside Spotify’s Terms of Service, and tells users to use the app at their own risk.
I’ll come back to this in sections 10 and 14. For now the takeaway is: official Spotify Web API = documented, OAuth, rate-limited, shrinking. What Meld does with Spotify = undocumented web-player endpoints that can change without notice. They’re different things and shouldn’t be conflated.
What Meld uses the Spotify data for
[Code] Reading SpotifyYouTubeMapper.kt and MusicService.kt, Spotify data feeds: search results and home content; library and playlists; queue generation (the README describes a custom scoring engine over your top tracks and artists); album art (the mapper prefers the highest-resolution Spotify cover); the duration used to score YouTube candidates; and the ISRC, which is copied into the resulting MediaMetadata. What it does not do is play Spotify audio. The README states plainly that audio comes through YouTube Music.
4. How Can an App Find the Same Recording on YouTube?
Now the interesting problem. You have a Spotify track: “Artist – Title”, 3:35. You want the YouTube Music item that is the same recording. There’s no shared key between the services in the general case, so the app does a fuzzy join.
The usual ingredients:
- Build a query. Typically artist + title, sometimes with the album.
- Normalize strings on both sides: lowercase, strip punctuation, drop “(feat. …)”, remaster and edition suffixes, normalize Unicode.
- Compare durations. Same recording ± a couple of seconds is a strong signal.
- Score similarity (edit distance, token overlap, n-gram overlap) and combine into a confidence number.
- Penalize variants (live, karaoke, cover, sped-up, instrumental) unless the source title says it’s that variant.
- Pick the best candidate above a threshold, and persist the decision.
Here is an educational example (Kotlin). It’s deliberately simple, it is not copied from any production app, and the weights are arbitrary placeholders you’d tune against your own labelled data:
// Educational sketch — not production code and not any app's real algorithm.
data class Track(val title: String, val artist: String, val durationSec: Int)
data class Weights(val title: Double, val artist: Double, val duration: Double)
private val NOISE = Regex("""\((feat|ft)\.[^)]*\)|\[[^\]]*]|\((remaster(ed)?|deluxe)[^)]*\)""", RegexOption.IGNORE_CASE)
fun normalize(s: String): String =
s.lowercase()
.replace(NOISE, "")
.replace(Regex("""[^\p{L}\p{Nd}\s]"""), "") // keep letters/digits in ANY script
.replace(Regex("""\s+"""), " ")
.trim()
private fun bigrams(s: String): Set<String> =
if (s.length < 2) emptySet() else s.windowed(2).toSet()
// Sørensen–Dice coefficient over character bigrams: 0.0 .. 1.0
fun dice(a: String, b: String): Double {
if (a == b) return if (a.isEmpty()) 0.0 else 1.0
val x = bigrams(a); val y = bigrams(b)
if (x.isEmpty() || y.isEmpty()) return 0.0
return 2.0 * x.intersect(y).size / (x.size + y.size)
}
fun durationScore(aSec: Int, bSec: Int?): Double {
if (bSec == null || aSec <= 0) return 0.5 // unknown: stay neutral
val d = kotlin.math.abs(aSec - bSec)
return when { d <= 2 -> 1.0; d <= 5 -> 0.7; d <= 15 -> 0.3; else -> 0.0 }
}
fun score(src: Track, cand: Track, w: Weights): Double =
w.title * dice(normalize(src.title), normalize(cand.title)) +
w.artist * dice(normalize(src.artist), normalize(cand.artist)) +
w.duration * durationScore(src.durationSec, cand.durationSec)
fun best(src: Track, candidates: List<Track>, w: Weights, minScore: Double): Track? =
candidates.maxByOrNull { score(src, it, w) }
?.takeIf { score(src, it, w) >= minScore }
// Example only: Weights(0.5, 0.3, 0.2) is arbitrary, tune on labelled pairs.
Why a match can be wrong even when the title looks right
- Same title, different recording. Studio vs live vs re-recorded “Taylor’s Version”-style re-releases; the strings can be near-identical and the durations only a few seconds apart.
- Duplicate uploads. Several uploads of the same audio; only one is the “official” one, and the search order doesn’t necessarily favour it.
- Explicit vs clean edits. Titles are the same, audio differs. A scorer that only looks at title, artist and duration can’t tell them apart.
- Covers and remixes with the original title plus a small suffix.
- Metadata disagreements. Artist credits (“A & B” vs “A feat. B”), transliteration, or one service listing only the first artist.
- Regional edits and different masters, which shift duration slightly.
What Meld’s matcher actually does
[Code] The scoring lives in SpotifyMapper.kt and the orchestration in SpotifyYouTubeMapper.kt. From those two files:
- The query is the first artist plus the title (
buildSearchQuery), sent to YouTube Music’s search viaYouTube.searchSummary(query, incognito = true). The code comment explains theincognitoflag: this is a background match, so it shouldn’t land in the user’s search history. - Only
SongItemresults are considered as candidates. - Titles and artists are normalized (lowercased;
(feat. …),(ft. …),[…], remaster and remix parentheticals removed; non-alphanumerics stripped), then compared with a Dice coefficient over character bigrams. - The combined score weights title 0.45, artist 0.35 and duration 0.20. Duration is bucketed: within 2 seconds scores 1.0, within 5 scores 0.8, within 10 scores 0.5, within 30 scores 0.2, otherwise 0; unknown duration scores 0.5.
- A separate ranking penalty applies to candidates that carry variant words (live, karaoke, cover, instrumental, sped up, slowed, nightcore, music video, and a few others) that the Spotify title doesn’t: 0.15 per extra marker, capped at 0.30. The penalty affects ranking only; the acceptance check uses the raw score, so a track that exists only as a live version still resolves.
- A candidate must reach a minimum score of 0.35 to be accepted, and the loop exits early if a candidate’s adjusted score reaches 0.95.
- The scoring function takes title, artist and duration. Explicit/clean status is stored on the candidate but is not part of the score.
[Reading] One thing I noticed in the normalization regex: after lowercasing it removes everything outside a–z, 0–9 and whitespace. That means a title written entirely in a non-Latin script (Bangla, Japanese, Arabic and so on) normalizes to an empty string, and the similarity function returns 1.0 when the two normalized strings are equal, including when both are empty. If I’m reading it right, for such titles the title and artist components stop discriminating between candidates, and ranking falls back on duration and the variant penalty. I didn’t run Meld’s code to confirm this behaviour, so treat it as a hypothesis worth testing, not a bug report. It’s also a reminder that “normalize strings” hides decisions about Unicode that matter a lot outside English-language catalogues; my sketch above keeps letters in any script for exactly that reason.
Caching and manual overrides
[Code] Resolution order in mapToYouTube is: an in-process LRU cache (512 entries), then a Room database table (SpotifyMatchEntity: Spotify ID, YouTube ID, title, artist, score, and an isManualOverride flag), then a fresh search. Successful searches are written back to both caches. overrideMatch stores a user-chosen YouTube ID with score 1.0 and the override flag set. The doc comment says manual overrides are never replaced by the automatic matcher, and since the cache lookup runs before the search, that falls out naturally. The README documents the UI for it (a “Change YouTube version” action where you paste a link).
This is the pragmatic answer to “fuzzy matching will sometimes be wrong”: make the first resolution slow and cached, make wrong answers fixable by a human, and never re-guess something a human has already corrected.
5. What Can the Official YouTube API Actually Do?
The YouTube Data API v3 is a metadata and management API. Two calls cover most of what a matcher would want.
Search (search.list):
# Official YouTube Data API v3 — search returns metadata, not audio
curl -s "https://www.googleapis.com/youtube/v3/search?part=snippet&type=video&maxResults=5&q=Example+Artist+Example+Track&key=$YOUTUBE_API_KEY"
Per the reference, part=snippet is required, type can restrict results to videos, maxResults runs 0–50, and results carry id.videoId and snippet.title [Docs]. The page lists a quota cost of 1 unit in a dedicated “Search Queries” bucket; quota rules have changed over the years, so check the current page. I didn’t confirm from that page which other snippet fields come back for search results; the full resource reference is the place to look.
Details (videos.list):
curl -s "https://www.googleapis.com/youtube/v3/videos?part=snippet,contentDetails&id=VIDEO_ID&key=$YOUTUBE_API_KEY"
A simplified, illustrative response shape, using the fields documented on the video resource page:
{
"kind": "youtube#videoListResponse",
"items": [
{
"snippet": {
"title": "Example Track",
"channelTitle": "Example Artist - Topic",
"thumbnails": { "default": { "url": "https://…", "width": 120, "height": 90 } }
},
"contentDetails": { "duration": "PT3M35S" }
}
]
}
Two practical points: durations come back as ISO 8601 strings (PT3M35S), and the docs note they can differ from real playback length by about a second [Docs]. So to do duration-based matching with the official API you need a second call to get durations at all; search.list alone isn’t enough. That alone tells you which API a duration-scoring matcher is really using.
What the official API is not
It isn’t a general-purpose raw-audio streaming API. Nothing in these endpoints returns an audio file or a stream URL you can feed to a decoder. For the official ways to play YouTube content you embed YouTube’s player.
And the developer policies (last updated 14 September 2026 as the page showed when I read it) are blunt about the things a music-player app would want to do [Docs]:
- Don’t download, cache, or store copies of YouTube audiovisual content, or make it available offline, without YouTube’s prior written approval (I read this as III.E.1).
- Don’t separate or isolate the audio or video components of audiovisual content, and don’t promote them separately (III.I.7, III.I.8).
- Don’t build features that play content from a background player, meaning a player that isn’t displayed in the screen the user is looking at (III.I.9).
- Don’t modify, interfere with, replace or block ads (III.I.5), and don’t modify or block parts of the YouTube player (III.I.6).
- Don’t use technology other than YouTube API Services to retrieve API data or audiovisual content (III.I.14), don’t use undocumented APIs without express permission (III.D.7), and don’t scrape (III.E.6).
- Don’t circumvent geographic restrictions (III.I.13).
[Reading] Put those together and the conclusion is uncomfortable but clear: a background-playing, audio-only YouTube music player is not something the official API’s policies describe as permitted. The official route supports search and embedded video; it doesn’t support “play the audio of this video in the background like a music app.” That gap is exactly what unofficial approaches fill, and why section 6 spends time on what that costs.
6. From Metadata to a Playable Stream: The Playback Pipeline
This is the part I most wanted to understand. Here’s the pipeline as a diagram, with the stages numbered as I’ll discuss them:
(1) user taps a track
│
▼
(2) metadata already known? ── Spotify GraphQL/REST, or local DB
│
▼
(3) MATCH: Spotify track ──► YouTube video ID
│ cache order: memory LRU → Room DB → live search + scoring
│ (manual override wins) [SpotifyYouTubeMapper.kt]
▼
MediaItem(id = YouTube video ID) is queued in the player
│
▼
(4) RESOLVE: video ID ──► playable stream URL + headers + expiry
│ inside ExoPlayer's ResolvingDataSource [MusicService.kt]
│ a. local file? → use it
│ b. bytes already on disk (download/player cache)? → use them
│ c. fresh stream URL in memory cache? [StreamUrlCache.kt]
│ d. otherwise ask the extractor [InnerTubeXPlayer.kt]
▼
(5) DataSpec rewritten: new URI + request headers (+ bounded ranges)
│
▼
(6) ExoPlayer: HTTP fetch → cache → container parse → decode → audio sink
│
▼
(7) Player state/position/errors ──► MediaSession ──► notification + UI
│
├── (8) reuse: match cache, stream-URL cache, on-disk byte cache
└── (9) failure: 403/410 → invalidate URL, mark client failed,
refresh config, retry; offline → back off and wait
The four files, and what each one is
| File | Role in the pipeline | Verified from |
|---|---|---|
SpotifyYouTubeMapper.kt | Stage 3. Turns a Spotify track into a YouTube video ID, with a three-level cache and manual overrides. | [Code] |
InnerTubeXPlayer.kt | Stage 4d. The app’s entry point to the stream-extraction library. | [Code] |
StreamUrlCache.kt | Stage 4c/8. An in-memory cache of resolved stream URLs with expiry. | [Code] |
MusicService.kt | Stages 4–9. The Android service that owns the ExoPlayer, the media session, the resolving data source, and error recovery. | [Code] |
Stage 3 to 4: the hand-off is just an ID
[Code] After matching, resolveToMediaItem builds a MediaItem whose id is the YouTube video ID; the doc comment says this lets the existing ResolvingDataSource resolve the actual stream URL later. It also registers the Spotify track in a SpotifyMetadataRegistry keyed by that ID, which the optional lossless branch uses for ISRC-based lookup.
That’s a neat design: the queue and UI deal in IDs and metadata, and the expensive, fragile step (finding a URL) is deferred until ExoPlayer actually asks for bytes.
Stage 4: resolving the source, inside the data source
[Code] In MusicService.createDataSourceFactory, Meld wraps its cache data source in Media3’s ResolvingDataSource. For each DataSpec the player wants to read, the resolver does roughly this, in order:
- Take the media ID from the data spec’s cache key.
- If the ID starts with
local:, look up the local file path in the database and return that URI. - If an opt-in lossless branch is enabled, try it first and fall back silently on failure. (This branch looks tracks up in a different paid catalogue through community-run resolver services. I’m deliberately not digging into it: I can’t verify the authorization status of those services, and the README itself warns that they can disappear or be rate-limited. It is a nice example of a fallback chain, though.)
- Check the download cache and the player cache for the requested byte range; if the bytes are on disk, return the spec unchanged.
- Check
songUrlCachefor a still-valid stream URL; if present, rewrite the spec with it. - Otherwise call
InnerTubeXPlayer.playerResponseForPlayback(...)and block (on an IO dispatcher) until it returns, persist format details to the database, put the result intosongUrlCache, and rewrite the spec.
This is what the Media3 docs describe ResolvingDataSource as being for: just-in-time modification of requests, such as changing headers or resolving a URI immediately before each request [Docs].
One more detail I liked: because the resolver can run for every chunk of a long, fragmented cache entry, MusicService guards its metadata-recovery step with a “currently recovering” set so a single song can’t fan out dozens of redundant database coroutines [Code: recoverSongDeduped, with a long comment explaining an out-of-memory scenario on low-RAM devices]. That’s what real playback code looks like: a lot of it exists because something once went wrong.
Match cache vs. stream URL cache
These two caches are easy to confuse and they solve different problems:
| Match cache | Stream URL cache | Byte cache | |
|---|---|---|---|
| Where | SpotifyYouTubeMapper (memory LRU + Room) | StreamUrlCache (memory only) | ExoPlayer Cache on disk |
| Key | Spotify track ID | YouTube video ID | media ID + byte range |
| Value | “this Spotify track = that YouTube ID” | URL + request headers + client name | the audio bytes |
| Lifetime | Effectively permanent (a human can override) | Short (expiry from the response, default 5 minutes if none) | Until evicted |
| Goes stale when… | the match was wrong | the URL expires or is rejected | cache is corrupted or a quality change requires it |
[Code] StreamUrlCache is an LRU (500 entries) whose entries carry an absolute expiry computed from expiresInSeconds; get drops and returns null for expired entries. It also keeps a per-ID generation counter: invalidate bumps the generation, and put takes an expectedGeneration and refuses to store if the generation moved. The point is a race: a slow resolution that started before an invalidation shouldn’t be able to re-insert the very URL that just failed. Here’s a simplified illustration of the idea, written by me, not copied:
// Simplified illustration of a TTL cache with a generation guard (not Meld's code).
class UrlCache(private val now: () -> Long = System::currentTimeMillis) {
private data class Entry(val url: String, val expiresAt: Long)
private val entries = HashMap<String, Entry>()
private val generations = HashMap<String, Long>()
@Synchronized fun generation(id: String) = generations[id] ?: 0L
@Synchronized fun get(id: String): String? {
val e = entries[id] ?: return null
if (e.expiresAt <= now()) { entries.remove(id); bump(id); return null }
return e.url
}
// Caller reads generation BEFORE the slow network call and passes it back.
@Synchronized fun put(id: String, url: String, ttlSec: Int, expectedGen: Long): Boolean {
if (generation(id) != expectedGen) return false // invalidated meanwhile
entries[id] = Entry(url, now() + ttlSec * 1000L)
return true
}
@Synchronized fun invalidate(id: String) { entries.remove(id); bump(id) }
private fun bump(id: String) { generations[id] = generation(id) + 1 }
}
Request headers and byte ranges
[Code] What the cache stores isn’t just a URL. CachedStreamUrl holds the URL, a map of request headers, the name of the extraction “client” that produced it, and flags for bounded-range requests. DataSpec.withResolvedStream swaps the URI, merges those headers into the request, and, when the stream requires it, restricts the read to a bounded chunk with subrange. In other words the URL alone isn’t enough; the service that issued it may expect particular headers and range behaviour, and the app has to carry that context into the player. [Reading] That’s a general lesson about stream URLs: they are capabilities issued under specific conditions, not permanent addresses for a file.
Stage 5–6: what the engine receives
[Code] MusicService builds ExoPlayer with a DefaultMediaSourceFactory constructed from that data source factory and a deliberately narrow extractor list: Matroska, fragmented MP4, and MP4 only. It configures DefaultLoadControl with buffer durations of 50 seconds minimum and maximum, 750 ms to start playback and 2 seconds after a rebuffer. The audio attributes are USAGE_MEDIA / CONTENT_TYPE_MUSIC. The native engine is Media3 ExoPlayer, which, per the Media3 docs, delegates to injected components: a MediaSource.Factory, Renderers, a LoadControl and so on [Docs].
Stage 7: reporting to the UI
[Code] MusicService extends MediaLibraryService and exposes a MediaLibrarySession; it also exposes StateFlows (current metadata, current stream client, network-waiting state) and a binder for the in-app UI. Notification and lock-screen controls come from the session. More on this in section 8.
Stage 9: recovery
[Code] In MusicService’s error handling, playback errors are classified and routed. HTTP 403 or 410 is treated as an expired or forbidden URL (isExpiredUrlError), 416 as a range mismatch, and so on. The expired-URL path (handleExpiredUrlError → refreshStreamAndRetry) invalidates the cached URL, records the failing extraction client for that video via InnerTubeXPlayer.markStreamClientFailed, kicks off a refresh of the extractor’s configuration, waits a short delay, re-checks that the user is still where they were, and re-prepares the player at the same position. Retries are capped per song (MAX_RETRY_PER_SONG = 3). If the device is offline, a separate path backs off exponentially (3 s doubling to a 30 s cap) and arms a connectivity listener so playback resumes when the network returns.
Unofficial extraction, at a conceptual level
InnerTubeXPlayer calls itself the “sole stream extraction entry point” in its doc comment [Code]. I’m not going to reproduce how it works, and I want to be explicit that this is an area where I’m describing architecture, not endorsing or documenting an approach. But the shape of the engineering is instructive.
[Code] From the file: playerResponseForPlayback asks an extractor for a stream given a video ID, content hints, a set of clients to exclude (clients that recently failed for that video are remembered for five minutes), and an audio quality preference derived from user settings and whether the connection is metered. It explicitly disallows HLS and SABR streaming modes for this playback engine, and rejects results that need SABR. The result (PlaybackData) carries the stream URL, an expiry in seconds (default five minutes if none is given), the client name, headers, and range requirements. The file also wires in a token provider that uses a WebView-based attestation flow, and a remotely-updatable “player config” JSON that the app downloads from a public GitHub repository.
[Reading] Without describing any mechanism, those details tell you what this class of system looks like:
- It depends on an undocumented interface that the service operator can change at will. The remote-config download and the “failed client” memory exist because things break.
- It depends on several moving parts staying in sync (client identities, attestation tokens, signature-handling logic), each of which can fail independently.
- Its output is short-lived, which is why expiry, invalidation, and retry logic are central rather than incidental.
- It is, by construction, outside the documented API contract, which affects what you can promise users and what you can say about its stability.
Treat it as you would any dependency whose maintainer hasn’t agreed to support you: assume it will break, and design the UI and caches so that breakage degrades gracefully instead of crashing playback.
7. How Do Lyrics Appear and Synchronize With Music?
Where lyrics come from
Lyrics can be embedded in the audio file (an ID3 USLT or synchronized-lyrics frame, or a Vorbis comment), shipped as a sidecar file next to it (song.lrc), or fetched at runtime from an external provider by title, artist, album and duration. Streaming-oriented apps almost always use the third approach, which means lyrics are another fuzzy join, with the same wrong-version risks as audio matching.
Plain vs. timestamped
Plain lyrics are just text. Timestamped lyrics usually use LRC, a simple text format with no formal standard [Docs: Wikipedia overview]. Each line starts with a [mm:ss.xx] tag; optional ID tags like [offset:+500] shift the whole file in milliseconds; and the “enhanced” variant adds per-word <mm:ss.xx> tags. An example with invented lyrics:
[ti:Imaginary Song]
[ar:Nobody In Particular]
[offset:0]
[00:12.00]First line of an imaginary song
[00:17.40]Second line, a little later
[00:23.10]Third line arrives right on time
# enhanced (word-level) variant:
[00:12.00]<00:12.00>First <00:12.45>line <00:12.90>of <00:13.20>an <00:13.60>imaginary <00:14.30>song
Finding the active line
The core of synchronization is small: given the player’s current position, find the last line whose timestamp is at or before it. Lines are sorted by time, so binary search works. An illustrative function (my sketch, not any app’s code):
// Educational sketch.
data class LyricLine(val timeMs: Long, val text: String)
/** Index of the active line, or -1 if the first line hasn't started yet. */
fun activeLineIndex(lines: List<LyricLine>, positionMs: Long, userOffsetMs: Long = 0): Int {
val t = positionMs + userOffsetMs // positive offset = lyrics appear earlier
var lo = 0; var hi = lines.lastIndex; var ans = -1
while (lo <= hi) {
val mid = (lo + hi) ushr 1
if (lines[mid].timeMs <= t) { ans = mid; lo = mid + 1 } else hi = mid - 1
}
return ans
}
Where it gets tricky is everything around that function:
- Which position? Players report position via polling or callbacks; for smooth karaoke-style highlighting you typically poll on each frame and extrapolate between reports.
- Seeking. After a seek you must recompute the index from scratch rather than stepping forward; a binary search makes that free.
- Buffering. While buffering, position stops advancing; lyrics should stop too, which happens naturally if you derive from player position rather than wall-clock time.
- Drift and offsets. If the lyric file was timed against a different edit of the song (a radio edit, a longer intro), every line is off by a constant. Hence the per-song offset control many apps offer.
- Word-by-word sync needs per-word start and end times; a line-level file can only approximate it (for example by dividing a line’s duration across its words).
Wrong or missing lyrics
Lyrics can be missing, plain-only, timed for a different version, or just for a different song with a similar title. Sensible apps fall back to plain text, let the user pick among several provider results, and let the user nudge the timing.
What Meld does
[Code] I read LyricsHelper.kt and LyricsProviderRegistry.kt:
- Providers are tried sequentially in a user-configurable order. The default order in the registry is BetterLyrics, LrcLib, KuGou, Paxsenix, LyricsPlus, Zemer, YouTubeSubtitle, YouTube. A Musixmatch provider exists in the codebase but is deliberately unregistered, with a comment saying Musixmatch no longer issues working guest tokens to its desktop API.
- Each provider gets an 8-second timeout and the whole attempt a 25-second budget. The query uses a cleaned title, the joined artist names, the duration and the album.
- It prefers synced lyrics: if an early provider returns plain text, the helper remembers it as a fallback and keeps looking for a time-synced result, because the lyrics view can only scroll synced text.
- “Credit lines” in provider text are filtered out, results are held in a tiny LRU (3 entries), and
MusicServicestores fetched lyrics in the database when lyrics display is enabled. LyricsUtils.ktcontains parsers for word-level formats (<mm:ss.xx>word tags, plus formats with speaker/“agent” markers and background-vocal lines), andLyricsEntrycan carry per-word timestamps.- Songs have a persisted
lyricsOffset, and the offset dialog clamps it to ±3000 ms in 50 ms steps.
[Unverified] I did not trace how the lyrics view picks the active line on screen, so I can’t tell you whether Meld uses binary search or a linear scan, or how it smooths word highlighting.
Licensing
Lyrics are copyrighted text, separate from the sound recording and from the underlying composition. A provider’s open-source server doesn’t make its data free: for instance, LRCLIB’s repository is MIT-licensed software [Docs], which says nothing by itself about the legal status of the lyrics people upload to it [Reading]. I haven’t evaluated any lyrics provider’s terms for this post. If you build a lyrics feature, read each provider’s terms and decide how much text you cache or display.
8. What Happens in the Background While a Song Plays?
Closing the screen shouldn’t stop the music, but Android is eager to kill background work. The platform answer is a bundle of cooperating pieces.
Background service. The Media3 background playback guide says playback should live in a MediaSessionService (or MediaLibraryService if you expose a browsable library) that owns the Player and the MediaSession, declared as a foreground service of type mediaPlayback, so the UI can come and go [Docs]. A minimal skeleton in that spirit (simplified from the guide’s pattern):
class PlaybackService : MediaSessionService() {
private var session: MediaSession? = null
override fun onCreate() {
super.onCreate()
val player = ExoPlayer.Builder(this).build()
session = MediaSession.Builder(this, player).build()
}
override fun onGetSession(info: MediaSession.ControllerInfo) = session
override fun onDestroy() {
session?.run { player.release(); release() }
session = null
super.onDestroy()
}
}
Media session and notification. The session is the bridge to everything outside your app: the notification, lock screen, headset buttons, Bluetooth, Android Auto. MediaSessionService creates the media-style notification automatically from session state; per the guide, the notification appears once the player has items, can’t be dismissed while the foreground service runs, and if playback stays paused or stopped for over ten minutes the service leaves the foreground and the system may stop it [Docs].
Audio focus. Android lets multiple apps make sound, so apps are supposed to request focus, pause or duck when they lose it, and resume when it returns. The audio focus guide describes the gain and loss types (permanent loss, transient loss, “can duck”) and notes that Media3 can handle it for you via handleAudioFocus [Docs].
What the app actually does. [Code] MusicService is an excellent example of how far real apps go beyond the minimum:
- It extends
MediaLibraryService, builds aMediaLibrarySession, wires a custom notification provider on top ofDefaultMediaNotificationProvider, and exposes custom session commands (like, repeat, shuffle, start radio) as notification buttons. onCreatepromotes the service to foreground before doing any disk I/O, with a comment about satisfying Android’s five-secondstartForegroundServicewindow on slow OEM builds.- It builds the player with audio attributes but passes
falsefor ExoPlayer’s built-in focus handling, and implements focus itself with anAudioFocusRequest: pause on loss, remember whether it was playing, duck to 0.2 volume on “can duck”, resume after a short delay when focus returns. There’s also a user setting to opt out of focus handling entirely. - It sets
setHandleAudioBecomingNoisy(true)and a network wake mode, listens for Bluetooth device connections (optionally resuming), and observes connectivity to drive the retry logic from section 6. - The queue is saved to disk periodically (every 10 to 15 seconds in the loops I read) so a process kill doesn’t lose it, and playback state is restored on start.
- There’s a pre-cache job that resolves and caches upcoming tracks, and logic to load more items when the queue runs low.
Who does what
| Layer | Responsibilities | In Meld |
|---|---|---|
| UI | Render state, send commands, show lyrics and artwork | Jetpack Compose screens observing flows from the service [Reading] |
| Playback engine | Fetch, buffer, parse, decode, output audio; track position | ExoPlayer, DataSources, renderers, audio processors |
| Service + session | Own the player, survive the UI, expose controls to the OS, handle focus, retries, queue persistence | MusicService |
| Data layer | Matches, formats, lyrics, history | Room, DataStore |
The mental model that helped me: the UI is a remote control; the service is the player. If the UI owned playback, rotating the screen or swiping the app away would stop the music.
9. Flutter vs Java vs Kotlin: How Would the Same App Be Built?
The three stacks can all build this app. They differ in how much glue you write and how close you get to the platform.
| Concern | Flutter + Dart | Native Java | Native Kotlin |
|---|---|---|---|
| UI | One widget toolkit for Android and iOS | Android Views (XML layouts); Compose isn’t supported from Java | Jetpack Compose or Views |
| Networking | Dart HTTP packages (commonly http or dio) | OkHttp / HttpURLConnection, threading by hand or via executors | OkHttp/Ktor with coroutines |
| JSON | Codegen or manual models in Dart | Gson/Moshi/Jackson, more boilerplate | kotlinx.serialization / Moshi |
| Audio engine | just_audio, which uses ExoPlayer on Android | Media3/ExoPlayer directly | Media3/ExoPlayer directly |
| Background + media session | audio_service wraps platform services | MediaSessionService directly | MediaSessionService/MediaLibraryService directly |
| Storage | Plugins (SQLite-based or key-value) | Room (supports Java) | Room + DataStore with coroutines/Flow |
| Lyrics sync | Streams of position, driven in Dart | Handler/Choreographer polling | Flows + coroutines, easy to combine with position |
| Code reuse | Highest across platforms | Android only | Android only (Kotlin Multiplatform can share logic) |
Some of the claims in that table deserve sources:
- [Docs]
just_audionames ExoPlayer on Android in its error mapping, supports caching and byte-stream sources through a local HTTP proxy, and points apps tojust_audio_backgroundoraudio_servicefor notification and lock-screen controls. It documents caveats worth knowing: the local proxy needs cleartext HTTP enabled, request headers are unsupported on web, and on iOS custom headers rely on an undocumented key apart from the user-agent on iOS 16+. Seeking accuracy depends on the server’s range-request support. - [Docs]
audio_servicelets a Flutter app keep playing in the background and connect to the media notification, lock screen, headset buttons, wearables and Android Auto, via anAudioHandleryou subclass. It has Android 12+ foreground-service caveats on its page. - [Docs] Google says Android development is Kotlin-first. Its support table lists Jetpack Compose as supported for Kotlin and not for Java, and lists AndroidX as fully supported from Java, while the coroutine and KTX APIs are Kotlin-only.
- [Reading] The networking, JSON and storage rows for Flutter and Java name commonly used libraries, but I haven’t evaluated them for this post.
My honest take, which is opinion: the audio engine is the same on Android in the Flutter and native versions (ExoPlayer), so the hard parts (resolution, caching, recovery) cost about the same either way. Flutter wins when you also want iOS; native Kotlin wins when you want to customise exactly how ExoPlayer fetches bytes (like the ResolvingDataSource hooks above) without going through a plugin boundary.
And the point that matters more than the table:
No language or UI framework grants permission to stream copyrighted content. Dart, Java and Kotlin are all indifferent to licensing. Whether you may play something depends on rights, platform terms and the law, not on your toolchain.
10. Why Do Some Music Apps Use So Little Server Infrastructure?
A lot of these apps have no backend of their own that anyone pays for. How is that possible? Because most of the work can live on the phone.
What the client can do alone:
- Metadata retrieval and search by calling third-party APIs (or undocumented endpoints) directly.
- Matching: scoring candidates is cheap CPU work.
- Caching: matches, URLs, lyrics, artwork and audio bytes all live on-device.
- Account state: tokens and cookies stored locally.
- Playlists and history: a local database, optionally synced to the upstream service’s own library API.
What genuinely needs a backend:
- Anything with shared state across users (listen-together rooms, public playlists, social features). Metrolist’s README credits a separate server project (
metroserver) for its listen-together feature [Code]. - Anything that must hold a secret you don’t want inside the APK.
- Rate-limit pooling, push notifications, and licensed-content enforcement.
But “no server of mine” doesn’t mean “no infrastructure.” Look at what Meld depends on, from the code alone:
- [Code] Spotify’s GraphQL hashes are tracked by a daily GitHub Actions workflow whose output is served from GitHub Pages (
AGENTS.md). - [Code]
SpotifyAuth.ktfetches a rotating secret from a community-maintained Gist. - [Code]
InnerTubeXPlayer.ktdownloads a player-config JSON from a public GitHub repository. - [Code] The lyrics layer fans out to many external providers, with a registry comment noting one of them stopped working.
None of those are the maintainer’s servers, and all of them are things that can change or disappear. The real costs here are:
- Quotas and rate limits: the official APIs have them (a 429 means you hit one, per Spotify’s docs [Docs]), and the unofficial ones have them too, just undocumented.
- Outages of services you don’t control.
- Maintenance labour: someone has to notice the daily breakage, ship the fix, and keep the remote config current. For an unofficial integration that’s the dominant cost, and it’s paid in volunteer time, not dollars.
[Reading] Free to run is not the same as cheap to maintain. The architecture is clever precisely because it pushes cost onto other people’s infrastructure and the maintainer’s attention.
11. What If You Host the MP3 Files Yourself?
The honest, boring alternative: you hold audio you have the right to serve, and the app plays it directly.
Client app ──HTTPS (Range requests)──► HTTP server / object storage / CDN ──► audio file
│ ▲
└─ ExoPlayer / AVPlayer / just_audio fetches, buffers, decodes ◄───────────┘
The moving parts
- Storage and backup. Audio is big (estimates below). Keep at least two copies on different media plus one off-site, and test restores. Object storage gives durability without you running RAID.
- VPS vs object storage vs CDN. A VPS is simplest but ties bandwidth and disk to one machine. Object storage separates storage from compute and scales cheaply for static files. A CDN caches popular files near listeners and cuts origin bandwidth. Prices vary by provider and change; I’m not quoting any, so check current pricing pages for your region.
- HTTP range requests. Seeking depends on them. The client sends a
Range: bytes=…header and the server answers206 Partial Contentwith aContent-Range, advertising support withAccept-Ranges: bytes(RFC 9110, sections 14 and 15.3.7). You can check a server quickly:
curl -sI -H "Range: bytes=0-1023" https://media.example.com/track.m4a
# Look for: HTTP/… 206, Accept-Ranges: bytes, Content-Range: bytes 0-1023/<total>
Just_audio’s own docs note seeking and duration accuracy depend on server range support [Docs], so a server that ignores Range breaks seeking.
- Bitrate, quality and transcoding. Lossless files are large; many libraries transcode to AAC or Opus for mobile. For example:
ffmpeg -i input.flac -c:a aac -b:a 128k output.m4a. Transcoding costs CPU once (pre-transcode) or per request (on-the-fly, which gets expensive with many users). - Concurrency and load. Static file serving is cheap per user, but bandwidth adds up, and slow clients hold connections open.
- Security and operations. TLS, authentication (signed, expiring URLs are common), rate limiting, patching, uptime monitoring, and logs. Public, unauthenticated links get scraped.
- Rights. Storing and serving a recording to other people is distribution. This is where self-hosting stops being a technical question.
A transparent bandwidth calculation
Assumptions: 100 users, each listening 1 hour per day, for a 30-day month; constant bitrate; decimal units (1 kbps = 1,000 bits/s, 1 GB = 10⁹ bytes); no protocol overhead or re-downloads.
| 128 kbps | 320 kbps | |
|---|---|---|
| Bytes per second | 128,000 ÷ 8 = 16,000 B/s | 320,000 ÷ 8 = 40,000 B/s |
| Per listening hour | 16,000 × 3,600 = 57.6 MB | 40,000 × 3,600 = 144 MB |
| Per user per month (30 h) | 57.6 MB × 30 = 1.728 GB | 144 MB × 30 = 4.32 GB |
| 100 users per month | 172.8 GB | 432 GB |
| Average rate over the month | 172.8 GB × 8 ÷ 2,592,000 s ≈ 0.53 Mbit/s | 432 GB × 8 ÷ 2,592,000 s ≈ 1.33 Mbit/s |
| Worst case, all 100 at once | 100 × 0.128 = 12.8 Mbit/s | 100 × 0.32 = 32 Mbit/s |
Storage works the same way. A 4-minute (240 s) track is 16,000 × 240 = 3.84 MB at 128 kbps and 40,000 × 240 = 9.6 MB at 320 kbps, so a 10,000-track library is roughly 38.4 GB or 96 GB.
Real life adds overhead: HTTP and TLS framing, over-fetching around seeks, players that prefetch the next track, and cache evictions on the device. Budget a margin rather than trusting the exact figure. Multiply the monthly totals by your provider’s current outbound transfer pricing to get a cost; I’m not inventing a price here.
Personal collection vs. public service
| Personal collection | Public streaming service | |
|---|---|---|
| Who listens | You (and maybe family) | Anyone |
| Audio rights | Depends on your jurisdiction and how you obtained the files | You need licences covering public distribution of recordings and compositions |
| Scale | One or a few concurrent streams | Concurrency, CDN, abuse handling |
| Auth | Simple | Accounts, payments, takedown process |
| Risk | Mostly operational | Legal and commercial, on top of operational |
Whether personal copying is allowed varies by country, so I won’t generalise. The jump from “my files, for me” to “a public service” is a change in kind, not scale.
12. If YouTube Runs Ads, Why Might a Third-Party Player Not Play Them?
This question comes up constantly, and it’s easy to answer it badly, so let me be careful about what I know.
What I can say from docs. YouTube’s developer policies forbid API clients from modifying, interfering with, replacing, or blocking ads served by YouTube or the API, including ads in YouTube players (as I read III.I.5), and forbid modifying or blocking parts of the player (III.I.6) [Docs]. Those policies govern use of YouTube API Services. I could not retrieve YouTube’s general Terms of Service for this post, so I can’t tell you what it says about unofficial access.
What I can say from engineering. [Reading] Ads in the official experience are a function of the player: the official player or embed decides when to request and show ads, and enforces the surrounding rules. An app that obtains a media URL by some other mechanism and hands it to its own decoder isn’t running that player. There’s no ad slot because no code that would create one is running. The difference isn’t magic; it’s that the ad behaviour was never in the audio bytes to begin with.
Caveats that matter:
- Behaviour differs by path. Different playback paths (the official player, different unofficial mechanisms, different content types) can produce different ad experiences. I won’t generalise from one app to all of them, and the code I read doesn’t let me describe any particular app’s ad handling beyond noting it stores a playback-tracking URL from the stream response, whose use I did not trace [Unverified].
- No ad doesn’t mean permission. The absence of an ad says nothing about whether an app has the right to use the recording or to access the service that way. Those are separate questions, answered by licences and terms, not by what the player happens to show.
- Ads are part of how the official service pays for content. Whatever you think of that bargain, it’s the context for why platforms write policies like III.I.5. I’m not going to describe ways to defeat ad systems or access controls, and I’d suggest not building features whose purpose is to do so.
13. Why Can a Project Exist on GitHub but Not Be Available on Google Play?
These are six different things that get lumped together:
- Publishing source code just means making it readable.
- Licensing source code means granting permissions (copy, modify, redistribute) under stated conditions.
- Distributing a compiled APK is a separate act, with its own obligations (under the GPL, offering corresponding source).
- Rights to the underlying music, artwork and lyrics belong to rights holders, not to the code author.
- Third-party service terms (Spotify, YouTube, lyrics providers) bind anyone who uses those services.
- App-store policies are a platform’s own conditions for listing.
Clearing one doesn’t clear the others.
The two repositories
- [Code] Both Meld and Metrolist carry the GNU GPL version 3 in their
LICENSEfiles, and the source files I read have a “Licensed under GPL-3.0” header. The Meld README credits Metrolist as the project it was forked from, and GitHub’s repository page shows the fork relationship. [Reading] A GPL-3.0 fork has to stay GPL-3.0 if distributed, which is the likely explanation for the matching licence. - [Code] Meld’s README points users to an APK in its GitHub Releases. Metrolist’s README lists Obtainium, IzzyOnDroid, OpenAPK, and GitHub releases, and describes the project as being in “maintenance mode.” Neither README includes a Google Play link.
- [Unverified] I didn’t check Play Store listings directly, and I found no credible evidence in either repository about any submission, rejection, or removal. A missing store badge isn’t evidence of anything; plenty of open-source apps simply choose other channels. I’m not going to guess at reasons.
What an open-source licence does and doesn’t cover
The GPL-3.0 text governs the program: it grants permission to run, modify and redistribute the code under conditions. Section 2 even says that output produced by running a covered work is covered by the licence only if that output is itself a covered work. [Reading] Nothing in it grants rights to recordings, album art, lyrics, or access to someone else’s service. Those come from separate grants.
Section 3 of the GPL says a covered work isn’t to be treated as part of an “effective technological measure,” and waives the licensors’ legal power to forbid circumvention by exercising rights under the licence. [Reading] That is a statement about what the code’s authors give up, not a permission to bypass someone else’s protections on someone else’s service. I mention it because people sometimes read more into it than it says.
Finally, Google Play’s Intellectual Property policy says apps may not infringe or induce infringement, and lists as examples streaming apps that let users save unauthorized copies and listings whose descriptions or screenshots encourage unauthorized downloads or streaming. It also says developers must ensure everything in the app and listing is their own work or properly licensed [Docs]. A repo on GitHub faces none of those store rules; an app on Play does.
14. Is Building or Publishing This Kind of App Legally Risky?
I’m a developer, not a lawyer, and this section is general information, not legal advice. What I can do is separate the questions people tend to mush together.
The distinct issues:
- Copyright in sound recordings and musical compositions are two different rights, often held by different parties, and a licence for one doesn’t cover the other.
- Lyrics and artwork carry their own copyright (and sometimes trademark).
- Permission to stream, reproduce, distribute, or offer downloads are different acts; a licence may cover one but not another.
- API terms and service agreements are contracts. Breaking them can cost you API access or accounts even where no copyright claim exists. Spotify’s Developer Terms, for example, restrict streaming apps to private personal use and prohibit facilitating stream ripping [Docs].
- Circumvention and ad-related restrictions show up in platform policies (YouTube’s III.I.5, III.I.13, III.I.14 above) and, in many jurisdictions, in anti-circumvention law. I haven’t researched any particular country’s rules.
- Open-source licence obligations (GPL-3.0 requires offering corresponding source when distributing binaries, and that derivatives stay GPL-compatible).
- Trademarks and branding. Naming and icons that suggest affiliation with Spotify or YouTube invite complaints. Both READMEs include non-affiliation disclaimers [Code], which is good practice but not a legal shield.
- Local law and jurisdiction change the answers to nearly all of the above.
- Google Play distribution rules (the IP policy above, plus its other policies).
Risk profiles differ a lot. Roughly, and as my own framing rather than a legal classification:
| App type | What it touches | Relative exposure |
|---|---|---|
| Local-file player | Files the user already has | Lowest: you ship a decoder, not content |
| Licensed streaming app | Catalogue through a provider’s official terms | Moderate: contractual obligations, but the rights chain is explicit |
| Client for an unofficial extraction path | Content and access the provider hasn’t authorised third parties to use | Highest and least predictable: contract, copyright and platform-policy questions all open, and the technical basis can change overnight |
Android and Google Play, specifically
On Android you can install outside Play, which is why GitHub-release and F-Droid-style channels exist; that’s a distribution choice, not a legal clearance. If you do publish on Play, its IP policy puts the burden on the developer to be able to show rights to what’s in the app and listing, and specifically flags listings that encourage unauthorized streaming [Docs]. Writing a store listing is also part of the risk surface: what you say your app does matters.
Apple
My own experience and access are on the Android side, and I’m not claiming expertise in iOS distribution. Apple publishes its rules in the App Review Guidelines, which include a legal section on intellectual property. [Unverified] I couldn’t retrieve that section’s text for this post, so I’m deliberately not summarising it; read it directly if you care about iOS.
Jurisdiction, including Bangladesh
I’m not going to give a verdict for Bangladesh or any other country, because I haven’t researched current local statutes or case law and a confident-sounding guess would be worse than nothing. If you plan to publish something that touches third-party catalogues, talk to a lawyer who handles intellectual property and software in the countries you’ll distribute to, and ideally before you build the part that depends on the answer.
15. Spotify, YouTube, Open-Source Developers, and the Platform Trade-offs
It’s tempting to tell this story with a hero and a villain. The evidence doesn’t really support either.
Why Spotify can run its own service. Spotify distributes recordings it has licensed, to clients it controls, under its own terms. (That licensing arrangement is general background rather than something I verified in the docs I read.) Its developer terms then decide what outsiders may do with that catalogue through its APIs, and they’re narrow about audio: streaming apps for private personal use, no stream ripping, tight caching limits [Docs].
Why developers can publish open-source code. Source code is the developer’s own work. Publishing it, even code that talks to someone else’s service, doesn’t require the service’s blessing. But, as section 13 explained, a code licence says nothing about the music or the service.
Why that doesn’t add up to permission. “Spotify can stream this catalogue” and “this GPL repo exists” are both true, and neither implies “an independent app may stream that catalogue.” The permission has to come from rights holders and from the platforms’ terms, and the platforms have been clear about where they draw lines.
The incentives, one by one:
- Platform owners want engagement, control of the experience, and compliance with their licence deals. Spotify’s 2025 and 2026 changes reduced what third parties can do, and it said part of the motivation was security, privacy and licensing standards [Docs, Spotify’s claim]. YouTube’s policies tie its API to its own player and ad model.
- Independent developers want interoperability, customisation, and a better client than the official one. Many are motivated by learning and craft, and some by frustration with limits. The cost lands on them when an unofficial interface changes.
- Users want one library, fewer interruptions, and things that work. [Reading] They mostly can’t see the legal and technical risk their app carries, and they’re the ones who lose playback when it breaks.
- Rights holders want to be paid and to control distribution. They’re mostly absent from this conversation, but they’re the party the whole licence chain exists for.
- App stores want to avoid liability and keep their own policies enforceable, which is why Google’s policy language is about inducement and listings, not just code.
I think the fairest summary is that all of these actors are behaving rationally by their own incentives, and the friction is structural. A developer can be sincerely motivated by learning, a platform can be sincerely protecting its licences, and both can be right about their own situation while their interests collide.
16. Should You Build One?
For learning, yes, with a caveat about what you ship.
Building a music client forces you through a remarkable number of real engineering problems in one project: networking (retries, timeouts, caching, headers, range requests), media decoding and the player pipeline, matching algorithms (string normalization, similarity, thresholds, evaluating on labelled data), asynchronous programming (cancellation, races like the generation counter above), caching at several levels with different invalidation rules, background services and the Android lifecycle, API design for your own abstractions (MetadataSource, AudioSource), and UI engineering around state that updates many times a second. Few projects teach all of that at once.
Public distribution is a different question, and the answer isn’t determined by whether it works. Before you publish, you need to be able to answer:
- Do you have the rights to the content you play, or does a provider’s licence cover your use?
- Do your use of each API and service comply with that service’s current terms, and what happens when they change?
- Are you meeting your open-source licence obligations, including for forks?
- How reliable is the thing you’re depending on, and what does a user see when it breaks?
- Are tokens, cookies and cached data stored securely?
- Does your listing and your app meet the store’s policies?
If any of those answers is “I don’t know,” that’s a reason to keep the project private for now, not a reason to feel bad about the project.
Ways to learn responsibly
- Local files you have the rights to use. Your own recordings, music you’ve bought in a DRM-free format, or files with permissive licences. You’ll build the entire player, queue, session and lyrics stack without a rights question.
- Open-licensed and public-domain catalogues. The Internet Archive exposes items and metadata through documented APIs, though it says rights status is per-item, so check each one. Jamendo documents a public API with a non-commercial tier (35,000 requests per month on the page I read) and links separate terms for other uses. Read the terms, and check each track’s licence.
- Open metadata. MusicBrainz provides music metadata (including lookup by ISRC), asks for at most one request per second and a meaningful User-Agent, and serves no audio [Docs]. It’s a great place to practise matching and tagging without touching audio rights.
- Official previews, within their terms. Apple’s iTunes Search API returns a 30-second
previewUrlfor track results, but its terms say promotional content like previews is for promoting store content, must be streamed rather than downloaded or cached, and needs attribution [Docs]. Spotify’s ownpreview_urlis deprecated [Docs]. Previews are fine for learning, but they restrict what you can build into a product. I couldn’t verify Deezer’s preview documentation, so I’m not including it. - Licensed streaming SDKs and APIs, with their official playback components, if you want to build something you could actually ship.
A music app is a pipeline of small, ordinary problems: identify a thing, find a source, get bytes, decode them, keep going when something fails. The pleasing part is how much of that is ordinary engineering. The uncomfortable part is that the step everyone is curious about, getting playable audio, is the step where the code is least in charge, because whether it should work is decided somewhere else, by people and contracts the compiler never sees. I’d build one to understand the pipeline, and I’d be honest, with myself and with users, about which parts of it I had permission to run.
References
Repositories (inspected at the commit shown)
- Meld,
9d97fb6: README, LICENSE, AGENTS.md - Metrolist: repository and README, LICENSE (README and LICENSE read from
mainon 15 October 2026)
Spotify
- Web API overview
- Web API concepts: API calls
- Get Track
- February 2026 migration guide
- Changelog: February 2026, March 2026
- Updating the criteria for Web API extended access (April 2025)
- Developer Terms
YouTube
Android and Media3
- Media3: background playback
- ExoPlayer customization (
DataSource,ResolvingDataSource) - Audio focus
- Kotlin-first Android development
Flutter
Store policy
- Google Play: Intellectual Property policy
- Apple App Review Guidelines (section 5.2 text not retrieved; see section 14)
Lyrics, standards, and open catalogues