Architecture
The source layer
The protocol abstraction, the M3U parser, the Xtream client, and the guards that keep an account alive.
# The abstraction
MediaSource is an interface. M3uSource and
XtreamSource implement it. Adding Stalker, XMLTV, or any future protocol means
adding one implementation and changing zero feature modules.
Capabilities beyond the base contract are separate interfaces — VodSource for
film details, SeriesSource for episode lists — so a source that cannot describe
films simply does not implement that interface. Callers get a typed "not supported" rather
than a null, and the distinction between "this failed" and "this was never possible" survives
to the UI, where an M3U film shows its artwork and title without an error.
# The M3U parser
Plain text, and messier in practice than the format suggests. The parser is pure JVM and is the most thoroughly tested code in the project, because parsing is exactly the kind of work where tests are cheap and reality is hostile.
It handles, and is tested against: byte-order marks, CRLF line endings, unescaped commas
inside display names, missing group-title attributes, and a truncated final
line. Entries it cannot read are skipped and counted, and the count is surfaced —
"Loaded 20002 channels. 2 entries could not be read and were skipped" — rather than failing
the whole import or silently dropping them.
One limitation, stated plainly because it surprises people: an M3U has no way to say what kind of thing an entry is, so everything parses as Live. Movies and Series require Xtream.
# The Xtream client
A JSON HTTP API exposed by many IPTV panels. Authenticated with a username and password against a base URL; returns categorised live channels, films, series and short-range guide data.
The client is pure JVM too, which has a practical consequence worth knowing when debugging
it: android.util.Log does not resolve there. Temporary instrumentation uses
println and is read back from logcat.
Never log a request URL. It carries the username and the password. This is an acceptance criterion, not a style preference.
# The four guards
Everything here exists because of two account blocks. They are easy to remove by accident and each covers a different failure.
- Prefetch fires on settling — scroll settling on the phone, focus settling on the television — rather than on composition. Rows flown past are not rows anyone read.
- A token bucket inside the client's request path, so every panel call passes it: a burst of eight, refilling one per 400 ms. The burst is sized so a refresh (auth plus six catalogue calls) is never slowed; the refill is the backstop that survives a future caller reintroducing a storm from a screen nobody has written yet. Its arithmetic has a trap in it.
- A block gate covering every call path — refresh, guide, series details and film details. When a panel refuses, the app stops asking for fifteen minutes. The catalogue walk stops at the first refusal; four of its seven calls used to swallow a block as "this account has no films" and carry on.
- The backoff is persisted. The first thing a blocked user does is force-stop the app and reopen it, which used to clear an in-memory deadline and send it straight back to asking.
Tests assert that after one block not a further byte is sent, that a mid-catalogue block ends the refresh at four requests, and that a stored block survives a restart, plus the bucket's burst and pacing. These tests are the guard rails on the guard rails.
# The one line of a token bucket that is easy to get wrong
When a caller finds the bucket empty, it takes a token anyway and pays for it by waiting. The balance is allowed to go negative, and the caller waits off exactly what it borrowed.
Clamping at zero instead reads as the safer choice and halves the spacing. The waiting caller's delay accrues a fresh token; the next caller arrives, finds it, and goes straight through. Requests leave in pairs, and the sustained rate is quietly double the constant that documents it.
That is exactly what ours did. The panel limiter ran at one request per 200 ms for its whole life while the constant beside it said 400 — a guard written, reviewed and believed, and wrong by a factor of two in the direction that matters.
It is the same class of mistake as capping requests in flight and calling it a rate limit, which is what got an account blocked in the first place. Both are a plausible mechanism that is not the one being measured at the other end.
The regression test asserts the total elapsed time across twenty requests rather than the gap between two. A pairwise assertion passes happily while requests leave two at a time; only the aggregate notices.
# A second bucket, for a key that is not ours
The metadata client carries its own token bucket, identical in shape to the panel's: burst of sixteen, refilling one per 125 ms, on the client so every path pays it — a poster tile, a detail screen, and a scan walking thirty thousand titles alike.
What is different is whose credit is being spent. The key belongs to the user. Getting a panel throttled makes our app slow; getting their key throttled affects everything else they use it for. That asymmetry is why the pacing sits in the client rather than in the one caller that looked risky.
It also means the scan needs no throttle of its own. A worker that cannot get a token simply waits here, and the sustained rate is whatever the bucket allows — one place to reason about instead of two that can disagree.
# A refusal is not an answer
Asking the metadata service about a title has three outcomes, not two:
| Outcome | Cached? |
|---|---|
| Found — the service knows this title | Yes |
| No match — it was asked and holds nothing under that name | Yes. Cheap, and worth remembering |
| Refused — rate limited, key rejected, unreachable, unparseable | Never |
The last two used to be the same null, and the conflation is harmless while
browsing: a poster shows no score for a minute. It is ruinous in bulk. A scan that trips a
rate limit half way through would otherwise write tens of thousands of rows saying "matches
nothing", each cached for a fortnight, and the search screen would then report a fully
described catalogue with no genres in it.
A cache may hold answers. It may never hold failures.
Screens still collapse the three back into two, because a missing plot and an unreachable host call for the same rendering: show what the provider supplied and nothing more. The distinction exists for the code deciding what to write down, not for the viewer.