diff --git a/cmd/ui/index.html.tmpl b/cmd/ui/index.html.tmpl
new file mode 100644
index 0000000..0572b72
--- /dev/null
+++ b/cmd/ui/index.html.tmpl
@@ -0,0 +1,20 @@
+
+
+
+
+
+ {{ range .Series }}
+
+ {{ end }}
+
+
+
+
diff --git a/cmd/ui/main.go b/cmd/ui/main.go
new file mode 100644
index 0000000..cd38877
--- /dev/null
+++ b/cmd/ui/main.go
@@ -0,0 +1,120 @@
+package main
+
+import (
+ "context"
+ "embed"
+ _ "embed"
+ "flag"
+ "fmt"
+ "html/template"
+ "log"
+ "net"
+ "net/http"
+ "os"
+ "os/signal"
+ "path"
+ "strings"
+ "syscall"
+
+ "ffmpeg.d/pkg/fs"
+)
+
+//go:embed *.tmpl
+var TMPL embed.FS
+
+func main() {
+ ctx, can := signal.NotifyContext(context.Background(), syscall.SIGINT)
+ defer can()
+
+ if err := Run(ctx, os.Args[1:]); err != nil {
+ panic(err)
+ }
+}
+
+func Run(ctx context.Context, args []string) error {
+ flags := flag.NewFlagSet(os.Args[0], flag.ContinueOnError)
+ d := flags.String("d", "./testdata/", "directory containing directories of (x.jpg,x.mp4)")
+ p := flags.Int("p", 8080, "port to listen on")
+ if err := flags.Parse(args); err != nil {
+ return err
+ }
+
+ tmpl, err := template.ParseFS(TMPL, "*.tmpl")
+ if err != nil {
+ return err
+ }
+
+ s := &http.Server{
+ Addr: fmt.Sprintf(":%d", *p),
+ Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if strings.HasPrefix(r.URL.Path, "/media/") {
+ http.StripPrefix("/media/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ log.Println(r.URL.Path, "vs", *d)
+ http.FileServer(http.Dir(*d)).ServeHTTP(w, r)
+ })).ServeHTTP(w, r)
+ return
+ }
+
+ type Series struct {
+ HREF string
+ Thumbnail string
+ }
+
+ ds, err := fs.LsD(*d)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ seriesByKey := map[string]Series{}
+ for _, d := range ds {
+ files, err := fs.LsF(d)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ for _, f := range files {
+ key := strings.Split(path.Base(f), ".")[0]
+ v := seriesByKey[key]
+ switch path.Ext(f) {
+ case ".jpg":
+ v.Thumbnail = path.Join(path.Base(d), path.Base(f))
+ case ".mp4":
+ v.HREF = path.Join(path.Base(d), path.Base(f))
+ }
+ seriesByKey[key] = v
+ }
+ }
+ series := []Series{}
+ for _, v := range seriesByKey {
+ series = append(series, v)
+ }
+
+ if err := tmpl.Execute(w, map[string]any{
+ "Series": series,
+ }); err != nil {
+ log.Println(err, *d)
+ }
+ }),
+ BaseContext: func(net.Listener) context.Context {
+ return ctx
+ },
+ }
+ defer s.Shutdown(ctx)
+
+ errs := make(chan error)
+ go func() {
+ defer close(errs)
+
+ errs <- s.ListenAndServe()
+ }()
+
+ select {
+ case err := <-errs:
+ return err
+ case <-ctx.Done():
+ s.Shutdown(ctx)
+ for range errs {
+ }
+ }
+ return nil
+}