Stream live su youtube da una console application in net core

di Andrea Zani, in .NET,

L'idea di questo post è nata dopo una discussione con un amico per la visione di un canale di Youtube in streaming live. Nel dettaglio tale canale trasmetteva in tempo reale la disponibilità delle schede video (oggi rarissime) nei vari store su Internet e del loro ultimo prezzo - tale canale, anche se ce ne è più di uno, trasmette anche attualmente. L'argomento della discussione era come realizzare qualcosa di simile. La soluzione? Molto semplice: si tiene un computer acceso 24h, si crea un applicativo, o anche una semplica pagina html che, a pieno schermo, visualizza le informazioni volute, quindi si installa un programma come OBS e lo si aggancia al proprio canale Youtube - non aggiungo altro perché sinceramente di questo argomento ne so ben poco.

Soluzione trovata, quindi posso chiudere questo post anche qui. Purtroppo no, perché abbiamo cercato di abbassare i requisiti al minimo, e siamo arrivati al punto che il tutto doveva girare senza desktop (la classica macchina linux senza desktop) su un computer remoto con il minimo delle prestazioni necessarie dando uno sguardo anche al costo.

In questo post tralascerò su come prendere da codice le informazioni interessate dalle pagine di altri siti perché, di questa sfida, era la cosa più semplice. Il problema più complesso era come creare un flusso video e poi come inviarlo a Youtube. Questo primo passo è quello che mi ha lasciato inizialmente senza idee, perché non avevo mai trattato l'argomento. Come potevo, da codice, creare un qualsiasi flusso video da una console application? Così ho iniziato a guardarmi in giro tra le librerie disponibili senza trovare nulla che facesse al caso mio (mi discolpo dicendo subito che la mia ricerca non è stata approfondita e di essere digiuno dell'argomento, come ho scritto poco fa). Alla fine mi sono ricordato del più semplice formato video che potevo ricreare pure io senza l'ausilio di chissà quale libreria, il formato MJPEG. Questo formato è banale: ogni fotogramma è un'immagine jpeg, e per creare un filmato è sufficiente concatenare, nel modo corretto, delle immagini jpeg con altre informazioni.

Mi rimaneva un altro problema: in questo modo avevo sì la possibilità di creare un filmato adatto ai miei scopi (alla fine per mostrare delle informazioni a schermo come prezzi e altro, mi bastava creare delle immagini senza preoccupazioni sul numero di fotogrammi o altro), ma come inviavo poi il tutto a Youtube? Richiesta la pagina di Youtube, è sufficiente cliccare sul link Crea e sulla voce sottostante Trasmetti dal vivo. La pagina seguente chiederà i dettagli del video che vogliamo trasmettere (nome, descrizione, età minima, etc..) quindi ci darà dei parametri da utilizzare per l'invio dello stream:

Configurazione Youtube live

Youtube (ma anche Facebook e Twitch) accettano in ingresso il protocollo RTMP. Ecco il problema finale: come trasferire il mio video in formato MJPEG via RTMP a Youtube? A questo problema sono andato subito nella giusta direzione conoscendo il convertitore universale di video: FFMPEG. Non esiste un flusso video che questo tool non sappia gestire, e trovo subito le informazioni volute. A questo punto ho tutto quello che mi serve. Gli unici requisiti sulla macchina su cui dovrà girare il tutto è trovare installato dotnet core e ffmpeg.

Inizio creando una console application in Net Core 3.1 e per la demo iniziale mi prefisso di creare come output un video con il framerate minimo di un fotogramma al secondo. L'output per questo esempio sarà solo la classica data e ora da visualizzare in tempo quasi reale (la tecnica per l'inserimento di informazioni da altri siti non lo tratto in questo post perché poco rilevante). Inizio proprio con la creazione dell'immagine fotogramma. Scrivo la mia bella classe che crea una bitmap in cui inserisco le informazioni volute grazie all'uso della libreria System.Drawing.Common. Faccio alcune prove ed è tutto ok. L'immagine che creo è molto semplice: disegno un orologio analogico con le lancette delle ore/minuti/secondi, e sotto scrivo la data odierna completa dell'ora:

immagine creata di esempio

Per mia fortuna mi ritrovo a fare altre prove su un computer Linux e da subito ho degli errori dovuti alla mancanza di una libreria installata sulla macchina: libgdiplus. Poco male, la installo con:

sudo apt install -y libgdiplus

E continuo le mie prove senza errori. Per controllare che tutto funzioni, simulo la creazione di parecchie immagini per verificarne le prestazioni e tutto fila liscio: ogni immagine viene creata in pochissimi centesimi di secondo. Ma lasciando andare il mio test mi rendo subito conto di un problema: il consumo di memoria. Lasciando andare il mio test per molti minuti mi rendo conto che qualcosa sta rubando memoria alla macchina, e in meno di due minuti mi ritrovo con un 1GB in meno. Tale memoria viene immediatamente ripristinata una volta chiuso il mio programma. Verifico di non aver lasciato nel codice qualcosa di strano e di avere usato tutti i Dispose al posto giusto, ma non trovo nulla di sbagliato; faccio altri test pensando che potrebbe essere qualcos'altro di attivo nella macchina a creare il problema (questa era una speranza), ma infine il colpevole è sempre il mio test. Inizio a indagare e scopro che il colpevole è la libreria libgdiplus che soffre di un memory leak e della paventata possibilità che in futuro System.Drawing.Common sarà utilizzabile solo su Windows.

Cerco un sostituto e lo trovo in SkiaSharp. Rifaccio delle prove per essere sicuro e infatti ora funziona tutto correttamente sia sotto Windows che con Linux. Qui non inserisco nessuna porzione di codice della classe per il disegno del fotogramma perché alla fine di questo post inserirò il link in Github dove è possibile reperire tutto il codice.

Inizio quindi a scrivere il codice per la gestione del timer che tratterà la richiesta della creazione del fotogramma della classe appena creata e il suo invio alla classe che poi la invierà via Socket (che farò a breve). Così come ho scritto precedentemente, il timer farà in modo di creare un solo fotogramma al secondo; per fare questo non ho utilizzato il Timer già predisposto per questo dal dotnet, ma per evitare eventuali rallentamenti per possibili future esigenze (un maggior numero di fotogrammi al secondo) ed evitare accavallamenti di chiamate, ho preferito gestire il tutto con il Delay di Task. L'avessi mai fatto... Anche qui faccio inizialmente dei test con questo codice:

while (true)
{
 // Richiedo la creazione dell'immagine
 // etc...
 // Calcolo quanti ms devo aspettare per arrivare al secondo successivo:

 var diff = 1_000 - DateTime.Now.Millisecond;
 await Task.Delay(diff);
 Console.WriteLine(DateTime.Now.ToString("HH:mm:ss"));
}

E sembra funzionare tutto... ma avviando il test simulando anche altre operazioni mi rendo conto che il Task.Delay ha delle grosse imprecisioni (la documentazione dice fino a 15ms) così come il DateTime.Now, e infatti mi ritrovo parecchi casi, nel mio codice di test, di secondi ripetuti:

12:20:31
12:20:31
12:20:32
12:20:33
12:20:33
12:20:35
12:20:36
12:20:37
12:20:38
12:20:39
12:20:39
12:20:40
...

Sto per cancellare tutto per passare all'uso del Timer quando trovo questo post, e invece di usare il Delay scrivo:

await Task.Run(() =>
{
 using var m = new System.Threading.ManualResetEventSlim(false);
 m.Wait(milliseconds);
});

E ora il problema sembra risolto. Potrei ricevere l'obiezione che, per i problemi di precisione del calcolo visto prima, basterebbe aumentare al delay la doppia imprecisione (1030 - DateTime.Now.Millisecond) per ovviare al problema. Infatti con questo trucco il tutto funziona correttamente, ma essendo questo progetto personale e fatto anche per sperimentare qualcosa di nuovo, ho preferito cercare soluzioni alternative.

Bene, anche questo è fatto. Passo al codice per la gestione del flusso video. Il tutto si risolve con l'apertura da codice di un Socket e l'attesa di una richiesta esterna:

using Socket Server = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);

Server.Bind(new IPEndPoint(IPAddress.Any, PORT));
Server.Listen(10);

_log.Info($"Wait port {PORT}...");

while (true)
{
 var client = await Server.AcceptAsync();
 _log.Info("New client connected");
 _ = HandleConnectionAsync(client);
}

Così come mostrato in un mio post del 2014 (come volano gli anni...) non utilizzo i thread per la comunicazione con i client che richiedono la connessione ma le async function. Per fare un po' di storia, una soluzione utilizzata precedentemente (che usavo anche io) era la creazione di un thread per ogni cliente che richiedeva la connessione via Tcp. Questo thread veniva mantenuto in vita per tutta la durata della connessione. La tecnica funzionava anche se c'era uno spreco di risorse che l'utilizzo di async/await ora ci permette di risparmiare. Dal codice qui sopra si può vedere infatti che, ricevuta una richiesta di connessione (attesa che avviene in await), il codice richiama un metodo asincrono (ora senza aspettare la sua fine con l'await) che elaborerà da lì in avanti tutte le richieste di quel client:

private async Task HandleConnectionAsync(Socket socket)
{
 using var client = new NetworkStream(socket, true);
 ...
 var buffer = new byte[1024];
 await client.ReadAsync(buffer, 0, buffer.Length);
 ...

Come viene collegata questa classe con quella del Timer vista prima? In Timer ho inserito un event al quale la funzione HandleConnectionAsync connetterà un suo metodo. La creazione dell'immagine che avviene ogni secondo farà scattare questo evento che avrà tra i parametri proprio l'immagine. La funzione collegata invierà lo stream dell'immagine ricevuta nel formato accettato dall'MJPEG:

await WriteToStreamAsync(
 "\r\n" +
 "--boundary\r\n" +
 "Content-Type: image/jpeg\r\n" +
 $"Content-Length: {byteArray.Length}\r\n\r\n"
 );

 await client.WriteAsync(byteArray, 0, byteArray.Length);
 await WriteToStreamAsync("\r\n");
 await client.FlushAsync();

E pure questa funzione è sistemata. Dimenticato nulla? Qui l'esperienza mi ha insegnato che nel caso di comunicazione Tcp non si sa mai cosa può fare il client sia con operazioni volute o non volute. Infatti il codice visto ora presenta un grosso problema, nel dettaglio a questa riga:

await client.WriteAsync(byteArray, 0, byteArray.Length);

Questo metodo invia un array di byte al client, ma cosa succede se chi c'è dall'altra parte non li legge? Per mandare in crisi questa riga di codice è sufficiente che il client dall'altra parte faccia questo:

IPAddress ipAddress = IPAddress.Parse("127.0.0.1");
IPEndPoint remoteEP = new IPEndPoint(ipAddress, 4003);

// Create a TCP/IP  socket.  
using Socket sender = new Socket(ipAddress.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
sender.Connect(remoteEP);
byte[] msg = Encoding.ASCII.GetBytes("This is a test\r\n");

// Send the data through the socket.  
int bytesSent = sender.Send(msg);

await Task.Delay(1000 * 60 * 60); // <- un'ora

Queste poche righe di codice si connettono al mio socket, inviano dei byte solo per verificare la connessione, quindi si mette in attesa del nulla (Task.Delay). Fine... questo script blocca il WriteAsync per un'ora, perché non scatterà nessuna Exception di timeout anche se configurato il WriteTimeout (e il ReadTimeout) del NetworkStream, così come il ReceiveTimeout e SendTimeout del Socket. Per risolvere il problema io utilizzo un CancellationToken in cui definisco il timeout (io uso 500ms per stare largo):

var writeCts = new CancellationTokenSource(500);
var byteArray = e.Bitmap.ToArray();

await WriteToStreamAsync(
 "\r\n" +
 "--boundary\r\n" +
 "Content-Type: image/jpeg\r\n" +
 $"Content-Length: {byteArray.Length}\r\n\r\n",
 writeCts.Token
);
  
await client.WriteAsync(byteArray, 0, byteArray.Length, writeCts.Token);
await WriteToStreamAsync("\r\n", writeCts.Token);
await client.FlushAsync(writeCts.Token);

Nel caso precedente, passati 500ms, scatterà l'exception OperationCanceledException in cui chiederò la connessione con quel client.

Il mio codice è finito. Lo avvio e la mia console application rimane in attesa sulla porta 4003 di un client per trasmettergli il video nel formato MJPEG. Provo a collegare VLC e tutto funziona:

vlc in test

E' ora di collegare l'output del mio tool a Youtube. Ho già mostrato prima il pannello di configurazione:

Informazioni live stream in youtubw

Importante è l'URL dello stream e la chiave nascosta (che deve rimanere tale). Il link da utilizzare sarà infine la concatenazione dell'url è della chiave, per esempio:

rtmp://x.rtmp.youtube.com/live2/xxxx-xxxx-xxxx-xxxx-xxxx

E' ora il momento di utilizzare ffmpeg, che farà da tramite tra il mio stream e Youtube. Dopo alcuni test ho scoperto che per inviare un video accettato è necessario che esso abbia anche una traccia audio, inoltre deve avere almeno 30 fotogrammi al secondo per evitare messaggi di alert da parte di Youtube. Entrambe le condizioni non sono fornite dal mio stream, e utilizzerò giustappunto ffpmeg per risolvere queste mancanze. Ecco il comando che ho utilizzato:

ffmpeg -reconnect 1 -reconnect_at_eof 1 -reconnect_streamed 1 -reconnect_delay_max 2 \
-rtbufsize 200M \
-f mjpeg -use_wallclock_as_timestamps true -i http://localhost:4003 \
-f lavfi -re -i anullsrc \
-vsync cfr -r 30 -c:v libx264 -crf 24 \
-f flv rtmp://x.rtmp.youtube.com/live2/xxxx-xxxx-xxxx-xxxx-xxxx

Nella prima riga vengono definite le opzioni per la riconnessione in caso caduta dello stream sorgente (con l'attesa massima di due secondi tra un tentativo e l'altro). La seconda riga definisce il buffer di memoria utilizzato per l'elaborazione del video. Nella terza riga, finalmente, inserisco il mio stream come sorgente in input. La quarta riga sopperisce alla mancanza di audio: aggiunge una traccia audio completamente senza rumore allo stream in uscita accettata da Youtube. La quinta riga con il parametro -r si definiscono quanti fotogrammi al secondo voglio, mentre il parametro vsync cfr:

Frames will be duplicated and dropped to achieve exactly the requested constant frame rate.

Questo permette di trasformare il mio flusso di un fotogramma in input in un flusso costante di 30 fotogrammi in output: il mio unico frame sarà duplicato per il numero di volte necessario per raggiungere il numero di fotogrammi impostato dal parametro -r. Sempre questa riga definisce l'encoder che sarà utilizzato per la creazione del video finale. Il parametro cfr è il constant rate factor che accetta valori da zero a cinquantuno, dove più è basso il numero è migliore la qualità del video di output.

Eseguito il comando qui sopra ffmpeg visualizzerà informazioni nella console:

informazioni da ffmpeg output nel terminale

Dove si può vedere il numero di frame in output così come quelli duplicati (dup) ed eventualmente persi (drop). E' il momento della verità, Youtube?

Risultato del video in tempo reale con youtube

Ok, risultato ottenuto. Controllo con più attenzione e scopro che il video su Youtube ha un ritardo di circa 20-25 secondi, ma sembra una cosa normale con le impostazioni di base (si può modificare la latenza per ovviare a questo problema).

La prova finale l'ho fatta attivando una macchina virtuale di bassa potenza con un solo core e 1GB di ram (era la macchina più economica) su un cloud. Sistema operativo utilizzato Ubuntu su cui ho installato solo il necessario:

wget https://packages.microsoft.com/config/ubuntu/20.04/packages-microsoft-prod.deb
dpkg -i packages-microsoft-prod.deb
apt update 
apt install apt-transport-https dotnet-sdk-3.1 ffmpeg -y

Con il comando screen ho prima scaricato il codice sorgente della mia console application, compilato ed eseguito. Quindi in un secondo screen ho avviato il comando ffmpeg visto prima. Ho lasciato girare il tutto per più di 24 ore senza particolari problemi (e grazie al comando screen ho potuto chiudere la connessione ssh senza chiudere i programmi avviati).

Controllando con il comando top di linux il consumo di cpu e memoria vedo che è ovviamente ffmpeg che ha il più grande consumo:

comando top

 Ecco per curiosità l'uso delle risorse della macchina dopo 24 ore dal pannello di controllo del cloud utilizzato:

performance in cloud

Il consumo medio di banda per l'invio dello stream è stato di circa 220kb/s, che a livello giornaliero è circa 2.4GB.

Prima di concludere questo post, altre informazioni che ho scoperto facendo alcuni test. Ho provato, sempre con la virtual machine qui sopra, a fare in modo che la mia console application generasse più di 20fps (inviandoli sempre a ffmpeg), ma ho avuto problemi per la discontinuità della mia applicazione e sia per errori di ffmpeg che sembrava non reggere l'input (probabilmente il problema è dovuto dalle prestazioni della macchina monocore). Non ho fatto altre prove con macchine più potenti per mancanza di tempo e di interesse (per il mio obbiettivo finale avere un fotogramma al secondo era sufficiente).

La mia console application non è completamente ottimizzata. Soprattutto per la creazione dell'immagine che viene creata da zero ad ogni richiesta. In un mio esempio non incluso qui perché sperimentale, ho preparato un'immagine con una tabella e alcune immagini di esempio, tabella in cui inserire valori presi da altre fonti. Ho fatto in modo che solo alcuni di questi valori variassero ad ogni richiesta dell'immagine, ottenendo subito prestazioni migliori (per simulare lo stream di cui avevo parlato ad inizio post). Inoltre se si ha necessità di creare un video con più fotogrammi al secondo si può tranquillamente ignorare la classe per la gestione del timer e lasciare fare il tutto alla classe per la comunicazione tcp (ho usato questo trucco per simulare un numero elevato di fotogrammi al secondo).

Come ho scritto, non ho mai fatto prove più lunghe di 30 ore consecutive di stream live. Per evitare problemi si dovrebbe fare in modo di monitorare entrambi i software (sia la mia console application, sia ffmpeg) perché non crashino per un qualsiasi motivo (oppure ci sia una caduta di connessione momentanea o qualsiasi altro inconveniente). Si potrebbe pensare ad un riciclo di processo almeno per ffmpeg visto che ha anche un parametro per definire la lunghezza in secondi massima per lo stream (parametro -t secondi).

Solo per mia curiosità ho provato a creare questo script che interrompe ffmpeg e lo riavvia ogni 5 minuti (300 secondi):

#!/bin/bash
while :
do
  ffmpeg -t 300 ...
done

Dopo cinque minuti il video su Youtube ha un freeze di circa un secondo ma continua poi senza problemi, potrebbe essere accettabile impostando ore di riciclo invece di cinque minuti. Inoltre se si vuole usare un servizio simile sarebbe meglio usare virtual machine meglio corazzate con almeno due cpu dedicate e non la soluzione più economica come ho fatto io. Personalmente credo che si dovrebbero fare maggiori test per verificare il carico del sistema sulla macchina utilizzata e sullo stream generato, ma il mio tempo è quello che è e non ho potuto approfondire. Così come in linea teorica il mio tool in coppia con ffmpeg potrebbe inviare anche lo stream a Facebook e Twitch, ma non ho verificato.

Sicuramente ci sarà una soluzione migliore a tutto il giro che ho fatto io, ma è stata un'esperienza interessante per imparare cose nuove. Dimenticavo, ecco il link dove è disponibile il codice sorgente della mia console application.

Commenti

Visualizza/aggiungi commenti

| Condividi su: Twitter, Facebook, LinkedIn

Per inserire un commento, devi avere un account.

Fai il login e torna a questa pagina, oppure registrati alla nostra community.

Nella stessa categoria
I più letti del mese