streaming

Media streaming and broadcast systems in Go
Log | Files | Refs | README | LICENSE

commit 61ffe36e6efb13fc404698f0769f345d2807a2a6
parent ff9281350d5e4901cc7da5aad607b45bb84e58f9
Author: Oliver Lowe <o@olowe.co>
Date:   Tue,  4 Jun 2024 15:33:57 +1000

cmd/hlsserve: import our poor man's livestream server

More documentation to come

Diffstat:
Am3u8/cmd/hlsproxy/hlsproxy.go | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Am3u8/cmd/hlsserve/hlsserve.go | 170+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 261 insertions(+), 0 deletions(-)

diff --git a/m3u8/cmd/hlsproxy/hlsproxy.go b/m3u8/cmd/hlsproxy/hlsproxy.go @@ -0,0 +1,91 @@ +package main + +import ( + "fmt" + "log" + "net/http" + "net/url" + "os" + "path" + "time" + + "github.com/untangledco/streaming/m3u8" +) + +const usage string = "usage: hlsproxy url" + +func init() { + log.SetFlags(0) + log.SetPrefix("hlssrv: ") +} + +func servePlaylist(p *m3u8.Playlist) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + http.Error(w, "nope", http.StatusMethodNotAllowed) + } + log.Println(req.Method, req.URL) + w.Header().Set("Content-Type", "application/vnd.apple.mpegurl") + if err := m3u8.Encode(w, p); err != nil { + log.Println("encode playlist:", err) + } + } +} + +func injectBreak(plist *m3u8.Playlist, dur time.Duration, schedule int) error { + // ticks := (dur * time.Second) / 90000 + var money []m3u8.Segment + for i, seg := range plist.Segments { + if i%schedule != 0 || i == 0 { + money = append(money, seg) + continue + } + adbreak := m3u8.Segment{ + URI: "https://test-streams.mux.dev/test_001/stream_1000k_48k_640x360_000.ts", + Duration: dur, + Discontinuity: true, + } + seg.Discontinuity = true + money = append(money, adbreak, seg) + } + plist.Segments = money + return nil +} + +func main() { + if len(os.Args) != 2 { + fmt.Fprintln(os.Stderr, usage) + os.Exit(127) + } + link, err := url.Parse(os.Args[1]) + if err != nil { + log.Fatal(err) + } + resp, err := http.Get(link.String()) + if err != nil { + log.Fatal("get playlist:", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + log.Fatal("get playlist: remote status:", resp.Status) + } + source, err := m3u8.ParsePlaylist(resp.Body) + if err != nil { + log.Fatal("parse playlist:", err) + } + link.Path = path.Dir(link.Path) + "/" + for i := range source.Media { + source.Media[i].URI = link.String() + source.Media[i].URI + } + for i := range source.Variants { + source.Variants[i].URI = link.String() + source.Variants[i].URI + } + for i := range source.Segments { + source.Segments[i].URI = link.String() + source.Segments[i].URI + } + if err := injectBreak(source, 10*time.Second, 8); err != nil { + log.Fatalf("inject ad break: %v", err) + } + http.HandleFunc("/", servePlaylist(source)) + log.Fatal(http.ListenAndServe(":8080", nil)) +} diff --git a/m3u8/cmd/hlsserve/hlsserve.go b/m3u8/cmd/hlsserve/hlsserve.go @@ -0,0 +1,170 @@ +package main + +import ( + "bytes" + "fmt" + "io" + "log" + "net" + "net/http" + "os" + "path" + "path/filepath" + "strings" + "time" + + "github.com/untangledco/streaming/m3u8" +) + +func init() { + log.SetFlags(0) + log.SetPrefix("hlsserve: ") + var err error + cacheDir, err = os.UserCacheDir() + if err != nil { + log.Fatalln("user cache dir:", err) + } + cacheDir = filepath.Join(cacheDir, "hlsserve") +} + +const tsSize int = 188 + +// rule of thumb for UDP transport +const maxTSBytes int = 7 * tsSize + +const segmentDuration = 4 * time.Second + +var cacheDir string +var sequence int + +func removeOld(dir string, maxAge time.Duration) error { + ents, err := os.ReadDir(dir) + if err != nil { + return err + } + for _, dent := range ents { + if !strings.HasSuffix(dent.Name(), ".ts") { + continue + } + stat, err := dent.Info() + if err != nil { + return err + } + if time.Since(stat.ModTime()) > maxAge { + if err := os.Remove(filepath.Join(dir, dent.Name())); err != nil { + return err + } + sequence++ + } + } + return nil +} + +func dumpMedia(dir string, r io.Reader, ch <-chan time.Time) error { + var segment int + segments := &bytes.Buffer{} + for { + select { + case <-ch: + s := fmt.Sprintf("%04d.ts", segment) + fname := path.Join(dir, s) + if err := os.WriteFile(fname, segments.Bytes(), 0644); err != nil { + return err + } + segments.Reset() + segment++ + default: + _, err := io.CopyN(segments, r, int64(maxTSBytes)) + if err != nil { + return err + } + } + } + fmt.Fprintln(os.Stderr, "shouldn't really be returning here...?") + return nil +} + +func makePlaylist(dir string) (*m3u8.Playlist, error) { + names, err := filepath.Glob(filepath.Join(dir, "*.ts")) + if err != nil { + return nil, fmt.Errorf("find segments: %w", err) + } + playlist := &m3u8.Playlist{ + Version: 7, + TargetDuration: segmentDuration, + Sequence: sequence, + } + for _, name := range names { + seg := m3u8.Segment{URI: path.Base(name), Duration: playlist.TargetDuration} + playlist.Segments = append(playlist.Segments, seg) + } + return playlist, nil +} + +const usage string = "usage: hlsserve dir" + +func servePlaylist(dir string) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + playlist, err := makePlaylist(dir) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", m3u8.MimeType) + if err := m3u8.Encode(w, playlist); err != nil { + log.Printf("encode playlist: %v", err) + } + } +} + +func setCache(seconds int, next http.Handler) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Cache-Control", fmt.Sprintf("max-age=%d", seconds)) + next.ServeHTTP(w, req) + } +} + +func main() { + if len(os.Args) > 2 { + fmt.Fprintln(os.Stderr, usage) + os.Exit(2) + } else if len(os.Args) == 2 { + cacheDir = os.Args[1] + } + if err := os.MkdirAll(cacheDir, 0755); err != nil { + log.Fatal(err) + } + + ln, err := net.Listen("tcp", ":9000") + if err != nil { + log.Fatal(err) + } + conn, err := ln.Accept() + if err != nil { + log.Fatal(err) + } + + go func() { + ticker := time.NewTicker(segmentDuration) + if err := dumpMedia(cacheDir, conn, ticker.C); err != nil { + log.Fatalln("dump media:", err) + } + }() + + go func() { + ticker := time.NewTicker(segmentDuration) + for { + select { + case <-ticker.C: + if err := removeOld(cacheDir, 3*8*segmentDuration); err != nil { + log.Println("remove old segments:", err) + } + } + } + }() + + http.Handle("/playlist.m3u8", servePlaylist(cacheDir)) + fsys := http.FileServer(http.FS(os.DirFS(cacheDir))) + http.Handle("/", setCache(60, fsys)) + log.Fatal(http.ListenAndServe(":8000", nil)) +}