Programmheft (Linux-Magazin, Juni 2026)

Damit Mike Schilli angefangene Filme später fertigsehen kann, hilft eine Go-TUI bei der Service-übergreifenden Heimkinoplanung und -buchführung.

Läuft ein Netflix-Film schon eine halbe Stunde, ginge aber noch eine weitere, und die Äuglein fallen schon zu, wird's Zeit, den Kasten abzuschalten, um das Fernsehvergnügen 24 Stunden später fortzusetzen. In der Zwischenzeit findet sich allerdings ein Youtube-Video aus der interessanten Serie "Lohnt sich das?", das ebenfalls ansprechend losging, aber auch hier musste man abbrechen, denn die Pflicht rief. Und auf Hulu gibt's ja die neuesten Folgen des 2026-Remakes von "King of the Hill", gleich auf die Merkliste damit, die gucken wir auch in Bälde weg!

Mit dem unerschöpflichen Streaming-Angebot einer Vielzahl von unterschiedlichen Anbietern ist es schier unmöglich, den Überblick darüber zu behalten, was man wann angefangen hat, oder wo man eventuell nochmal reinschnuppern möchte. Ja, es ist mir sogar schon passiert, dass ich einen Film abgerufen habe, der mir nach fünf Minuten seltsam bekannt vorkam. Ja hätte ich nur eine App, die mein Filmgedächtnis stützt.

Dabei merkt sich zum Beispiel Netflix, wie weit ein Zuseher (anhand dessen "Profile") in einem Heimkinoerlebnis schon fortgeschritten ist, aber wer tags darauf auf die Webseite schaut, muss sich durch lauthals plärrende Trailer anderer Filme wieder zurück zum gewünschten Film navigigieren, um dort fortzufahren, wo vortags abgeschaltet wurde. Wohl dem, der den URL zum Film gesichtert hat.

Die in dieser Ausgabe vorgestellte 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 merkt sie sich, an welchem Tag der User einen Film weggeguckt hat und erlaubt der Jury zudem eine Wertung von ein bis vier Sternen für das Erzeugnis. Noch nicht bewerte Filme gelten als nicht geguckt und werden zum weggucken nach oben gespült. Rechts oben im Infofenster steht noch die abgekürzte URL zum Film und das Datum des letzten Aufrufs. In der Listbox ausgewählte Filme schnalzen auf Enter hin im extern laufenden Browser hoch.

Abbildung 1: Die Video-Schlange mit Bewertungen in einer Terminal-UI

Freizügige Updates

Zum Einfügen neuer Filme könnte eine Maske im Terminal URLs und Titel entgegennehmen, aber ich arbeite ja nicht an der Rezeption einer Zahnarztpraxis oder beim Einwohnermeldeamt, also springt die TermUI bei mir auf "e" (für Edit) hin in den Vim-Editor, der die zugrundeliegende YAML-Datei anzeigt und Modifizierungen zulässt (Abbildung 2). Nach der Sicherung und dem Verlassen des Editors mit :w:q oder ZZ springt wieder die Terminal-UI hervor und zeigt die geänderten Filmdaten an.

Abbildung 2: Angefangene Filme im YAML-Format

Maus im Terminal

Dabei ist die Terminal-UI keineswegs auf Tastatureingaben beschränkt, auch Mausklicks kriegt die App mit. 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 so um die 80x20 Zeichen misst.

Klickt der User mit der Maus auf einen der vier Sterne des "Ratings"-Bereichs rechts unten, leuchten die aktivierten Sterne grün auf und die Term-UI speichert die Bewertung hinter den Kulissen in der YAML-Datei ab. Der James-Bond-Streifen "Thunderball" mit Sean Connery erhielt zum Beispiel die Wertung "3 Sterne", anders als andere Remakes mit Daniel Craig, der bei mir nicht als Geheimagent sondern bestenfalls als Etagenkellner durchginge.

Ein mit der "Enter"-Taste ausgewählter Film in der Listbox kommt mit der dazu im YAML gespeicherten URL im Firefox-Browser hoch und kann dort wegegeguckt werden. Damit Netflix im Firefox auf Linux läuft, muss der Abonnent die DRM-Einstellung aktivieren (Abbildung 3), sonst verweigert Netflix die Wiedergabe. Wenn's schön macht.

Abbildung 3: Auf dem Linux-Desktop muss Firefox DRM einstellen

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, den URL zum Streaming-Dienst, sowie das Rating von 0 bis 4, wobei 0 "nicht bewertet" bedeutet und 1-4 die vergebenen Sternchen darstellen.

Um Go die Wandlung vom Dateiformat (Yaml) in interne Datenstrukturen zu erleichtern (später mit Marshal() und Unmarshal()), definieren die Zeilen 8-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's dann schon. Bislang unbewertete Videos soll die Listbox ganz nach oben spülen, 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

    01 package main
    02 import (
    03   "gopkg.in/yaml.v2"
    04   "os"
    05   "sort"
    06 )
    07 type Pick struct {
    08   Date   string `yaml:"date"`
    09   Title  string `yaml:"title"`
    10   URL    string `yaml:"url"`
    11   Rating int    `yaml:"rating"`
    12 }
    13 type Picker struct {
    14   YAMLPath string
    15 }
    16 func NewPicker() *Picker {
    17   return &Picker{
    18     YAMLPath: "flixer.yaml",
    19   }
    20 }
    21 func (p *Picker) Load() []Pick {
    22   picks := []Pick{}
    23   b, err := os.ReadFile(p.YAMLPath)
    24   if err != nil {
    25     panic(err)
    26   }
    27   err = yaml.Unmarshal(b, &picks)
    28   if err != nil {
    29     panic(err)
    30   }
    31   p.Sort(picks)
    32   return picks
    33 }
    34 func (p *Picker) Sort(picks []Pick) {
    35   sort.Slice(picks, func(i, j int) bool {
    36     ri, rj := picks[i].Rating, picks[j].Rating
    37     // unrated first
    38     if ri == 0 && rj != 0 {
    39       return true
    40     }
    41     if rj == 0 && ri != 0 {
    42       return false
    43     }
    44     // high rank first
    45     return ri > rj
    46   })
    47 }
    48 func (p *Picker) Save(picks []Pick) {
    49   b, err := yaml.Marshal(picks)
    50   if err != nil {
    51     panic(err)
    52   }
    53   err = os.WriteFile(p.YAMLPath, b, 0644)
    54   if err != nil {
    55     panic(err)
    56   }
    57 }

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) wird von der Haupt-Eventschleife mit PollEvents() ab Zeile 62 abgehandelt. 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 selektiert war. Jegliche Änderung an der Listbox passiert nur intern und wird erst durch den Aufruf von render() ab Zeile 50 tatsächlich in der TUI erscheinen.

Listing 2: flixer.go

    01 package main
    02 import (
    03   "fmt"
    04   "net/url"
    05   "time"
    06   ui "github.com/gizak/termui/v3"
    07   "github.com/gizak/termui/v3/widgets"
    08 )
    09 func main() {
    10   if err := ui.Init(); err != nil {
    11     panic(err)
    12   }
    13   defer ui.Close()
    14   picker := NewPicker()
    15   picks := picker.Load()
    16   lb := widgets.NewList()
    17   lb.Title = "Movies"
    18   lb.SelectedRowStyle = ui.NewStyle(ui.ColorBlue)
    19   lb.TextStyle.Fg = ui.ColorGreen
    20   info := widgets.NewParagraph()
    21   info.Title = "Info"
    22   info.WrapText = true
    23   info.TextStyle.Fg = ui.ColorBlue
    24   rate := NewRating()
    25   grid := ui.NewGrid()
    26   w, h := ui.TerminalDimensions()
    27   grid.SetRect(0, 0, w, h)
    28   grid.Set(
    29     ui.NewRow(1.0,
    30       ui.NewCol(0.5, lb),
    31       ui.NewCol(0.5,
    32         ui.NewRow(0.5, info),
    33         ui.NewRow(0.5, rate.Widget),
    34       ),
    35     ),
    36   )
    37   setInfo := func(pick Pick) {
    38     service := "No URL"
    39     u, err := url.Parse(pick.URL)
    40     if err == nil {
    41       service = u.Hostname()
    42     }
    43     seen := "(not seen)"
    44     if len(pick.Date) != 0 {
    45       seen = pick.Date
    46     }
    47     info.Text = fmt.Sprintf("Service: %s\nSeen: %s\n",
    48       service, seen)
    49   }
    50   render := func() {
    51     lb.Rows = []string{}
    52     for _, it := range picks {
    53       lb.Rows = append(lb.Rows, it.Title)
    54     }
    55     it := picks[lb.SelectedRow]
    56     setInfo(it)
    57     rate.Rating = it.Rating
    58     rate.Update()
    59     ui.Render(grid)
    60   }
    61   render()
    62   for e := range ui.PollEvents() {
    63     switch e.ID {
    64     case "q", "<C-c>":
    65       return
    66     case "e":
    67       ui.Close()
    68       runVim(picker.YAMLPath)
    69       ui.Init()
    70       picks = picker.Load()
    71       render()
    72     case "<Resize>":
    73       pay := e.Payload.(ui.Resize)
    74       grid.SetRect(0, 0, pay.Width, pay.Height)
    75       render()
    76     case "j":
    77       if lb.SelectedRow < len(picks)-1 {
    78         lb.SelectedRow++
    79         render()
    80       }
    81     case "k":
    82       if lb.SelectedRow > 0 {
    83         lb.SelectedRow--
    84         render()
    85       }
    86     case "<Enter>":
    87       picks[lb.SelectedRow].Date = time.Now().Format("2006-01-02")
    88       picker.Save(picks)
    89       runFirefox(picks[lb.SelectedRow].URL)
    90     case "<MouseLeft>":
    91       rate.MouseEvent(e.Payload.(ui.Mouse))
    92       picks[lb.SelectedRow].Rating = rate.Rating
    93       picker.Save(picks)
    94       render()
    95     }
    96   }
    97 }

Die Zusatzinfo zum Video im rechts oben liegenden Paragraph-Widget 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 Terminal-Fensters mit einer Listbox links und zwei untereinander liegenden Paragraph-Widgets rechts bestimmt das Grid-Widget, das das ganze Terminalfenster füllt und wegen der numerisch gesetzten Längenverhältnisse im Aufruf zu grid.Set() ab Zeile 28 den Platz gerecht aufteilt.

Abbildung 4: Proportionen innerhalb der Widget-Matrix

Jury gibt vier Sterne

Bekommt die Haupteventschleife von Listing 2 in 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 denn genau 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 füllbare Sterne) reservierten Bereiches liegt.

Listing 3: rate.go

    01 package main
    02 import (
    03   ui "github.com/gizak/termui/v3"
    04   "github.com/gizak/termui/v3/widgets"
    05 )
    06 type Rating struct {
    07   x, y, w, h int
    08   Widget     *widgets.Paragraph
    09   Rating     int
    10 }
    11 func NewRating() *Rating {
    12   p := widgets.NewParagraph()
    13   p.Title = "Rating"
    14   p.Text = render(0)
    15   return &Rating{Rating: 0, Widget: p}
    16 }
    17 func (r *Rating) Update() {
    18   r.Widget.Text = render(r.Rating)
    19 }
    20 func (r *Rating) MouseEvent(me ui.Mouse) {
    21   in := r.Widget.Inner
    22   if me.X >= in.Min.X && me.X < in.Max.X &&
    23     me.Y >= in.Min.Y && me.Y < in.Max.Y {
    24     idx := (me.X - in.Min.X) / 2
    25     if idx >= 0 && idx <= 4 {
    26       r.Rating = idx
    27       r.Update()
    28     }
    29   }
    30 }
    31 func render(rate int) string {
    32   s := "□ "
    33   for i := 0; i < 4; i++ {
    34     if i < rate {
    35       s += "[★](fg:green) "
    36     } else {
    37       s += "☆ "
    38     }
    39   }
    40   return s
    41 }

Die Ratings-Werte laufen von 0 (kein Rating), über 1 (ein Stern), bis zu 4 (vier Sterne). Die Funktion render() ab Zeile 31 malt das Rating-Widget als Ascii-Art, entsprechend der als Parameter hereingereichten Integer-Wertung. Klickt der User auf das leere Quadrat links der Sternchen, setzt dies 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 dies, um das zugrundeliegende Paragraph-Widget mit dem Rest der UI 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 Zeilen 8 geben dem zu startenden Editorprozess noch eine natürliche Unix-Terminal-Umgebung bereit, mit Standard-Eingabe, -Error, und -Ausgabe. Beendet der User den Editor, endet runVim() und das Hauptprogramm started in Listing 69 wieder die TermUI-Oberfläche, die es vorher in Zeile 67 deaktiviert hatte.

Listing 4: run.go

    01 package main
    02 import (
    03   "os"
    04   "os/exec"
    05 )
    06 func runVim(path string) {
    07   cmd := exec.Command("vim", path)
    08   cmd.Stdin = os.Stdin
    09   cmd.Stdout = os.Stdout
    10   cmd.Stderr = os.Stderr
    11   err := cmd.Run()
    12   if err != nil {
    13     panic(err)
    14   }
    15 }
    16 func runFirefox(url string) {
    17   cmd := exec.Command("/bin/sh", "firefox", url)
    18   if err := cmd.Start(); err != nil {
    19     panic(err)
    20   }
    21 }

Ein Druck auf die Enter-Taste während die Listbox einen Eintrag anzeigt startet den Firefox-Browser, diesmal im Hintergrund, denn die Terminal-UI soll ja 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 angeguckt wird, und setzt in Zeile 87 in Listing 1 das aktuelle Datum im Zeitstempel Date in der Yaml-Datei. Es wird später im Info-Feld zum Video unter "Seen:" zu sehen sein, damit klar ist, dass das Video schon einmal abgerufen wurde.

Professionell reagieren

Die hohe Kunst der Terminal-UI-Programmierung erfordert es weiterhin, festzulegen, was passiert, falls der User 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 wie nach einem Wirbelsturm.

Im vorliegenden Fall erledigt dies 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. Zeile 74 stellt das Grid-Widget auf die neue Fenstergröße ein und render() stellt es 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 immer noch recht brauchbare Widget-Anordnungen produzieren.

Abbildung 5: Auch bei veränderter Fensterform ...

Abbildung 6: ... macht die Terminal-UI eine gute Figur.

Listing 5: build.sh

    1 $ go mod init flixer
    2 $ go mod tidy
    3 $ go build flixer.go data.go rate.go run.go
    4 $ ./flixer

Der übliche Dreisprung aus Listing 6 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 gleichen Verzeichnis, und das Kinoprogramm für die nächsten Tage ist startbereit.

Infos

[1]

Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2026/06/snapshot/

Michael Schilli

arbeitet als Software-Engineer in der San Francisco Bay Area in Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen verschiedener Programmiersprachen. Unter mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.