feat: add authenticated live podcasts and simplify native video playback
All checks were successful
Forgejo Release / release (push) Successful in 8m6s
Dotnet build and test / build (pull_request) Successful in 7m50s
Dotnet build and test / build (push) Successful in 9m29s

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:
Paul Schneider 2026-10-19 15:22:34 +01:00
commit 4fc91cec3e
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
45 changed files with 2330 additions and 378 deletions

View file

@ -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);
}

View file

@ -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();
}
}

View file

@ -0,0 +1,6 @@
namespace Yavsc.Services;
public sealed class PodcastException(int statusCode, string message) : Exception(message)
{
public int StatusCode { get; } = statusCode;
}

View 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;
}

View file

@ -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();
}
}