Salta al contenuto principale

Video Streaming in Go: Range Request HTTP, Segmenti HLS e FFmpeg

·9 minuti
Indice dei contenuti
Il video streaming corretto in HTTP non riguarda il piping di byte. Riguarda le range request che permettono ai player di fare seeking, i segmenti HLS che abilitano il bitrate adattivo, e mantenere FFmpeg come sottoprocesso mentre Go gestisce HTTP. Fai bene queste tre cose e hai un server video funzionante.

http.ServeFile gestisce effettivamente le range request correttamente per i file statici. Il problema sorge quando cerchi di costruire qualcosa di piu sofisticato: transcodifica al volo, live streaming, o segmentazione HLS. Questo articolo copre ogni scenario con codice funzionante, e spiega cosa gestisce e cosa non gestisce http.ServeFile.

Come funzionano i video player
#

Un video player non richiede l’intero file in una singola chiamata HTTP. Invia una richiesta Range:

GET /video.mp4 HTTP/1.1
Range: bytes=0-1048575

Il server risponde con 206 Partial Content e l’intervallo di byte richiesto:

HTTP/1.1 206 Partial Content
Content-Range: bytes 0-1048575/52428800
Accept-Ranges: bytes
Content-Length: 1048576

Quando l’utente cerca una posizione diversa, il player invia una nuova range request per l’offset di byte corrispondente a quel timestamp. Senza supporto delle range request, il seeking azzera dall’inizio.

Servire un file video statico con supporto range
#

Per un file statico su disco, http.ServeFile gestisce automaticamente le range request. Questa e l’implementazione corretta e completa:

cmd/static/main.go
package main

import (
	"log"
	"net/http"
)

func main() {
	http.HandleFunc("/video/", func(w http.ResponseWriter, r *http.Request) {
		// ServeFile gestisce header Range, risposte 206, ETag e If-Modified-Since.
		// Il path dopo /video/ mappa al filesystem.
		http.ServeFile(w, r, "videos/"+r.URL.Path[len("/video/"):])
	})

	log.Println("in ascolto su :8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}
Nota

http.ServeFile e appropriato per i file statici. Gestisce gli header Range, ETag, If-Modified-Since, If-None-Match e If-Range. Usalo per la distribuzione di video statici. Il resto di questo articolo copre scenari in cui hai bisogno di maggiore controllo.

Implementare le range request manualmente
#

Quando servi byte da qualcosa di diverso da un file (un buffer, uno stream, un output transcodificato), devi implementare la gestione range tu stesso:

rangeserver/range.go
package rangeserver

import (
	"fmt"
	"io"
	"net/http"
	"os"
	"strconv"
	"strings"
)

// ServeRange serve il file in path con pieno supporto delle range request.
func ServeRange(w http.ResponseWriter, r *http.Request, path string) {
	f, err := os.Open(path)
	if err != nil {
		http.Error(w, "not found", http.StatusNotFound)
		return
	}
	defer f.Close()

	info, err := f.Stat()
	if err != nil {
		http.Error(w, "stat error", http.StatusInternalServerError)
		return
	}

	totalSize := info.Size()
	w.Header().Set("Accept-Ranges", "bytes")
	w.Header().Set("Content-Type", "video/mp4")

	rangeHeader := r.Header.Get("Range")
	if rangeHeader == "" {
		w.Header().Set("Content-Length", strconv.FormatInt(totalSize, 10))
		w.WriteHeader(http.StatusOK)
		io.Copy(w, f)
		return
	}

	// Parsa "bytes=start-end"
	start, end, err := parseRange(rangeHeader, totalSize)
	if err != nil {
		http.Error(w, "invalid range", http.StatusRequestedRangeNotSatisfiable)
		return
	}

	length := end - start + 1
	w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, totalSize))
	w.Header().Set("Content-Length", strconv.FormatInt(length, 10))
	w.WriteHeader(http.StatusPartialContent)

	f.Seek(start, io.SeekStart)
	io.CopyN(w, f, length)
}

func parseRange(header string, total int64) (int64, int64, error) {
	if !strings.HasPrefix(header, "bytes=") {
		return 0, 0, fmt.Errorf("unsupported range unit")
	}
	parts := strings.Split(header[6:], "-")
	if len(parts) != 2 {
		return 0, 0, fmt.Errorf("malformed range")
	}
	start, err := strconv.ParseInt(parts[0], 10, 64)
	if err != nil {
		return 0, 0, err
	}
	end := total - 1
	if parts[1] != "" {
		end, err = strconv.ParseInt(parts[1], 10, 64)
		if err != nil {
			return 0, 0, err
		}
	}
	if start < 0 || end >= total || start > end {
		return 0, 0, fmt.Errorf("range out of bounds")
	}
	return start, end, nil
}

Segmentazione HLS con FFmpeg
#

HLS (HTTP Live Streaming) divide il video in piccoli segmenti .ts e genera un manifest .m3u8. I player scaricano il manifest, poi recuperano i segmenti sequenzialmente. Questo abilita lo streaming a bitrate adattivo e il seeking affidabile.

Usa exec.Command per eseguire FFmpeg come sottoprocesso:

hls/segment.go
package hls

import (
	"fmt"
	"log"
	"os"
	"os/exec"
	"path/filepath"
)

// Segment transcodifica inputPath in segmenti HLS in outputDir.
// Blocca finche FFmpeg non finisce (caso d'uso VOD).
func Segment(inputPath, outputDir string) error {
	if err := os.MkdirAll(outputDir, 0755); err != nil {
		return fmt.Errorf("create output dir: %w", err)
	}

	manifestPath := filepath.Join(outputDir, "index.m3u8")
	segmentPattern := filepath.Join(outputDir, "segment%03d.ts")

	cmd := exec.Command(
		"ffmpeg",
		"-i", inputPath,
		"-codec:", "copy",         // Copia gli stream senza ri-encodare (veloce, nessuna perdita di qualita)
		"-start_number", "0",
		"-hls_time", "6",          // Durata target del segmento in secondi
		"-hls_list_size", "0",     // 0 = mantieni tutti i segmenti nel manifest (VOD)
		"-hls_segment_filename", segmentPattern,
		"-f", "hls",
		manifestPath,
	)

	// Cattura stderr per il debug. FFmpeg scrive il suo log li.
	cmd.Stderr = os.Stderr

	log.Printf("segmentazione %s -> %s", inputPath, outputDir)
	if err := cmd.Run(); err != nil {
		return fmt.Errorf("ffmpeg: %w", err)
	}
	log.Printf("segmentazione completata: %s", manifestPath)
	return nil
}
Importante

Usare -codec: copy copia gli stream senza ri-encodare. E veloce e senza perdite ma richiede che l’input sia gia in un codec compatibile con il container (video H.264, audio AAC per HLS). Se hai bisogno di transcodificare, sostituisci con -c:v libx264 -c:a aac e aggiungi flag per il bitrate.

Servire HLS da Go
#

Servi il manifest .m3u8 e i segmenti .ts con i tipi MIME corretti e gli header CORS:

hls/server.go
package hls

import (
	"net/http"
	"path/filepath"
	"strings"
)

// Handler serve file HLS da dir.
func Handler(dir string) http.Handler {
	fs := http.FileServer(http.Dir(dir))

	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// Gli header CORS sono necessari per la riproduzione HLS da un'origine diversa
		// (es. un player video.js su una porta diversa).
		w.Header().Set("Access-Control-Allow-Origin", "*")
		w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
		w.Header().Set("Access-Control-Allow-Headers", "Range")

		if r.Method == http.MethodOptions {
			w.WriteHeader(http.StatusNoContent)
			return
		}

		// Imposta i tipi MIME corretti.
		ext := strings.ToLower(filepath.Ext(r.URL.Path))
		switch ext {
		case ".m3u8":
			w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
			w.Header().Set("Cache-Control", "no-cache") // Il manifest cambia durante il live
		case ".ts":
			w.Header().Set("Content-Type", "video/mp2t")
			w.Header().Set("Cache-Control", "public, max-age=3600") // I segmenti sono immutabili
		}

		fs.ServeHTTP(w, r)
	})
}

Collegalo:

cmd/hlsserver/main.go
package main

import (
	"flag"
	"log"
	"net/http"

	"github.com/yourname/video/hls"
)

func main() {
	segDir := flag.String("dir", "./segments", "directory segmenti HLS")
	addr := flag.String("addr", ":8080", "indirizzo di ascolto")
	flag.Parse()

	mux := http.NewServeMux()
	mux.Handle("/hls/", http.StripPrefix("/hls/", hls.Handler(*segDir)))

	log.Printf("HLS server su %s, servendo da %s", *addr, *segDir)
	log.Fatal(http.ListenAndServe(*addr, mux))
}

I player accedono a http://localhost:8080/hls/index.m3u8.

Live streaming con FFmpeg
#

Per il live streaming da una webcam o da una sorgente RTMP, FFmpeg scrive segmenti continuamente in una directory. Il server Go li serve. Il manifest usa un hls_list_size finito e il player lo recupera ripetutamente:

hls/live.go
package hls

import (
	"context"
	"fmt"
	"log"
	"os"
	"os/exec"
	"path/filepath"
)

// StartLive avvia uno stream HLS live da inputSource in outputDir.
// Rispetta ctx per la cancellazione graceful.
func StartLive(ctx context.Context, inputSource, outputDir string) error {
	if err := os.MkdirAll(outputDir, 0755); err != nil {
		return fmt.Errorf("create output dir: %w", err)
	}

	manifestPath := filepath.Join(outputDir, "live.m3u8")
	segmentPattern := filepath.Join(outputDir, "live%05d.ts")

	cmd := exec.CommandContext(ctx,
		"ffmpeg",
		"-re",                          // Leggi l'input al framerate nativo (per input da file)
		"-i", inputSource,              // es. "rtmp://localhost/live/stream" o "/dev/video0"
		"-c:v", "libx264",
		"-preset", "veryfast",          // Bassa CPU, bassa latenza
		"-tune", "zerolatency",
		"-c:a", "aac",
		"-hls_time", "2",               // Segmenti brevi per bassa latenza
		"-hls_list_size", "5",          // Mantieni 5 segmenti nel manifest (sliding window)
		"-hls_flags", "delete_segments+append_list",
		"-hls_segment_filename", segmentPattern,
		"-f", "hls",
		manifestPath,
	)

	cmd.Stderr = os.Stderr

	log.Printf("avvio live stream: %s -> %s", inputSource, outputDir)
	if err := cmd.Start(); err != nil {
		return fmt.Errorf("ffmpeg start: %w", err)
	}

	// Attendi che FFmpeg termini (la cancellazione del context lo ferma via CommandContext).
	if err := cmd.Wait(); err != nil && ctx.Err() == nil {
		return fmt.Errorf("ffmpeg exited: %w", err)
	}
	return nil
}
Avviso

-hls_flags delete_segments rimuove i vecchi file .ts dal disco. Questo previene la crescita illimitata del disco durante stream live lunghi, ma significa che i segmenti non piu nel manifest daranno 404 se un player lento li richiede. Calibra hls_list_size e la durata dei segmenti in base al tuo trade-off latenza/buffer.

Progressive download vs HLS vs DASH
#

Come funziona: Singolo file MP4 servito con supporto range request.

Pro: Semplice. Un file. Funziona ovunque.

Contro: Nessun bitrate adattivo. Il seeking richiede il download fino a quell’offset di byte se il file non e gia bufferizzato. Non adatto per stream live.

Quando usarlo: Clip brevi, download, embed video semplici.

http.HandleFunc("/video/", func(w http.ResponseWriter, r *http.Request) {
    http.ServeFile(w, r, "./videos/"+r.URL.Path[len("/video/"):])
})

Come funziona: Video diviso in segmenti .ts, manifest .m3u8. Il player recupera il manifest, poi i segmenti.

Pro: Supporto nativo su iOS e Safari. Bitrate adattivo (varianti di qualita multiple). Funziona per il live. Seekable senza download completo.

Contro: Latenza piu alta rispetto al progressive download (minimo ~2x la durata del segmento). Richiede FFmpeg o simile per la segmentazione.

Quando usarlo: Video mobile, live streaming, qualsiasi cosa che necessiti di bitrate adattivo.

Come funziona: Simile a HLS ma usa manifest XML .mpd e segmenti MP4 frammentati .m4s.

Pro: Standard aperto. Miglior supporto browser su Android. Struttura dei segmenti piu flessibile.

Contro: Nessun supporto nativo iOS/Safari senza una libreria player (dash.js, Shaka Player). Formato manifest piu complesso.

Quando usarlo: Quando hai bisogno di bitrate adattivo cross-platform senza l’overhead nativo HLS di Safari.

Produzione: usa un CDN per la distribuzione dei segmenti
#

flowchart LR
    Source["Sorgente\n(camera / file)"] --> FFmpeg["FFmpeg\n(sottoprocesso Go)"]
    FFmpeg --> Segments["Segmenti su disco\n(file .m3u8 + .ts)"]
    Segments --> GoServer["Go HTTP Server\n(origin)"]
    GoServer --> CDN["CDN\n(CloudFront / CloudFlare)"]
    CDN --> Players["Video Player\n(milioni)"]

Il server Go in questo articolo gestisce la generazione dei segmenti e funge da server origin. Per qualsiasi traffico reale, metti un CDN davanti. CloudFront puo fare cache dei segmenti .ts (sono immutabili una volta scritti) con un TTL lungo. Il manifest (.m3u8) dovrebbe essere servito con Cache-Control: no-cache o un TTL molto breve in modo che i player ottengano sempre la lista corrente dei segmenti.

Go e per generare segmenti. Il CDN e per distribuirli in scala. Non servire segmenti video direttamente dal tuo processo Go a qualsiasi livello di traffico significativo.

**Nessuna range request.** Wrappare un file video in una normale risposta HTTP senza supporto range forza il player a scaricare dall'inizio ad ogni seeking. `http.ServeFile` gestisce questo automaticamente per i file su disco. Implementalo manualmente per tutto il resto. **Nessun header CORS per HLS.** Se il JavaScript del video player e servito da un'origine diversa dal server dei segmenti, i browser bloccheranno le fetch dei segmenti. Aggiungi `Access-Control-Allow-Origin` e `Access-Control-Allow-Headers: Range` a ogni risposta. **Bloccare l'handler HTTP su FFmpeg.** Eseguire `cmd.Run()` in un handler HTTP blocca la goroutine dell'handler per l'intera durata della transcodifica. Avvia FFmpeg in una goroutine in background e ritorna immediatamente. Usa un endpoint di stato o un canale per segnalare il completamento. **Non pulire i vecchi segmenti.** In uno stream live, i segmenti si accumulano sul disco indefinitamente a meno che non usi `-hls_flags delete_segments` o un job di pulizia separato. Uno stream di 2 ore con segmenti da 2 secondi produce 3600 file `.ts`. **Servire segmenti dall'origin in scala.** Un singolo processo Go puo gestire centinaia di richieste di segmenti concorrenti, ma i picchi di traffico video sono severi. Un CDN scarica il traffico dell'origin del 99%+ per contenuti popolari. Configura un CDN prima di averne bisogno, non dopo.

Se vuoi approfondire questi argomenti, offro sessioni di coaching 1:1 per ingegneri che lavorano su integrazione AI, architettura cloud e platform engineering. Prenota una sessione (50 EUR / 60 min) o scrivimi a manuel.fedele+website@gmail.com.

Articoli correlati