Damit Mike Schilli angefangene Filme später fertigschauen kann, greift ihm eine Go-TUI bei der Service-übergreifenden Heimkinoplanung und -buchführung unter die Arme.
Fallen die Äuglein beim Schauen eines Netflix-Films schon eine halbe Stunde vor dessen Ende zu, ist es Zeit, den Kasten abzuschalten, um das Fernsehvergnügen am nächsten Abend fortzusetzen. Anderntags findet sich noch ein YouTube-Video aus der interessanten Serie “Lohnt sich das?”. Es startet zwar ebenfalls ansprechend, aber auch hier hieß es abbrechen, weil ein Termin dazwischenkam. Und auf Hulu gibt es außerdem die neuesten Folgen des 2026-Remakes von “King of the Hill” – gleich auf die Merkliste damit, die gucken wir in Bälde!
Angesichts des unerschöpflichen Streaming-Angebots zahlreicher unterschiedlicher Plattformen gibt es nahezu keine Chance, den Überblick über angefangene oder interessante Videos zu behalten. Vermutlich ist es nicht nur mir schon passiert, dass ich einen Film abgerufen habe, der mir nach fünf Minuten seltsam bekannt vorkam. Es fehlt also eine App, die mein Filmgedächtnis stützt.
Netflix zum Beispiel merkt sich, wie weit ein Nutzer (anhand dessen “Profile”) in einem Heimkinoerlebnis bereits fortgeschritten ist. Doch wer tags darauf auf die Webseite schaut, muss sich durch lauthals plärrende Trailer anderer Filme wieder zurück zum gewünschten Film navigieren, um beim gestrigen Sendeschluss fortzufahren. Wohl dem, der die URL zum Film gesichert hat.
Die Terminal-UI »flixer« speichert Daten zu einem Video in einer YAML-Datei und stellt eine Auswahl von Filmen in einer Listbox bereit (Abbildung 1). Dabei notiert sie, an welchem Tag der User einen Film weggeguckt hat und erlaubt der Jury zudem eine Wertung von ein bis vier Sternen dafür. Noch nicht bewerte Filme gelten als nicht angeschaut und wandern zum Ansehen nach oben. Rechts oben im Infofenster steht noch die abgekürzte URL zum Film und das Datum des letzten Aufrufs. In der Listbox ausgewählte Filme öffnen sich nach einem Tastendruck auf [Enter] im extern laufenden Browser.
Freizügige Updates
Zum Einfügen neuer Filme könnte eine Maske im Terminal URLs und Titel entgegennehmen. Doch auf die verzichte ich gern und springe stattdessen über »e« (für Edit) von der Terminal-UI in den Editor Vim, der die zugrunde liegende YAML-Datei anzeigt und Modifizierungen zulässt (Abbildung 2). Nach dem Sichern und Verlassen des Editors mit »:w:q« oder »ZZ« geht es zurück in die Terminal-UI, die die geänderten Filmdaten anzeigt.
Dabei ist die Terminal-UI keineswegs auf Tastatureingaben beschränkt, auch Mausklicks registriert die App. Die Klick-Events kommen nicht in Pixelkoordinaten an wie auf einer GUI, sondern als Reihen- und Spaltenwerte in der Zeichen-Matrix des Terminals, das typischerweise ungefähr 80 x 20 Zeichen misst.
Klickt der User mit der Maus auf einen der vier Sterne im Bereich Ratings rechts unten, leuchten die aktivierten Sterne grün auf. Hinter den Kulissen speichert die Terminal-UI die Bewertung in der YAML-Datei ab. Der James-Bond-Streifen “Thunderball” mit Sean Connery erhielt beispielsweise drei Sterne, anders als die Filme mit Daniel Craig, der bei mir nicht als Geheimagent sondern bestenfalls als Etagenkellner durchginge.
Ein per [Enter] ausgewählter Film in der Listbox kommt mit der im YAML gespeicherten URL im Firefox-Browser hoch und lässt sich dort anschauen. Damit Netflix im Firefox auf Linux läuft, muss der Abonnent die DRM-Einstellung aktivieren (Abbildung 3), sonst verweigert Netflix die Wiedergabe.

Abbildung 3: Auf dem Linux-Desktop müssen Sie in Firefox DRM aktivieren, um Netflix-Videos abzuspielen.
Yaml fest integriert
Listing 1 zeigt das Datenmodell. Jeder Video-Eintrag »Pick« ab Zeile 7 enthält das Datum der letzten Wiedergabe (»Date«), den Titel des Werks, die URL zum Streaming-Dienst, sowie das »Rating« von 0 bis 4, wobei 0 “nicht bewertet” bedeutet und 1 bis 4 den vergebenen Sternchen entsprechen.
Um Go die Wandlung vom Dateiformat (Yaml) in interne Datenstrukturen zu erleichtern (später mit »Marshal()« und »Unmarshal()«), definiert der Code in den Zeilen 8 bis 11 die durch Kleinschreibung abweichenden Namen der Felder. Der Konstruktor »NewPicker()« ab Zeile 16 setzt den Pfad zur Yaml-Datei als »flixer.yaml«, und das war es dann schon.
Bislang unbewertete Videos soll die Listbox ganz nach oben setzen, damit der User endlich hineinschnuppert. Dabei hilft die Funktion »Sort()« ab Zeile 34, die im Callback zu »sort.Slice()« jeweils zwei Einträge A und B miteinander vergleicht und einen wahren Wert zurückgibt, falls A vor B erscheinen soll. Bereits bewertete Filme sortiert der Algorithmus von “gut” bis “schlecht”, sodass Spitzenfilme weiter oben liegen.
Listing 1
data.go
package main
import (
"gopkg.in/yaml.v2"
"os"
"sort"
)
type Pick struct {
Date string `yaml:"date"`
Title string `yaml:"title"`
URL string `yaml:"url"`
Rating int `yaml:"rating"`
}
type Picker struct {
YAMLPath string
}
func NewPicker() *Picker {
return &Picker{
YAMLPath: "flixer.yaml",
}
}
func (p *Picker) Load() []Pick {
picks := []Pick{}
b, err := os.ReadFile(p.YAMLPath)
if err != nil {
panic(err)
}
err = yaml.Unmarshal(b, &picks)
if err != nil {
panic(err)
}
p.Sort(picks)
return picks
}
func (p *Picker) Sort(picks []Pick) {
sort.Slice(picks, func(i, j int) bool {
ri, rj := picks[i].Rating, picks[j].Rating
// unrated first
if ri == 0 && rj != 0 {
return true
}
if rj == 0 && ri != 0 {
return false
}
// high rank first
return ri > rj
})
}
func (p *Picker) Save(picks []Pick) {
b, err := yaml.Marshal(picks)
if err != nil {
panic(err)
}
err = os.WriteFile(p.YAMLPath, b, 0644)
if err != nil {
panic(err)
}
}
TUI mit Widget-Matrix
Listing 2 zeigt das Hauptprogramm der App, die mit dem Paket »termui« von GitHub die TUI aufbaut und verwaltet. Die links erscheinende Listbox vom Typ »widget.List« enthält die Videotitel als Slice von Strings im Attribut »Rows«. Die Navigation mit »j« und »k« (wie in Vim) handelt die Haupteventschleife mit »PollEvents()« ab Zeile 62 ab. Kommt zum Beispiel in Zeile 76 der Tastendruck »”j”« an, fährt »lb.SelectedRow« um eins nach oben, falls nicht eh schon das Element auf Platz eins ausgewählt war. Jegliche Änderung an der Listbox passiert nur intern und erscheint erst durch den Aufruf von »render()« ab Zeile 50 tatsächlich in der Terminal UI.
Listing 2
flixer.go
package main
import (
"fmt"
"net/url"
"time"
ui "github.com/gizak/termui/v3"
"github.com/gizak/termui/v3/widgets"
)
func main() {
if err := ui.Init(); err != nil {
panic(err)
}
defer ui.Close()
picker := NewPicker()
picks := picker.Load()
lb := widgets.NewList()
lb.Title = "Movies"
lb.SelectedRowStyle = ui.NewStyle(ui.ColorBlue)
lb.TextStyle.Fg = ui.ColorGreen
info := widgets.NewParagraph()
info.Title = "Info"
info.WrapText = true
info.TextStyle.Fg = ui.ColorBlue
rate := NewRating()
grid := ui.NewGrid()
w, h := ui.TerminalDimensions()
grid.SetRect(0, 0, w, h)
grid.Set(
ui.NewRow(1.0,
ui.NewCol(0.5, lb),
ui.NewCol(0.5,
ui.NewRow(0.5, info),
ui.NewRow(0.5, rate.Widget),
),
),
)
setInfo := func(pick Pick) {
service := "No URL"
u, err := url.Parse(pick.URL)
if err == nil {
service = u.Hostname()
}
seen := "(not seen)"
if len(pick.Date) != 0 {
seen = pick.Date
}
info.Text = fmt.Sprintf("Service: %s\nSeen: %s\n",
service, seen)
}
render := func() {
lb.Rows = []string{}
for _, it := range picks {
lb.Rows = append(lb.Rows, it.Title)
}
it := picks[lb.SelectedRow]
setInfo(it)
rate.Rating = it.Rating
rate.Update()
ui.Render(grid)
}
render()
for e := range ui.PollEvents() {
switch e.ID {
case "q", "<C-c>":
return
case "e":
ui.Close()
runVim(picker.YAMLPath)
ui.Init()
picks = picker.Load()
render()
case "<Resize>":
pay := e.Payload.(ui.Resize)
grid.SetRect(0, 0, pay.Width, pay.Height)
render()
case "j":
if lb.SelectedRow < len(picks)-1 {
lb.SelectedRow++
render()
}
case "k":
if lb.SelectedRow > 0 {
lb.SelectedRow--
render()
}
case "<Enter>":
picks[lb.SelectedRow].Date = time.Now().Format("2006-01-02")
picker.Save(picks)
runFirefox(picks[lb.SelectedRow].URL)
case "<MouseLeft>":
rate.MouseEvent(e.Payload.(ui.Mouse))
picks[lb.SelectedRow].Rating = rate.Rating
picker.Save(picks)
render()
}
}
}
Die Zusatzinfo zum Video im rechts oben liegenden Widget »Paragraph« frischt »setInfo()« ab Zeile 37 auf. Rechts unten im Fenster steht das Rating-Widget, dessen Details später Listing 3 implementiert. Die räumliche Aufteilung des Terminalfensters mit einer Listbox links und zwei untereinander liegenden Paragraph-Widgets rechts bestimmt das Grid-Widget. Es füllt das ganze Terminalfenster und teilt wegen der numerisch gesetzten Längenverhältnisse im Aufruf zu »grid.Set()« ab Zeile 28 den Platz gerecht auf.

Abbildung 4: Proportionen innerhalb der Widget-Matrix.
Jury-Bewertung: vier Sterne
Bekommt die Haupteventschleife von Listing 2 in Zeile 90 einen Mausklick mit, ruft sie das Spezial-Widget »Rating« in Listing 3 mit »MouseEvent()« ab Zeile 20 dort auf. Als Fundament dient ein »Paragraph«-Widget aus dem »termui«-Fundus, das der Grid-Layouter vorher an den zugewiesenen Platz im Fenster bugsiert hat. Das Widget kann nun mit »Min« und »Max« abfragen, wo es gelandet ist. Die vierfache »if«-Bedingung ab Zeile 22 prüft anhand der Koordinaten, ob der Mausklick innerhalb des für die fünf Symbole (ein leeres Quadrat und vier ausfüllbare Sterne) reservierten Bereichs liegt.
Listing 3
rate.go
package main
import (
ui "github.com/gizak/termui/v3"
"github.com/gizak/termui/v3/widgets"
)
type Rating struct {
x, y, w, h int
Widget *widgets.Paragraph
Rating int
}
func NewRating() *Rating {
p := widgets.NewParagraph()
p.Title = "Rating"
p.Text = render(0)
return &Rating{Rating: 0, Widget: p}
}
func (r *Rating) Update() {
r.Widget.Text = render(r.Rating)
}
func (r *Rating) MouseEvent(me ui.Mouse) {
in := r.Widget.Inner
if me.X >= in.Min.X && me.X < in.Max.X &&
me.Y >= in.Min.Y && me.Y < in.Max.Y {
idx := (me.X - in.Min.X) / 2
if idx >= 0 && idx <= 4 {
r.Rating = idx
r.Update()
}
}
}
func render(rate int) string {
s := "? "
for i := 0; i < 4; i++ {
if i < rate {
s += "[?](fg:green) "
} else {
s += "? "
}
}
return s
}
Die Rating-Werte laufen von »0« (kein Rating), über »1« (ein Stern), bis zu »4« (vier Sterne). Die Funktion »render()« ab Zeile 31 in Listing 3 zeichnet das Rating-Widget als ASCII-Art, entsprechend der als Parameter hereingereichten Integer-Wertung. Klickt der Nutzer auf das leere Quadrat links der Sternchen, dreht das das Rating auf “ungesetzt” zurück. Ein Klick auf einen Stern lässt Sterne bis einschließlich des geklickten aufleuchten und das Hauptprogramm sichert das Urteil der Jury mit »picker.Save()« permanent in der Yaml-Datei.
Da das Feld »Widget« der »Rating«-Struktur in Zeile 8 von Listing 3 mit einem Großbuchstaben beginnt, kann ein steuerndes Paket darauf zugreifen. Das Hauptprogramm nutzt das, um das unterliegende »Paragraph«-Widget mit dem Rest der UI zu rendern.
An und aus und an
Listing 4 startet mit »runVim()« den Vim-Editor in der App, zum direkten Editieren der Yaml-Datei. Hierzu muss die App die »Termui«-Einstellungen zurücksetzen, denn Vim benötigt ein unverstelltes Terminal. Die Kommandos ab Zeile 8 stellen dem zu startenden Editorprozess noch eine natürliche Unix-Terminal-Umgebung bereit, mit Standard-Eingabe, -Error, und -Ausgabe. Schließt der User den Editor, endet »runVim()« und das Hauptprogramm aus Listing 2 startet in Zeile 69 wieder die Terminal-UI-Oberfläche, die es zuvor in Zeile 67 deaktiviert hatte.
Listing 4
run.go
package main
import (
"os"
"os/exec"
)
func runVim(path string) {
cmd := exec.Command("vim", path)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err := cmd.Run()
if err != nil {
panic(err)
}
}
func runFirefox(url string) {
cmd := exec.Command("/bin/sh", "firefox", url)
if err := cmd.Start(); err != nil {
panic(err)
}
}
Ein Druck auf [Enter] während die Listbox einen Eintrag anzeigt, startet den Firefox – diesmal im Hintergrund, denn die Terminal-UI soll weiterlaufen, während der Browser hochfährt und den Film anzeigt. Die Funktion »Start()« aus dem Paket »os/exec« erzeugt einen neuen externen Unix-Prozess und die Kombination aus »/bin/sh« und dem Firefox-Executable findet den Browser im voreingestellten PATH der Shell.
Diese Aktion wertet die App als Indikator dafür, dass das Video nun läuft, und setzt in Zeile 87 in Listing 1 das aktuelle Datum im Zeitstempel »Date« in der Yaml-Datei. Es taucht später im Informationsfeld zum Video unter “Seen:” auf, damit klar ist, dass der Zuschauer das Video schon einmal konsumiert hat.
Professionell reagieren
Die hohe Kunst der Terminal-UI-Programmierung erfordert es weiterhin, festzulegen, was passiert, falls der Nutzer das Terminalfenster auf- oder zuzieht. Eine gute App arrangiert die Einzelfelder auf harmonische Art und Weise, ohne dass die ASCII-Zeichen kreuz und quer herumliegen.
Im vorliegenden Fall erledigt das praktischerweise das Grid-Widget aus Listing 2 im Callback zu »”<Resize>”« ab Zeile 72. Dem Event liegt als Payload die neu eingestellte Breite und Höhe des Terminalfensters bei. Der Code in Zeile 74 stellt das Grid-Widget auf die neue Fenstergröße ein und »render()« die Dimensionen der untergeordneten Widgets (falls möglich) wieder her, den Prozentwerten in Abbildung 4 folgend. Abbildung 5 und Abbildung 6 zeigen Fälle extrem gequetschter Fenster, die trotzdem noch recht brauchbare Widget-Anordnungen produzieren.
Listing 5
build.sh
$ go mod init flixer $ go mod tidy $ go build flixer.go data.go rate.go run.go $ ./flixer
Der übliche Dreisprung aus Listing 5 macht aus den vier Source-Dateien dieser Ausgabe ein Executable »flixer«. Es fährt die UI hoch, arrangiert die Widgets und wartet auf User-Eingaben in der Haupteventschleife. Fehlen nur noch ein paar Einträge in der Datei »flixer.yaml« im selben Verzeichnis, und das Kinoprogramm für die nächsten Tage ist startbereit. (uba)










