feat: add authenticated live podcasts and simplify native video playback
Include podcast capture, ingestion and replay, dependency and installer updates, and native video positioning after navigation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
d9b183c500
commit
4fc91cec3e
45 changed files with 2330 additions and 378 deletions
|
|
@ -2,6 +2,7 @@ using System.Collections.Concurrent;
|
|||
using Microsoft.AspNetCore.Http;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.ViewModels.Streaming;
|
||||
using Yavsc.Abstract.Streaming;
|
||||
|
||||
namespace Yavsc.Services
|
||||
{
|
||||
|
|
@ -19,6 +20,14 @@ namespace Yavsc.Services
|
|||
/// <returns></returns>
|
||||
Task<bool> AcceptStream (HttpContext context, ApplicationUser user, string destDir, string fileName);
|
||||
|
||||
Task<PodcastResponse> CreateAsync(string ownerId, string ownerName, string title, CancellationToken ct);
|
||||
Task<IReadOnlyList<PodcastResponse>> ListAsync(CancellationToken ct);
|
||||
Task<PodcastManifestResponse> GetAsync(Guid id, CancellationToken ct);
|
||||
Task<PodcastResponse> AppendAsync(Guid id, string ownerId, int sequence,
|
||||
double durationSeconds, Stream input, CancellationToken ct);
|
||||
Task<PodcastResponse> CompleteAsync(Guid id, string ownerId, CancellationToken ct);
|
||||
Task<Stream> OpenSegmentAsync(Guid id, int sequence, CancellationToken ct);
|
||||
Task DeleteAsync(Guid id, string ownerId, CancellationToken ct);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,204 +1,360 @@
|
|||
#nullable enable annotations
|
||||
#nullable enable
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.WebSockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Yavsc.Abstract.Streaming;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.ViewModels.Streaming;
|
||||
using Newtonsoft.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Yavsc.Services
|
||||
namespace Yavsc.Services;
|
||||
|
||||
public sealed class LiveProcessor : ILiveProcessor, IDisposable
|
||||
{
|
||||
private sealed record Document(PodcastManifestResponse Manifest, string[] Hashes);
|
||||
private readonly ILogger<LiveProcessor> _logger;
|
||||
private readonly PodcastOptions _options;
|
||||
private readonly TimeProvider _clock;
|
||||
private readonly SemaphoreSlim _gate = new(1);
|
||||
private readonly SemaphoreSlim _uploads = new(2);
|
||||
private readonly Dictionary<Guid, Document> _documents = new();
|
||||
private bool _loaded;
|
||||
|
||||
public class LiveProcessor : ILiveProcessor
|
||||
public ConcurrentDictionary<string, LiveCastHandler> Casters { get; } = new();
|
||||
|
||||
public LiveProcessor(ILoggerFactory loggerFactory, IOptions<PodcastOptions> options, TimeProvider? clock = null)
|
||||
{
|
||||
private readonly ILogger _logger;
|
||||
_logger = loggerFactory.CreateLogger<LiveProcessor>();
|
||||
_options = options.Value;
|
||||
_clock = clock ?? TimeProvider.System;
|
||||
if (!Path.IsPathFullyQualified(_options.RootPath) || _options.MaxSegmentBytes < 188
|
||||
|| _options.MaxSegmentBytes > 8 * 1024 * 1024
|
||||
|| _options.MaxOwnerBytes < _options.MaxSegmentBytes || _options.MaxSegments < 1
|
||||
|| _options.IdleTimeoutSeconds < 15 || _options.MaxOwnerPodcasts < 1)
|
||||
throw new InvalidOperationException("Invalid Podcast storage options.");
|
||||
}
|
||||
|
||||
public ConcurrentDictionary<string, LiveCastHandler> Casters { get; } = new ConcurrentDictionary<string, LiveCastHandler>();
|
||||
|
||||
public LiveProcessor(ILoggerFactory loggerFactory)
|
||||
public async Task<PodcastResponse> CreateAsync(string ownerId, string ownerName, string title, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ownerId) || string.IsNullOrWhiteSpace(title) || title.Trim().Length > 160)
|
||||
throw new PodcastException(400, "Un titre de 1 a 160 caracteres et un utilisateur sont requis.");
|
||||
await _gate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
_logger = loggerFactory.CreateLogger<LiveProcessor>();
|
||||
await LoadAsync(ct);
|
||||
await ExpireAsync(ct);
|
||||
if (_documents.Values.Any(d => d.Manifest.Podcast.OwnerId == ownerId && d.Manifest.Podcast.State == PodcastState.Live))
|
||||
throw new PodcastException(409, "Vous avez deja un direct en cours.");
|
||||
if (OwnerBytes(ownerId) >= _options.MaxOwnerBytes)
|
||||
throw new PodcastException(413, "Quota de stockage des podcasts atteint.");
|
||||
if (_documents.Values.Count(d => d.Manifest.Podcast.OwnerId == ownerId) >= _options.MaxOwnerPodcasts)
|
||||
throw new PodcastException(413, "Nombre maximal de podcasts atteint. Supprimez un ancien replay.");
|
||||
var now = _clock.GetUtcNow();
|
||||
var podcast = new PodcastResponse(Guid.NewGuid(), title.Trim(), ownerId, ownerName,
|
||||
now, now, PodcastState.Live, 0, 0, 0);
|
||||
var document = new Document(new(podcast, Array.Empty<PodcastSegmentResponse>()), Array.Empty<string>());
|
||||
Directory.CreateDirectory(DirectoryFor(podcast.Id));
|
||||
await SaveAsync(document, ct);
|
||||
_logger.LogInformation("Podcast {PodcastId} created by {OwnerId}", podcast.Id, ownerId);
|
||||
return podcast;
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
public async Task<bool> AcceptStream(HttpContext context, ApplicationUser user, string destDir, string fileName)
|
||||
public async Task<IReadOnlyList<PodcastResponse>> ListAsync(CancellationToken ct)
|
||||
{
|
||||
await _gate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
// TODO defer request handling
|
||||
string uname = user.UserName;
|
||||
LiveCastHandler liveHandler = null;
|
||||
if (Casters.ContainsKey(uname))
|
||||
{
|
||||
_logger.LogWarning($"Casters.ContainsKey({uname})");
|
||||
liveHandler = Casters[uname];
|
||||
if (liveHandler.Socket.State == WebSocketState.Open || liveHandler.Socket.State == WebSocketState.Connecting)
|
||||
{
|
||||
_logger.LogWarning($"Closing cx");
|
||||
// FIXME loosed connexion should be detected & disposed else where
|
||||
await liveHandler.Socket.CloseAsync(WebSocketCloseStatus.EndpointUnavailable, "one by user", CancellationToken.None);
|
||||
await LoadAsync(ct);
|
||||
await ExpireAsync(ct);
|
||||
return _documents.Values.Select(d => d.Manifest.Podcast)
|
||||
.OrderByDescending(p => p.CreatedAt).ToArray();
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
}
|
||||
if (!liveHandler.TokenSource.IsCancellationRequested)
|
||||
{
|
||||
liveHandler.TokenSource.Cancel();
|
||||
}
|
||||
liveHandler.Socket.Dispose();
|
||||
liveHandler.Socket = await context.WebSockets.AcceptWebSocketAsync();
|
||||
liveHandler.TokenSource = new CancellationTokenSource();
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation($"new caster");
|
||||
// Accept the socket
|
||||
liveHandler = new LiveCastHandler { Socket = await context.WebSockets.AcceptWebSocketAsync() };
|
||||
}
|
||||
_logger.LogInformation("Accepted web socket");
|
||||
// Dispatch the flow
|
||||
public async Task<PodcastManifestResponse> GetAsync(Guid id, CancellationToken ct)
|
||||
{
|
||||
await _gate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
await LoadAsync(ct);
|
||||
await ExpireAsync(ct);
|
||||
var manifest = Find(id).Manifest;
|
||||
return manifest with { Segments = manifest.Segments.ToArray() };
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
public async Task<PodcastResponse> AppendAsync(Guid id, string ownerId, int sequence,
|
||||
double durationSeconds, Stream input, CancellationToken ct)
|
||||
{
|
||||
if (sequence < 0 || !double.IsFinite(durationSeconds) || durationSeconds <= 0 || durationSeconds > 15)
|
||||
throw new PodcastException(400, "Sequence ou duree du segment invalide (maximum 15 secondes).");
|
||||
|
||||
await _gate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
await LoadAsync(ct);
|
||||
await ExpireAsync(ct);
|
||||
_ = Owned(id, ownerId);
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
|
||||
// Bound concurrent body buffering without holding the metadata lock during network reads.
|
||||
await _uploads.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
var bytes = await ReadSegmentAsync(input, ct);
|
||||
await _gate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (liveHandler.Socket != null && liveHandler.Socket.State == WebSocketState.Open)
|
||||
var document = Owned(id, ownerId);
|
||||
var podcast = document.Manifest.Podcast;
|
||||
var segments = document.Manifest.Segments;
|
||||
if (sequence > segments.Count)
|
||||
throw new PodcastException(409, $"Le prochain segment attendu est {segments.Count}.");
|
||||
if (sequence == segments.Count && podcast.State != PodcastState.Live)
|
||||
throw new PodcastException(409, "Ce direct est termine.");
|
||||
var hash = Convert.ToHexString(SHA256.HashData(bytes));
|
||||
if (sequence < segments.Count)
|
||||
{
|
||||
Casters[uname] = liveHandler;
|
||||
// TODO: Handle the socket here.
|
||||
// Find receivers: others in the chat room
|
||||
// send them the flow
|
||||
var buffer = new byte[Constants.WebSocketsMaxBufLen];
|
||||
var sBuffer = new ArraySegment<byte>(buffer);
|
||||
_logger.LogInformation("Receiving bytes...");
|
||||
|
||||
WebSocketReceiveResult received = await liveHandler.Socket.ReceiveAsync(sBuffer, liveHandler.TokenSource.Token);
|
||||
|
||||
_logger.LogInformation($"Received bytes : {received.Count}");
|
||||
_logger.LogInformation($"Is the end : {received.EndOfMessage}");
|
||||
|
||||
|
||||
|
||||
var fsInputQueue = new Queue<ArraySegment<byte>>();
|
||||
|
||||
bool endOfInput = false;
|
||||
sBuffer = new ArraySegment<byte>(buffer,0,received.Count);
|
||||
fsInputQueue.Enqueue(sBuffer);
|
||||
var taskWritingToFs = liveHandler.ReceiveUserFile(user, _logger, destDir, fsInputQueue, fileName, () => endOfInput);
|
||||
|
||||
|
||||
Stack<string> ToClose = new Stack<string>();
|
||||
|
||||
try
|
||||
{
|
||||
do
|
||||
{
|
||||
_logger.LogInformation($"Echoing {received.Count} bytes received in a {received.MessageType} message; Fin={received.EndOfMessage}");
|
||||
// Echo anything we receive
|
||||
// and send to all listner found
|
||||
_logger.LogInformation($"{liveHandler.Listeners.Count} listeners");
|
||||
foreach (var cliItem in liveHandler.Listeners)
|
||||
{
|
||||
var listenningSocket = cliItem.Value;
|
||||
if (listenningSocket.State == WebSocketState.Open)
|
||||
{
|
||||
_logger.LogInformation(cliItem.Key);
|
||||
await listenningSocket.SendAsync(
|
||||
sBuffer, received.MessageType, received.EndOfMessage, liveHandler.TokenSource.Token);
|
||||
}
|
||||
else if (listenningSocket.State == WebSocketState.CloseReceived || listenningSocket.State == WebSocketState.CloseSent)
|
||||
{
|
||||
ToClose.Push(cliItem.Key);
|
||||
}
|
||||
}
|
||||
|
||||
if (!received.CloseStatus.HasValue)
|
||||
{
|
||||
_logger.LogInformation("try and receive new bytes");
|
||||
|
||||
buffer = new byte[Constants.WebSocketsMaxBufLen];
|
||||
received = await liveHandler.Socket.ReceiveAsync(sBuffer, liveHandler.TokenSource.Token);
|
||||
|
||||
_logger.LogInformation($"Received bytes : {received.Count}");
|
||||
|
||||
sBuffer = new ArraySegment<byte>(buffer,0,received.Count);
|
||||
_logger.LogInformation($"segment : offset: {sBuffer.Offset} count: {sBuffer.Count}");
|
||||
_logger.LogInformation($"Is the end : {received.EndOfMessage}");
|
||||
|
||||
if (received.CloseStatus.HasValue)
|
||||
{
|
||||
endOfInput=true;
|
||||
_logger.LogInformation($"received a close status: {received.CloseStatus.Value}: {received.CloseStatusDescription}");
|
||||
}
|
||||
else fsInputQueue.Enqueue(sBuffer);
|
||||
}
|
||||
else endOfInput=true;
|
||||
while (ToClose.Count > 0)
|
||||
{
|
||||
string no = ToClose.Pop();
|
||||
_logger.LogInformation("Closing follower connection");
|
||||
WebSocket listenningSocket;
|
||||
if (liveHandler.Listeners.TryRemove(no, out listenningSocket))
|
||||
{
|
||||
await listenningSocket.CloseAsync(WebSocketCloseStatus.EndpointUnavailable,
|
||||
"State != WebSocketState.Open", CancellationToken.None);
|
||||
listenningSocket.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
while (liveHandler.Socket.State == WebSocketState.Open);
|
||||
|
||||
_logger.LogInformation("Closing connection");
|
||||
taskWritingToFs.Wait();
|
||||
await liveHandler.Socket.CloseAsync(WebSocketCloseStatus.NormalClosure, received.CloseStatusDescription, liveHandler.TokenSource.Token);
|
||||
|
||||
liveHandler.TokenSource.Cancel();
|
||||
liveHandler.Dispose();
|
||||
_logger.LogInformation("Resulting file : " + JsonConvert.SerializeObject(taskWritingToFs.Result));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError($"Exception occured : {ex.Message}");
|
||||
_logger.LogError(ex.StackTrace);
|
||||
liveHandler.TokenSource.Cancel();
|
||||
throw;
|
||||
}
|
||||
taskWritingToFs.Dispose();
|
||||
if (document.Hashes[sequence] != hash || segments[sequence].DurationSeconds != durationSeconds)
|
||||
throw new PodcastException(409, "Un autre contenu existe deja pour cette sequence.");
|
||||
return podcast;
|
||||
}
|
||||
else
|
||||
if (segments.Count >= _options.MaxSegments || OwnerBytes(ownerId) + bytes.Length > _options.MaxOwnerBytes)
|
||||
throw new PodcastException(413, "Quota de stockage ou duree maximale du podcast atteint.");
|
||||
|
||||
var path = SegmentPath(id, sequence);
|
||||
await File.WriteAllBytesAsync(path + ".tmp", bytes, ct);
|
||||
File.Move(path + ".tmp", path, true);
|
||||
var updated = podcast with
|
||||
{
|
||||
// Socket was not accepted open ...
|
||||
// not (meta.Socket != null && meta.Socket.State == WebSocketState.Open)
|
||||
if (liveHandler.Socket != null)
|
||||
{
|
||||
_logger.LogError($"meta.Socket.State not Open: {liveHandler.Socket.State} ");
|
||||
liveHandler.Socket.Dispose();
|
||||
}
|
||||
else
|
||||
_logger.LogError("socket object is null");
|
||||
}
|
||||
|
||||
RemoveLiveInfo(uname);
|
||||
UpdatedAt = _clock.GetUtcNow(), SegmentCount = segments.Count + 1,
|
||||
Bytes = podcast.Bytes + bytes.Length, DurationSeconds = podcast.DurationSeconds + durationSeconds
|
||||
};
|
||||
await SaveAsync(new(new(updated,
|
||||
segments.Append(new PodcastSegmentResponse(sequence, durationSeconds, bytes.Length)).ToArray()),
|
||||
document.Hashes.Append(hash).ToArray()), ct);
|
||||
return updated;
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
if (ex.Message == "Unexpected end of stream")
|
||||
{
|
||||
_logger.LogError($"Unexpected end of stream");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError($"Really unexpected end of stream");
|
||||
await liveHandler.Socket?.CloseAsync(WebSocketCloseStatus.EndpointUnavailable, ex.Message, CancellationToken.None);
|
||||
}
|
||||
liveHandler.Socket?.Dispose();
|
||||
RemoveLiveInfo(uname);
|
||||
}
|
||||
return true;
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
void RemoveLiveInfo(string userName)
|
||||
{
|
||||
LiveCastHandler caster;
|
||||
if (Casters.TryRemove(userName, out caster))
|
||||
_logger.LogInformation("removed live info");
|
||||
else
|
||||
_logger.LogError("could not remove live info");
|
||||
finally { _uploads.Release(); }
|
||||
}
|
||||
|
||||
private async Task<byte[]> ReadSegmentAsync(Stream input, CancellationToken ct)
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(20));
|
||||
try
|
||||
{
|
||||
using var buffer = new MemoryStream();
|
||||
var chunk = new byte[64 * 1024];
|
||||
int count;
|
||||
while ((count = await input.ReadAsync(chunk, timeout.Token)) != 0)
|
||||
{
|
||||
if (buffer.Length + count > _options.MaxSegmentBytes)
|
||||
throw new PodcastException(413, "Segment trop volumineux.");
|
||||
await buffer.WriteAsync(chunk.AsMemory(0, count), timeout.Token);
|
||||
}
|
||||
var bytes = buffer.ToArray();
|
||||
ValidateTransportStream(bytes);
|
||||
return bytes;
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
throw new PodcastException(408, "Delai de reception du segment depasse.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<PodcastResponse> CompleteAsync(Guid id, string ownerId, CancellationToken ct)
|
||||
{
|
||||
await _gate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
await LoadAsync(ct);
|
||||
var document = Owned(id, ownerId);
|
||||
var podcast = document.Manifest.Podcast;
|
||||
if (podcast.State == PodcastState.Completed)
|
||||
return podcast;
|
||||
var updated = podcast with { State = PodcastState.Completed, UpdatedAt = _clock.GetUtcNow() };
|
||||
await SaveAsync(document with { Manifest = document.Manifest with { Podcast = updated } }, ct);
|
||||
_logger.LogInformation("Podcast {PodcastId} completed with {Segments} segments", id, updated.SegmentCount);
|
||||
return updated;
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
public async Task<Stream> OpenSegmentAsync(Guid id, int sequence, CancellationToken ct)
|
||||
{
|
||||
await _gate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
await LoadAsync(ct);
|
||||
var document = Find(id);
|
||||
if (sequence < 0 || sequence >= document.Manifest.Segments.Count)
|
||||
throw new PodcastException(404, "Segment introuvable.");
|
||||
return new FileStream(SegmentPath(id, sequence), FileMode.Open, FileAccess.Read,
|
||||
FileShare.Read | FileShare.Delete, 64 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid id, string ownerId, CancellationToken ct)
|
||||
{
|
||||
await _gate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
await LoadAsync(ct);
|
||||
var document = Owned(id, ownerId);
|
||||
if (document.Manifest.Podcast.State == PodcastState.Live)
|
||||
throw new PodcastException(409, "Terminez le direct avant de le supprimer.");
|
||||
Directory.Delete(DirectoryFor(id), recursive: true);
|
||||
_documents.Remove(id);
|
||||
_logger.LogInformation("Podcast {PodcastId} deleted by {OwnerId}", id, ownerId);
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
private async Task LoadAsync(CancellationToken ct)
|
||||
{
|
||||
if (_loaded) return;
|
||||
Directory.CreateDirectory(_options.RootPath);
|
||||
foreach (var directory in Directory.EnumerateDirectories(_options.RootPath))
|
||||
{
|
||||
if (!Guid.TryParseExact(Path.GetFileName(directory), "N", out var id)) continue;
|
||||
var path = Path.Combine(directory, "index.json");
|
||||
if (!File.Exists(path)) continue; // An uncommitted create may leave an empty directory.
|
||||
await using var file = File.OpenRead(path);
|
||||
var document = await JsonSerializer.DeserializeAsync<Document>(file, cancellationToken: ct)
|
||||
?? throw new InvalidDataException($"Invalid podcast index {id}.");
|
||||
if (document.Manifest.Podcast.Id != id || document.Hashes.Length != document.Manifest.Segments.Count)
|
||||
throw new InvalidDataException($"Inconsistent podcast index {id}.");
|
||||
_documents[id] = document;
|
||||
}
|
||||
// A restarted process cannot know whether its old encoder is still running.
|
||||
foreach (var document in _documents.Values.Where(d => d.Manifest.Podcast.State == PodcastState.Live).ToArray())
|
||||
await InterruptAsync(document, ct);
|
||||
_loaded = true;
|
||||
}
|
||||
|
||||
private async Task ExpireAsync(CancellationToken ct)
|
||||
{
|
||||
var deadline = _clock.GetUtcNow().AddSeconds(-_options.IdleTimeoutSeconds);
|
||||
foreach (var document in _documents.Values.Where(d =>
|
||||
d.Manifest.Podcast.State == PodcastState.Live && d.Manifest.Podcast.UpdatedAt < deadline).ToArray())
|
||||
await InterruptAsync(document, ct);
|
||||
}
|
||||
|
||||
private async Task InterruptAsync(Document document, CancellationToken ct)
|
||||
{
|
||||
var podcast = document.Manifest.Podcast with { State = PodcastState.Interrupted, UpdatedAt = _clock.GetUtcNow() };
|
||||
await SaveAsync(document with { Manifest = document.Manifest with { Podcast = podcast } }, ct);
|
||||
_logger.LogWarning("Podcast {PodcastId} interrupted; committed segments remain playable", podcast.Id);
|
||||
}
|
||||
|
||||
private async Task SaveAsync(Document document, CancellationToken ct)
|
||||
{
|
||||
var path = Path.Combine(DirectoryFor(document.Manifest.Podcast.Id), "index.json");
|
||||
await using (var file = new FileStream(path + ".tmp", FileMode.Create, FileAccess.Write, FileShare.None))
|
||||
{
|
||||
await JsonSerializer.SerializeAsync(file, document, cancellationToken: ct);
|
||||
await file.FlushAsync(ct);
|
||||
}
|
||||
File.Move(path + ".tmp", path, true);
|
||||
_documents[document.Manifest.Podcast.Id] = document;
|
||||
}
|
||||
|
||||
private Document Find(Guid id) => _documents.TryGetValue(id, out var document)
|
||||
? document : throw new PodcastException(404, "Podcast introuvable.");
|
||||
|
||||
private Document Owned(Guid id, string ownerId)
|
||||
{
|
||||
var document = Find(id);
|
||||
if (document.Manifest.Podcast.OwnerId != ownerId)
|
||||
throw new PodcastException(403, "Seul l'auteur peut modifier ce podcast.");
|
||||
return document;
|
||||
}
|
||||
|
||||
private long OwnerBytes(string ownerId) => _documents.Values
|
||||
.Where(d => d.Manifest.Podcast.OwnerId == ownerId).Sum(d => d.Manifest.Podcast.Bytes);
|
||||
private string DirectoryFor(Guid id) => Path.Combine(_options.RootPath, id.ToString("N"));
|
||||
private string SegmentPath(Guid id, int sequence) => Path.Combine(DirectoryFor(id), $"{sequence:D8}.ts");
|
||||
|
||||
private static void ValidateTransportStream(byte[] bytes)
|
||||
{
|
||||
if (bytes.Length == 0 || bytes.Length % 188 != 0)
|
||||
throw new PodcastException(400, "Un segment MPEG-TS complet est requis.");
|
||||
for (var i = 0; i < bytes.Length; i += 188)
|
||||
if (bytes[i] != 0x47)
|
||||
throw new PodcastException(400, "Synchronisation MPEG-TS invalide.");
|
||||
}
|
||||
|
||||
public async Task<bool> AcceptStream(HttpContext context, ApplicationUser user, string destDir, string fileName)
|
||||
{
|
||||
var userName = user.UserName ?? throw new InvalidOperationException("A stream owner must have a user name.");
|
||||
var handler = new LiveCastHandler();
|
||||
if (!Casters.TryAdd(userName, handler))
|
||||
{
|
||||
handler.Dispose();
|
||||
context.Response.StatusCode = StatusCodes.Status409Conflict;
|
||||
return false;
|
||||
}
|
||||
try
|
||||
{
|
||||
handler.Socket = await context.WebSockets.AcceptWebSocketAsync();
|
||||
using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(
|
||||
context.RequestAborted, handler.TokenSource.Token);
|
||||
var ct = cancellation.Token;
|
||||
var path = Path.Combine(destDir, Path.GetFileName(fileName));
|
||||
var oldLength = File.Exists(path) ? new FileInfo(path).Length : 0;
|
||||
var usage = user.DiskUsage - oldLength;
|
||||
await using var file = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.Read);
|
||||
var buffer = new byte[Constants.WebSocketsMaxBufLen];
|
||||
while (handler.Socket.State == WebSocketState.Open)
|
||||
{
|
||||
var received = await handler.Socket.ReceiveAsync(new ArraySegment<byte>(buffer), ct);
|
||||
if (received.MessageType == WebSocketMessageType.Close) break;
|
||||
if (received.MessageType != WebSocketMessageType.Binary || usage + received.Count > user.DiskQuota)
|
||||
{
|
||||
await handler.Socket.CloseOutputAsync(WebSocketCloseStatus.PolicyViolation, "Binary data and available quota required.", ct);
|
||||
return false;
|
||||
}
|
||||
await file.WriteAsync(buffer.AsMemory(0, received.Count), ct);
|
||||
usage += received.Count;
|
||||
user.DiskUsage = usage;
|
||||
foreach (var listener in handler.Listeners.Values)
|
||||
if (listener.State == WebSocketState.Open)
|
||||
await listener.SendAsync(new ArraySegment<byte>(buffer, 0, received.Count),
|
||||
WebSocketMessageType.Binary, received.EndOfMessage, ct);
|
||||
}
|
||||
if (handler.Socket.State == WebSocketState.CloseReceived)
|
||||
await handler.Socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "Stream complete.", ct);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or WebSocketException or OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Legacy stream interrupted for {UserName}", user.UserName);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Casters.TryRemove(userName, out _);
|
||||
handler.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var handler in Casters.Values) handler.Dispose();
|
||||
_gate.Dispose();
|
||||
_uploads.Dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
6
src/Yavsc.Server/Services/PodcastException.cs
Normal file
6
src/Yavsc.Server/Services/PodcastException.cs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
namespace Yavsc.Services;
|
||||
|
||||
public sealed class PodcastException(int statusCode, string message) : Exception(message)
|
||||
{
|
||||
public int StatusCode { get; } = statusCode;
|
||||
}
|
||||
11
src/Yavsc.Server/Settings/PodcastOptions.cs
Normal file
11
src/Yavsc.Server/Settings/PodcastOptions.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
namespace Yavsc.Services;
|
||||
|
||||
public sealed class PodcastOptions
|
||||
{
|
||||
public string RootPath { get; set; } = Path.Combine(AppContext.BaseDirectory, "DataDir", "podcasts");
|
||||
public int MaxSegmentBytes { get; set; } = 8 * 1024 * 1024;
|
||||
public long MaxOwnerBytes { get; set; } = 1024L * 1024 * 1024;
|
||||
public int MaxSegments { get; set; } = 7200;
|
||||
public int MaxOwnerPodcasts { get; set; } = 100;
|
||||
public int IdleTimeoutSeconds { get; set; } = 60;
|
||||
}
|
||||
|
|
@ -1,85 +1,33 @@
|
|||
using System.Collections.Concurrent;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.WebSockets;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Server.Helpers;
|
||||
using Yavsc.Server.Models.FileSystem;
|
||||
|
||||
namespace Yavsc.ViewModels.Streaming
|
||||
namespace Yavsc.ViewModels.Streaming;
|
||||
|
||||
public class LiveCastClient
|
||||
{
|
||||
public class LiveCastClient
|
||||
{
|
||||
public string UserName { get; set; }
|
||||
public WebSocket Socket { get; set; }
|
||||
}
|
||||
|
||||
public class LiveEntryViewModel
|
||||
{
|
||||
public string UserName { get; set; }
|
||||
public string FlowId { get; set; }
|
||||
}
|
||||
|
||||
public class LiveCastHandler : IDisposable
|
||||
{
|
||||
|
||||
public WebSocket Socket { get; set; }
|
||||
public ConcurrentDictionary<string, WebSocket> Listeners { get; set; } = new ConcurrentDictionary<string, WebSocket>();
|
||||
|
||||
public CancellationTokenSource TokenSource { get; set; } = new CancellationTokenSource();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
|
||||
public async Task<FileReceivedInfo> ReceiveUserFile(ApplicationUser user, ILogger logger, string root, Queue<ArraySegment<byte>> queue, string destFileName, Func<bool> isEndOfInput)
|
||||
{
|
||||
// TODO lock user's disk usage for this scope,
|
||||
// this process is not safe at concurrent access.
|
||||
long usage = user.DiskUsage;
|
||||
|
||||
var item = new FileReceivedInfo
|
||||
(root, AbstractFileSystemHelpers.FilterFileName(destFileName));
|
||||
|
||||
var fi = new FileInfo(Path.Combine(root, item.FileName));
|
||||
if (fi.Exists)
|
||||
{
|
||||
item.Overridden = true;
|
||||
usage -= fi.Length;
|
||||
}
|
||||
|
||||
logger.LogInformation("Opening the file");
|
||||
using (var dest = fi.Open(FileMode.Create, FileAccess.Write, FileShare.Read))
|
||||
{
|
||||
logger.LogInformation("Appening to file");
|
||||
while (!isEndOfInput() || queue.Count > 0)
|
||||
{
|
||||
if (queue.Count > 0)
|
||||
{
|
||||
var buffer = queue.Dequeue();
|
||||
|
||||
logger.LogInformation($"writing {buffer.Count} bytes...");
|
||||
|
||||
await dest.WriteAsync(buffer.Array, buffer.Offset, buffer.Count);
|
||||
logger.LogInformation($"done.");
|
||||
usage += buffer.Count;
|
||||
}
|
||||
if (usage >= user.DiskQuota) break;
|
||||
if (queue.Count == 0 && !isEndOfInput())
|
||||
{
|
||||
await Task.Delay(100);
|
||||
}
|
||||
}
|
||||
user.DiskUsage = usage;
|
||||
dest.Close();
|
||||
}
|
||||
if (usage >= user.DiskQuota)
|
||||
{
|
||||
item.QuotaOffense = true;
|
||||
}
|
||||
user.DiskUsage = usage;
|
||||
return item;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public string UserName { get; set; }
|
||||
public WebSocket Socket { get; set; }
|
||||
}
|
||||
|
||||
public class LiveEntryViewModel
|
||||
{
|
||||
public string UserName { get; set; }
|
||||
public string FlowId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class LiveCastHandler : IDisposable
|
||||
{
|
||||
private int _disposed;
|
||||
public WebSocket Socket { get; set; }
|
||||
public ConcurrentDictionary<string, WebSocket> Listeners { get; } = new();
|
||||
public CancellationTokenSource TokenSource { get; } = new();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
|
||||
TokenSource.Cancel();
|
||||
Socket?.Dispose();
|
||||
foreach (var listener in Listeners.Values) listener.Dispose();
|
||||
TokenSource.Dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue