Die Unsitte, alles in den Browser zu verlegen, ist ja schon aus einer
Freiheitsperspektive zu verurteilen – „die Plattform“ gibt die
Benutzerschnittstelle vor, kann die Software jederzeit abschalten und
sieht (potenziell) noch den kleinsten Klick der NutzerIn (vgl. WWWorst
App Store). Bei Mediatheken und Livestreams kommt noch dazu, dass
videoabspielende Browser jedenfalls in der Vergangenheit gerne mal
einen Faktor zwei oder drei mehr Strom verbraucht haben als ordentliche
Videosoftware, von vermeidbaren Hakeleien aufgrund von schlechter
Hardwarenutzung und daraus resultierendem Elektroschrott ganz zu
schweigen.
Es gibt also viele Gründe, speziell im Videobereich den Web-Gefängnissen
entkommen zu wollen. Für übliche Videoplattformen gibt es dafür
Meisterwerke der EntwicklerInnengeduld wie youtube-dl oder streamlink.
Für die Live-Ströme der öffentlich-rechtlichen Anstalten hingegen habe
ich zumindest nichts Paketiertes gefunden. Vor vielen Jahren hatte ich
von einem Freund ein paar screenscrapende Zeilen Python erledigt dazu.
In diesen Zeiten müssen allerdings Webseiten alle paar Monate komplett
umgeschrieben („jquery ist doch total alt, angular.js hat auch schon
bessere Tage gesehen“) und regelauncht werden, und das Skript ging mit
einem Relaunch ca. 2015 kaputt. Als ich am Freitag die Tagesschau
ansehen wollte, ohne DVB-Hardware zu haben, habe ich mich deshalb nach
einer Neufassung des Skripts umgesehen.
Das Ergebnis war eine uralte Seite mit mpv-Kommandozeilen und ein
Verweis auf ein von den MediathekView-Leuten gepflegtes Verzeichnis
von Live-Strömen. Da stehen zwar oben alt aussehende Timestamps drin,
das Log aber zeigt, dass der Kram durchaus gepflegt wird.
Aus letzterem habe ich livetv.py (ja, das ist ein
Download-Link) gestrickt, ein weiteres meiner Ein-Datei-Programme.
Installiert mpv (und Python, klar), macht chmod +x livetv.py und
sagt dann ./livetv.py ARD Livestream – fertig. Bequemer ist es
natürlich, das Skript einfach irgendwo in den Pfad zu legen.
Das Argument, das das Programm haben will, kann irgendeine Zeichenfolge
sein, die eindeutig einen Sender identifiziert, also nur in einem
Sendernamen vorkommt. Welche Sender es gibt, gibt das Programm aus,
wenn es ohne Argumente aufgerufen wird:
$ livetv.py
3Sat Livestream
...
PHOENIX Livestream
Mit der aktuellen Liste könnt ihr z.B. livetv.py Hamburg sagen, weil
„Hamburg“ (auch nach Normalisierung auf Kleinbuchstaben) nur in einer
Stationsbezeichnung vorkommt, während „SWR“ auf eine Rückfrage führt:
$ livetv.py SWR
SWR BW Livestream? SWR RP Livestream?
„SWR BW“ (mit oder ohne Quotes auf der Kommandozeile) ist dann
eindeutig, woraufhin livetv an den mpv übergibt.
Ich gehe davon aus, dass die Anstalten die URLs ihrer Streams auch
weiterhin munter verändern werden. Deshalb kann sich das Programm neue
URLs von den MediathekView-Leuten holen, und zwar durch den Aufruf:
$ livety.py update
Das schreibt, wenn alles gut geht, die Programmdatei neu und
funktioniert mithin nur, wenn ihr Schreibrechte auf das Verzeichnis
habt, in dem livetv.py liegt – was besser nicht der Fall sein sollte,
wenn ihr es z.B. nach /usr/local/bin geschoben habt.
A propos Sicherheitsüberlegungen: Der update-Teil vertraut gegenwärtig
ein wenig den MediathekView-Repo – ich entschärfe zwar die
offensichtlichsten Probleme, die durch Kopieren heruntergeladenen
Materials in ausführbaren Code entstehen, aber ich verspreche nicht,
raffinierteren Angriffen zu widerstehen. Abgesehen vom update-Teil
halte ich das Programm für sicherheits-unkritisch. Es redet selbst auch
nicht mit dem Netz, sondern überlässt das dem mpv.
Livetv.py sagt per Voreinstellung dem mpv, es solle einen „vernünftigen“
Stream aussuchen, was sich im Augenblick zu „2 Mbit/s oder weniger“
übersetzt. Wer eine andere Auffassung von „vernünftig“ hat, kann die
--max-bitrate-Option verwenden, die einfach an mpvs
--hls-bitrate weitergereicht wird. Damit könnt ihr
$ livetv.py --max-bitrate min arte.de
für etwas sagen, das für die Sender, die ich geprüft habe, auch auf sehr
alten Geräten noch geht,
$ livetv.py --max-bitrate max arte.fr
für HD-Wahnsinn oder
$ livetv.py --max-bitrate 4000000 dw live
für einen Stream, der nicht mehr als 4 MB/s verbraucht.
Technics
Die größte Fummelei war, die Kanalliste geparst zu bekommen, denn aus
Gründen, für die meine Fantasie nicht ausreicht (MediathekView-Leute:
Ich wäre echt neugierig, warum ihr das so gemacht habt), kommen die
Sender in einem JSON-Objekt (statt einer Liste), und jeder Sender hat
den gleichen Schlüssel:
"X" : [ "3Sat", "Livestream", ...
"X" : [ "ARD", "Livestream", ...
– ein einfaches json.loads liefert also ein Dictionary, in dem nur
ein Kanal enthalten ist.
Auch wenn ich sowas noch nie gesehen habe, ist es offenbar nicht ganz
unüblich, denn der json-Parser aus der Python-Standardbibliothek ist
darauf vorbereitet. Wer ein JSONDecoder-Objekt konstruiert,
kann in object_pairs_hook eine Funktion übergeben, die entscheiden
kann, was mit solchen mehrfach besetzen Schlüsseln pasieren soll. Sie
bekommt vom Parser eine Sequenz von Schlüssel-Wert-Paaren übergeben.
Für meine spezielle Anwendung will ich lediglich ein Mapping von
Stationstiteln (in Element 3 der Kanaldefinition) zu Stream-URLs (in
Element 8) rausziehen und den Rest der Information wegwerfen. Deshalb
reicht mir Code wie dieser:
def load_stations():
channels = {}
def collect(args):
for name, val in args:
if name=="X":
channels[val[2]] = val[8]
dec = json.JSONDecoder(object_pairs_hook=collect)
dec.decode(LIST_CACHE)
return channels
– das channels-Dictionary, das collect nach und nach füllt, ist
wegen Pythons Scoping-Regeln das, das load_stations definiert. Die
collect-Funktion ist also eine Closure, eine Funktion, die Teile
ihres Definitionsumfelds einpackt und mitnehmt. So etwas macht das
Leben von AutorInnen von Code sehr oft leichter – aber vielleicht nicht
das Leben der späteren LeserInnen. Dass die collect-Funktion als
ein Seiteneffekt von dec.decode(...) aufgerufen wird und dadurch
channels gefüllt wird, braucht jedenfalls erstmal etwas Überlegung.
Der andere interessante Aspekt am Code ist, dass ich die Liste der
Live-Streams nicht separat irgendwo ablegen wollte. Das Ganze soll ja ein
Ein-Datei-Programm sein, das einfach und ohne Installation überall
läuft, wo es Python und mpv gibt. Ein Blick ins Commit-Log der
Kanalliste verrät, dass sich diese allein im letzten Jahr über ein
dutzend Mal geändert hat (herzlichen Dank an dieser Stelle an die
Maintainer!). Es braucht also eine Möglichkeit, sie aktuell zu halten,
wenn ich die Liste nicht bei jedem Aufruf erneut aus dem Netz holen
will. Das aber will ich auf keinen Fall, weniger, um github zu schonen,
mehr, weil sonst github sehen kann, wer so alles wann livetv.py
verwendet.
Ich könnte die Liste beim ersten Programmstart holen und
irgendwo im Home (oder gar unter /var/tmp) speichern. Aber dann setzt
zumindest der erste Aufruf einen Datenpunkt bei github, und zwar für
neue NutzerInnen eher überraschend. Das kann ich verhindern,
wenn ich die Liste einfach im Programm selbst speichere, also
selbstverändernden Code schreibe.
Das ist in interpretierten Sprachen eigentlich nicht schwierig, da bei
ihnen Quellcode und ausgeführtes Programm identisch sind. Zu den
großartigen Ideen in Unix gehört weiter, dass (das Äquivalent von)
sys.argv[0] den Pfad zur gerade ausgeführten Datei enthält. Und so
dachte ich mir, ich ziehe mir einfach den eigenen Programmcode und
ersetze die Zuweisung des LIST_CACHE (das json-Literal von github)
per Holzhammer, also regulärem Ausdruck. In Code:
self_path = sys.argv[0]
with open(self_path, "rb") as f:
src = f.read()
src = re.sub(b'(?s)LIST_CACHE = """.*?"""',
b'LIST_CACHE = """%s"""'%(in_bytes.replace(b'"', b'\\"')),
src)
with open(self_path, "wb") as f:
f.write(src)
Dass das Schreiben ein eigener, fast atomarer Schritt ist, ist
Vorsicht: Wenn beim Ersetzen etwas schief geht und das Programm eine
Exception wirft, ist das open(... "wb") noch nicht gelaufen. Es
leert ja die Programmdatei, und solange es das nicht getan hat, hat mensch
eine zweite Chance. Ähnlich übrigens meine Überlegung, das
alles in Binärstrings zu bearbeiten: beim Enkodieren kann es immer
mal Probleme geben, die am Ende zu teilgeschriebenen Dateien führen
können. Vermeide ich Umkodierungen, kann zumindest die Sorte von
Fehler nicht auftreten.
Wie dem auch sei: Dieser Code funktioniert nicht. Und zwar in recht
typischer Weise innerhalb der Familie von Quines und anderen
Selbstanwendungsproblemen: das re.sub erwischt auch seine beiden
ersten Argumente, denn beide passen auf das Muster LIST_CACHE =
""".*?""". Deshalb würden von livetv.py update auch diese beiden
durch das json-Literal mit den Senderdefinitionen ersetzt. Das so
geänderte Programm hat zwei Syntaxfehler, weil das json natürlich nicht
in die String-Literale passt, und selbst wenn es das täte, gingen keine
weiteren Updates mehr, da die Such- und Ersatzpatterns wegersetzt wären.
Eine Lösung in diesem Fall ist geradezu billig: in Python kann mensch ein
Leerzeichen auch als '\x20' schreiben (das ASCII-Zeichen Nummer
0x20 oder 32), und schon matcht der reguläre Ausdruck nicht mehr sich
selbst:
re.sub(b'(?s)LIST_CACHE\x20= """.*?"""',
b'LIST_CACHE\x20= """%s"""'...
Sicherheitsfragen
Ein Programm, das Daten aus dem Netz in sich selbst einbaut, muss
eigentlich eine Ecke vorsichtiger vorgehen als dieses hier. Stellt euch
vor, irgendwer bekommt etwas wie:
{ "Filmliste": [....,
"X": ["...
"Igore": ['"""; os.system("rm -r ~"); """']
}
in das MediathekView-Repo committet; das würde für die MediathekView
immer noch prima funktionieren, das Objekt mit dem Schlüssel Ignore
würde fast sicher tatsächlich einfach ignoriert.
Wer dann allerdings livetv.py update laufen lässt, bekommt den
ganzen Kram in Python-Quelltext gepackt, und der Inhalt des
Ignore-Schlüssels wird vom Python-Parser gelesen. Der sieht, wie der
lange String mit den drei Anführungszeichen geschlossen wird. Danach
kommt eine normale Python-Anweisung. Die hier das Home-Verzeichnis der
NutzerIn löscht. Python wird die treu ausführen. Bumm.
So funktioniert das in Wirklichkeit zum Glück nicht, denn ich escape im
realen Code Anführungszeichen (das .replace(b'"', b'\\"')). Damit …