Prepare podcast emissions using LiveFlow

Persist scheduled emissions, expose owner-only planning controls in PostIt and publish optional presentation-only announcements on Yavsc.Org. Preserve manual broadcasting and authenticated playback.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Paul Schneider 2026-11-02 14:08:22 +00:00
commit 4a5a3c8844
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
28 changed files with 5960 additions and 29 deletions

View file

@ -122,6 +122,22 @@ public sealed class PodcastBroadcasterTests
}
}
[Fact]
public async Task Prepared_broadcast_starts_the_existing_emission_instead_of_creating_another()
{
await using var transport = new Transport();
var client = new PodcastApiClient(transport, () => "https://api.example/");
FakeCapture? capture = null;
await using var broadcaster = await PodcastBroadcaster.StartAsync(client, "Prepared",
(directory, _) => Task.FromResult<IPodcastCapture>(capture = new FakeCapture(directory)),
TestContext.Current.CancellationToken, transport.PodcastId);
Assert.Equal(transport.PodcastId, broadcaster.Podcast.Id);
Assert.Equal(transport.PodcastId, transport.StartedId);
Assert.Equal(0, transport.CreateCount);
await capture!.StopAsync();
await broadcaster.Completion.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
}
[Fact]
public async Task Native_stop_flushes_final_segment_before_completing_server_replay()
{
@ -217,10 +233,14 @@ public sealed class PodcastBroadcasterTests
public List<string> Events { get; } = [];
public bool FailUpload { get; init; }
public int CreateCount { get; private set; }
public Guid PodcastId => _podcast.Id;
public Guid? StartedId { get; private set; }
public TaskCompletionSource FirstUpload { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
if (path.EndsWith("/complete", StringComparison.Ordinal)) Events.Add("complete");
else if (path.EndsWith("/start", StringComparison.Ordinal))
StartedId = Guid.Parse(new Uri(path).Segments[^2].TrimEnd('/'));
else CreateCount++;
return Task.FromResult((T)(object)_podcast);
}

View file

@ -0,0 +1,88 @@
using Avalonia.Headless.XUnit;
using PostIt.Services;
using PostIt.ViewModels;
using Yavsc.Abstract.Identity;
using Yavsc.Abstract.Streaming;
using Yavsc.Api.Client;
namespace PostIt.Tests;
public sealed class PodcastScheduleTests
{
[AvaloniaFact]
public async Task Preparation_and_editing_send_schedule_and_optional_announcement_without_capture()
{
var tokenPath = Path.Combine(Path.GetTempPath(), $"postit-plan-{Guid.NewGuid():N}.json");
var store = new TokenStore(tokenPath);
store.Save(new RefreshTokenRecord("test-access-token", "test-refresh-token", DateTimeOffset.UtcNow.AddHours(1), null));
try
{
await using var api = new ScheduleApi(store);
var session = new SessionStatusViewModel { Api = api };
session.Refresh();
var vm = new PodcastsPageViewModel(new PodcastApiClient(api, () => "https://api.example/"), api, session)
{
Title = "Concert", ScheduledDate = DateTimeOffset.Now.AddDays(7),
ScheduledTime = new TimeSpan(14, 15, 0), PlannedDurationMinutes = 90
};
Assert.True(vm.PrepareCommand.CanExecute(null));
await vm.PrepareCommand.ExecuteAsync(null);
Assert.Equal(HttpMethod.Post, api.SavedMethod);
Assert.EndsWith("/podcasts/planned", api.SavedPath);
Assert.Equal(90, api.Saved?.PlannedDurationMinutes);
Assert.Null(api.Saved?.PublicAnnouncement);
Assert.Equal(14, api.Saved?.ScheduledStartAt.Hour);
Assert.Equal(PodcastState.Planned, vm.SelectedPodcast?.State);
Assert.False(vm.FinishCommand.CanExecute(null));
Assert.True(vm.DeleteCommand.CanExecute(null));
Assert.True(vm.UpdatePlanCommand.CanExecute(null));
vm.Title = "Nouveau titre";
vm.PublishAnnouncement = true;
vm.Announcement = "Venez au concert";
await vm.UpdatePlanCommand.ExecuteAsync(null);
Assert.Equal(HttpMethod.Put, api.SavedMethod);
Assert.EndsWith($"/podcasts/{api.Plan.Id}/planned", api.SavedPath);
Assert.Equal("Venez au concert", api.Saved?.PublicAnnouncement);
Assert.Equal("Nouveau titre", vm.SelectedPodcast?.Title);
vm.PublishAnnouncement = false;
await vm.UpdatePlanCommand.ExecuteAsync(null);
Assert.Null(api.Saved?.PublicAnnouncement);
vm.PlannedDurationMinutes = 0;
api.Saved = null;
await vm.UpdatePlanCommand.ExecuteAsync(null);
Assert.Null(api.Saved);
Assert.Contains("Preparation impossible", vm.Status);
Assert.False(vm.IsBroadcasting);
}
finally { File.Delete(tokenPath); }
}
private sealed class ScheduleApi(TokenStore store) : YavscApiClient(new Settings(), store)
{
public PodcastResponse Plan { get; private set; } = new(Guid.NewGuid(), "Concert", "owner", "Owner",
DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, PodcastState.Planned, 0, 0, 0);
public PreparePodcastRequest? Saved { get; set; }
public string? SavedPath { get; private set; }
public HttpMethod? SavedMethod { get; private set; }
public override Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
object response;
if (path.EndsWith("/account/me", StringComparison.Ordinal)) response = new Me { UserId = "owner" };
else if (method == HttpMethod.Get) response = new List<PodcastResponse> { Plan };
else
{
Saved = Assert.IsType<PreparePodcastRequest>(body);
SavedPath = path;
SavedMethod = method;
Plan = Plan with
{
Title = Saved.Title, ScheduledStartAt = Saved.ScheduledStartAt,
PlannedDurationMinutes = Saved.PlannedDurationMinutes, PublicAnnouncement = Saved.PublicAnnouncement
};
response = Plan;
}
return Task.FromResult((T)response);
}
}
}

View file

@ -52,10 +52,10 @@ public sealed class PodcastBroadcaster : IAsyncDisposable
}
public static async Task<PodcastBroadcaster> StartAsync(PodcastApiClient api, string title,
string camera, string? microphone, CancellationToken ct)
string camera, string? microphone, CancellationToken ct, Guid? plannedId = null)
=> await StartAsync(api, title,
(directory, _) => Task.FromResult<IPodcastCapture>(new LibVlcPodcastCapture(
vlc => new CameraStreamer(vlc).CreateCaptureMedia(camera, microphone), directory, preview: true)), ct);
vlc => new CameraStreamer(vlc).CreateCaptureMedia(camera, microphone), directory, preview: true)), ct, plannedId);
internal static async Task<PodcastBroadcaster> StartAsync(PodcastApiClient api, string title,
Func<LibVLC, Media> createMedia, CancellationToken ct)
@ -63,7 +63,7 @@ public sealed class PodcastBroadcaster : IAsyncDisposable
(directory, _) => Task.FromResult<IPodcastCapture>(new LibVlcPodcastCapture(createMedia, directory)), ct);
public static async Task<PodcastBroadcaster> StartAsync(PodcastApiClient api, string title,
Func<string, CancellationToken, Task<IPodcastCapture>> createCapture, CancellationToken ct)
Func<string, CancellationToken, Task<IPodcastCapture>> createCapture, CancellationToken ct, Guid? plannedId = null)
{
IPodcastCapture? capture = null;
PodcastBroadcaster? broadcaster = null;
@ -73,7 +73,9 @@ public sealed class PodcastBroadcaster : IAsyncDisposable
try
{
Directory.CreateDirectory(directory);
podcast = await api.CreateAsync(title, ct);
podcast = plannedId.HasValue ? await api.StartAsync(plannedId.Value, ct) : await api.CreateAsync(title, ct);
if (podcast.State != PodcastState.Live)
throw new InvalidOperationException("Cette emission est deja terminee. Preparez une nouvelle emission.");
await File.WriteAllTextAsync(Path.Combine(directory, "podcast.json"), JsonSerializer.Serialize(podcast), ct);
ct.ThrowIfCancellationRequested();
capture = await createCapture(directory, ct);

View file

@ -43,6 +43,16 @@ public partial class PodcastsPageViewModel : ViewModelBase
public string Title { get => _title; set { if (SetProperty(ref _title, value)) UpdateCommands(); } }
[ObservableProperty]
public partial DateTimeOffset? ScheduledDate { get; set; } = DateTimeOffset.Now.AddDays(1);
[ObservableProperty]
public partial TimeSpan? ScheduledTime { get; set; } = new TimeSpan(20, 0, 0);
[ObservableProperty]
public partial decimal PlannedDurationMinutes { get; set; } = 60;
[ObservableProperty]
public partial bool PublishAnnouncement { get; set; }
[ObservableProperty]
public partial string Announcement { get; set; } = "";
public string Status { get => _status; private set => SetProperty(ref _status, value); }
public bool IsBroadcasting
@ -72,12 +82,28 @@ public partial class PodcastsPageViewModel : ViewModelBase
public PodcastResponse? SelectedPodcast
{
get => _selected;
set { if (SetProperty(ref _selected, value)) UpdateCommands(); }
set
{
if (!SetProperty(ref _selected, value)) return;
if (value is { State: PodcastState.Planned } && value.OwnerId == _ownerId)
{
Title = value.Title;
ScheduledDate = value.ScheduledStartAt?.ToLocalTime();
ScheduledTime = value.ScheduledStartAt?.ToLocalTime().TimeOfDay;
PlannedDurationMinutes = value.PlannedDurationMinutes ?? 60;
Announcement = value.PublicAnnouncement ?? "";
PublishAnnouncement = !string.IsNullOrEmpty(Announcement);
}
UpdateCommands();
}
}
public IAsyncRelayCommand RefreshCommand { get; }
public IRelayCommand DetectDevicesCommand { get; }
public IAsyncRelayCommand StartCommand { get; }
public IAsyncRelayCommand PrepareCommand { get; }
public IAsyncRelayCommand UpdatePlanCommand { get; }
public IRelayCommand NewPlanCommand { get; }
public IAsyncRelayCommand StopCommand { get; }
public IAsyncRelayCommand PlayCommand { get; }
public IAsyncRelayCommand FinishCommand { get; }
@ -95,11 +121,17 @@ public partial class PodcastsPageViewModel : ViewModelBase
DetectDevicesCommand = new RelayCommand(DetectDevices, () => CanBroadcast && !IsBroadcasting && !IsBusy);
StartCommand = new AsyncRelayCommand(StartAsync,
() => CanBroadcast && !IsBroadcasting && !IsBusy && _session.IsLoggedIn
&& SelectedCamera is not null && !string.IsNullOrWhiteSpace(Title));
&& SelectedCamera is not null && !string.IsNullOrWhiteSpace(Title)
&& (SelectedPodcast == null || SelectedPodcast is { State: PodcastState.Planned } p && p.OwnerId == _ownerId));
PrepareCommand = new AsyncRelayCommand(() => SavePlanAsync(false),
() => !IsBusy && !IsBroadcasting && _session.IsLoggedIn && !string.IsNullOrWhiteSpace(Title));
UpdatePlanCommand = new AsyncRelayCommand(() => SavePlanAsync(true),
() => !IsBusy && !IsBroadcasting && SelectedPodcast is { State: PodcastState.Planned } p && p.OwnerId == _ownerId);
NewPlanCommand = new RelayCommand(() => SelectedPodcast = null, () => !IsBusy && !IsBroadcasting);
StopCommand = new AsyncRelayCommand(StopAsync, () => IsBroadcasting && !IsBusy);
PlayCommand = new AsyncRelayCommand(PlayAsync, () => SelectedPodcast is { SegmentCount: > 0 } && !IsBroadcasting && !IsBusy);
FinishCommand = new AsyncRelayCommand(FinishAsync,
() => SelectedPodcast is { State: not PodcastState.Completed } p
() => SelectedPodcast is { State: PodcastState.Live or PodcastState.Interrupted } p
&& p.OwnerId == _ownerId && !IsBroadcasting && !IsBusy);
DeleteCommand = new AsyncRelayCommand(DeleteAsync,
() => SelectedPodcast is { State: not PodcastState.Live } p
@ -114,6 +146,9 @@ public partial class PodcastsPageViewModel : ViewModelBase
RefreshCommand.NotifyCanExecuteChanged();
DetectDevicesCommand.NotifyCanExecuteChanged();
StartCommand.NotifyCanExecuteChanged();
PrepareCommand.NotifyCanExecuteChanged();
UpdatePlanCommand.NotifyCanExecuteChanged();
NewPlanCommand.NotifyCanExecuteChanged();
StopCommand.NotifyCanExecuteChanged();
PlayCommand.NotifyCanExecuteChanged();
FinishCommand.NotifyCanExecuteChanged();
@ -138,6 +173,34 @@ public partial class PodcastsPageViewModel : ViewModelBase
finally { IsBusy = false; }
}
private async Task SavePlanAsync(bool update)
{
IsBusy = true;
try
{
if (ScheduledDate == null || ScheduledTime == null || PlannedDurationMinutes != decimal.Truncate(PlannedDurationMinutes)
|| PlannedDurationMinutes is < 1 or > int.MaxValue)
throw new InvalidOperationException("Choisissez un debut et une duree entiere positive en minutes.");
if (PublishAnnouncement && string.IsNullOrWhiteSpace(Announcement))
throw new InvalidOperationException("Saisissez le texte de l'annonce publique ou desactivez sa publication.");
var local = DateTime.SpecifyKind(ScheduledDate.Value.Date + ScheduledTime.Value, DateTimeKind.Unspecified);
if (TimeZoneInfo.Local.IsInvalidTime(local) || TimeZoneInfo.Local.IsAmbiguousTime(local))
throw new InvalidOperationException("Cette heure est inexistante ou ambigue lors du changement d'heure. Choisissez une autre heure.");
var start = new DateTimeOffset(local, TimeZoneInfo.Local.GetUtcOffset(local));
var plan = await _client.PrepareAsync(new PreparePodcastRequest
{
Title = Title, ScheduledStartAt = start,
PlannedDurationMinutes = (int)PlannedDurationMinutes,
PublicAnnouncement = PublishAnnouncement ? Announcement : null
}, update ? SelectedPodcast?.Id : null, _pageCancellation.Token);
await RefreshAsync();
SelectedPodcast = plan;
Status = "Emission preparee. Debut et duree previsionnels : demarrage et arret manuels.";
}
catch (Exception ex) { Status = $"Preparation impossible : {ex.Message}"; }
finally { IsBusy = false; }
}
public async Task ActivateAsync()
{
if (_pageCancellation.IsCancellationRequested)
@ -194,8 +257,8 @@ public partial class PodcastsPageViewModel : ViewModelBase
await requestPermissions(microphone, _pageCancellation.Token);
_broadcaster = Platform.CreatePodcastCaptureAsync is { } createCapture
? await PodcastBroadcaster.StartAsync(_client, Title,
(directory, ct) => createCapture(camera, microphone, directory, ct), _pageCancellation.Token)
: await PodcastBroadcaster.StartAsync(_client, Title, camera, microphone, _pageCancellation.Token);
(directory, ct) => createCapture(camera, microphone, directory, ct), _pageCancellation.Token, SelectedPodcast?.Id)
: await PodcastBroadcaster.StartAsync(_client, Title, camera, microphone, _pageCancellation.Token, SelectedPodcast?.Id);
_broadcaster.Progress += OnProgress;
CapturePreview = _broadcaster.Preview;
_session.LogoutCompleted += OnLogout;

View file

@ -50,6 +50,26 @@
<StackPanel Margin="16" Spacing="12" MaxWidth="900">
<TextBlock Text="Podcasts : directs et replays" FontSize="22" />
<TextBlock Text="Lecture et emission reservees aux utilisateurs connectes. Quitter cette page arrete la capture." TextWrapping="Wrap" />
<StackPanel Spacing="8" IsEnabled="{Binding !IsBroadcasting}">
<TextBlock Text="Preparer une emission" FontWeight="SemiBold" />
<TextBox Text="{Binding Title, Mode=TwoWay}" PlaceholderText="Titre de l'emission" MaxLength="160" />
<TextBlock Text="Debut prevu (heure locale)" />
<DatePicker SelectedDate="{Binding ScheduledDate, Mode=TwoWay}" />
<TimePicker SelectedTime="{Binding ScheduledTime, Mode=TwoWay}" />
<TextBlock Text="Duree prevue en minutes" />
<NumericUpDown Value="{Binding PlannedDurationMinutes, Mode=TwoWay}" Minimum="1" Maximum="2147483647" Increment="1" />
<CheckBox Content="Publier une annonce visible sans connexion sur Yavsc.Org"
IsChecked="{Binding PublishAnnouncement, Mode=TwoWay}" />
<TextBox Text="{Binding Announcement, Mode=TwoWay}" MaxLength="1023" AcceptsReturn="True"
IsVisible="{Binding PublishAnnouncement}" PlaceholderText="Annonce publique (ne pas inclure d'informations privees)" />
<TextBlock Text="Sans annonce, la preparation est privee. Les horaires sont previsionnels : demarrage et arret manuels. L'ecoute reste reservee aux utilisateurs connectes."
TextWrapping="Wrap" />
<WrapPanel>
<Button Content="Nouvelle emission / direct immediat" Command="{Binding NewPlanCommand}" Margin="0,0,8,8" />
<Button Content="Preparer une nouvelle emission" Command="{Binding PrepareCommand}" Margin="0,0,8,8" />
<Button Content="Enregistrer les modifications de l'emission selectionnee" Command="{Binding UpdatePlanCommand}" />
</WrapPanel>
</StackPanel>
<StackPanel Spacing="8" IsVisible="{Binding IsBroadcasting}">
<TextBlock Text="Apercu local de la capture" FontWeight="SemiBold" />
<Border Background="Black" CornerRadius="4" ClipToBounds="True">
@ -58,7 +78,6 @@
</Border>
</StackPanel>
<StackPanel Spacing="8" IsVisible="{Binding CanBroadcast}" IsEnabled="{Binding !IsBroadcasting}">
<TextBox Text="{Binding Title}" PlaceholderText="Titre du direct" MaxLength="160" />
<Button Content="Detecter cameras et microphones" Command="{Binding DetectDevicesCommand}" />
<ComboBox ItemsSource="{Binding Cameras}" SelectedItem="{Binding SelectedCamera}" PlaceholderText="Camera">
<ComboBox.ItemTemplate>
@ -88,6 +107,8 @@
<TextBlock Text="{Binding Replay.State, StringFormat='MP4 : {0}'}" />
</StackPanel>
<TextBlock Text="{Binding Replay.Error}" TextWrapping="Wrap" />
<TextBlock Text="{Binding ScheduledStartAt, StringFormat='Debut prevu : {0:g}'}" />
<TextBlock Text="{Binding PlannedDurationMinutes, StringFormat='Duree prevue : {0} min'}" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
@ -99,14 +120,14 @@
</WrapPanel>
<Border Classes="warning-danger">
<StackPanel Spacing="8">
<TextBlock Classes="warning-title" Text="⚠ Suppression du replay" />
<TextBlock Text="Cette action supprime définitivement le replay sélectionné. Cette opération est irréversible."
<TextBlock Classes="warning-title" Text="⚠ Suppression du podcast / emission preparee" />
<TextBlock Text="Cette action supprime définitivement le podcast ou l'émission préparée sélectionnée. Cette opération est irréversible."
TextWrapping="Wrap" />
<CheckBox x:Name="ConfirmDelete"
Classes="warning-check"
Content="Je confirme la suppression du replay sélectionné" />
Content="Je confirme la suppression du podcast sélectionné" />
<Button Classes="delete-danger"
Content="Supprimer mon replay"
Content="Supprimer mon podcast"
Command="{Binding DeleteCommand}"
IsEnabled="{Binding #ConfirmDelete.IsChecked}" />
</StackPanel>

View file

@ -5,7 +5,7 @@ namespace Yavsc.Abstract.Streaming;
/// <summary>The lifecycle of a retained live broadcast.</summary>
[JsonConverter(typeof(JsonStringEnumConverter<PodcastState>))]
public enum PodcastState { Live, Completed, Interrupted }
public enum PodcastState { Live, Completed, Interrupted, Planned }
/// <summary>Preparation state of the seekable MP4 copy of a completed broadcast.</summary>
[JsonConverter(typeof(JsonStringEnumConverter<PodcastReplayState>))]
@ -21,12 +21,33 @@ public sealed record CreatePodcastRequest
public required string Title { get; init; }
}
/// <summary>Prepares or edits an emission before its manual start.</summary>
public sealed record PreparePodcastRequest
{
[Required, StringLength(160, MinimumLength = 1)]
public required string Title { get; init; }
public required DateTimeOffset ScheduledStartAt { get; init; }
[Range(1, int.MaxValue)]
public required int PlannedDurationMinutes { get; init; }
[StringLength(1023)]
public string? PublicAnnouncement { get; init; }
}
/// <summary>Public presentation only; never includes playback addresses or private metadata.</summary>
public sealed record PodcastAnnouncementResponse(
Guid Id, string Title, string OwnerName, DateTimeOffset ScheduledStartAt,
int PlannedDurationMinutes, string Announcement);
/// <summary>A broadcast available to authenticated listeners.</summary>
public sealed record PodcastResponse(
Guid Id, string Title, string OwnerId, string OwnerName,
DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt, PodcastState State,
int SegmentCount, long Bytes, double DurationSeconds)
{
public long? FlowId { get; init; }
public DateTimeOffset? ScheduledStartAt { get; init; }
public int? PlannedDurationMinutes { get; init; }
public string? PublicAnnouncement { get; init; }
public PodcastReplayResponse? Replay { get; init; }
}

View file

@ -21,6 +21,13 @@ public sealed class PodcastApiClient(IYavscApiClient api, Func<string> baseAddre
public Task<PodcastResponse> CreateAsync(string title, CancellationToken ct = default)
=> api.CallAsync<PodcastResponse>(HttpMethod.Post, Absolute(Prefix), new CreatePodcastRequest { Title = title }, ct);
public Task<PodcastResponse> PrepareAsync(PreparePodcastRequest request, Guid? id = null, CancellationToken ct = default)
=> api.CallAsync<PodcastResponse>(id.HasValue ? HttpMethod.Put : HttpMethod.Post,
Absolute(id.HasValue ? $"{Prefix}/{id}/planned" : $"{Prefix}/planned"), request, ct);
public Task<PodcastResponse> StartAsync(Guid id, CancellationToken ct = default)
=> api.CallAsync<PodcastResponse>(HttpMethod.Post, Absolute($"{Prefix}/{id}/start"), ct: ct);
public Task<PodcastManifestResponse> GetAsync(Guid id, CancellationToken ct = default)
=> api.CallAsync<PodcastManifestResponse>(HttpMethod.Get, Absolute($"{Prefix}/{id}"), ct: ct);

View file

@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Yavsc.Abstract.Streaming;
using Yavsc.Api.Middleware;
using Yavsc.Api.Services;
using Yavsc.Server.Helpers;
using Yavsc.Services;
@ -16,13 +17,40 @@ namespace Yavsc.ApiControllers;
[Authorize(Policy = "ApiScope")]
[Route(Constants.APIPrefix + "/podcasts")]
[TypeFilter(typeof(PodcastExceptionFilter))]
public sealed class PodcastController(ILiveProcessor processor) : ControllerBase
public sealed class PodcastController(ILiveProcessor processor, IPodcastScheduleService schedule) : ControllerBase
{
/// <summary>Lists live broadcasts and retained replays for signed-in listeners.</summary>
[HttpGet]
[ProducesResponseType<IReadOnlyList<PodcastResponse>>(StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<PodcastResponse>>> List(CancellationToken ct)
=> Ok(await processor.ListAsync(ct));
=> Ok(await schedule.ListAsync(User.GetUserId(), ct));
/// <summary>Public announcements contain presentation details only, never the stream.</summary>
[AllowAnonymous, HttpGet("announcements")]
[ProducesResponseType<IReadOnlyList<PodcastAnnouncementResponse>>(StatusCodes.Status200OK)]
public async Task<IActionResult> Announcements(CancellationToken ct)
=> Ok(await schedule.AnnouncementsAsync(ct));
/// <summary>Prepares a private emission; a nonempty announcement publishes its presentation.</summary>
[HttpPost("planned")]
[ProducesResponseType<PodcastResponse>(StatusCodes.Status201Created)]
public async Task<IActionResult> Prepare(PreparePodcastRequest request, CancellationToken ct)
{
var response = await schedule.PrepareAsync(User.GetUserId(), request, null, ct);
return CreatedAtAction(nameof(Get), new { id = response.Id }, response);
}
/// <summary>Edits an owned emission before its first start.</summary>
[HttpPut("{id:guid}/planned")]
[ProducesResponseType<PodcastResponse>(StatusCodes.Status200OK)]
public async Task<IActionResult> Update(Guid id, PreparePodcastRequest request, CancellationToken ct)
=> Ok(await schedule.PrepareAsync(User.GetUserId(), request, id, ct));
/// <summary>Manually starts a prepared emission; retries retain the same broadcast identifier.</summary>
[HttpPost("{id:guid}/start")]
[ProducesResponseType<PodcastResponse>(StatusCodes.Status200OK)]
public async Task<IActionResult> Start(Guid id, CancellationToken ct)
=> Ok(await schedule.StartAsync(id, User.GetUserId(), ct));
/// <summary>Starts a broadcast owned by the current user.</summary>
[HttpPost]
@ -41,7 +69,7 @@ public sealed class PodcastController(ILiveProcessor processor) : ControllerBase
[ProducesResponseType<PodcastManifestResponse>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PodcastManifestResponse>> Get(Guid id, CancellationToken ct)
=> Ok(await processor.GetAsync(id, ct));
=> Ok(await schedule.GetAsync(id, User.GetUserId(), ct));
/// <summary>Commits an MPEG-TS segment. Identical retries are idempotent.</summary>
[HttpPut("{id:guid}/segments/{sequence:int}")]
@ -55,7 +83,8 @@ public sealed class PodcastController(ILiveProcessor processor) : ControllerBase
{
var ownerId = User.GetUserId();
if (string.IsNullOrWhiteSpace(ownerId)) return Unauthorized();
return Ok(await processor.AppendAsync(id, ownerId, sequence, duration, Request.Body, ct));
await processor.AppendAsync(id, ownerId, sequence, duration, Request.Body, ct);
return Ok((await schedule.GetAsync(id, ownerId, ct)).Podcast);
}
/// <summary>Ends the broadcast and schedules preparation of its MP4 replay.</summary>
@ -65,7 +94,8 @@ public sealed class PodcastController(ILiveProcessor processor) : ControllerBase
{
var ownerId = User.GetUserId();
if (string.IsNullOrWhiteSpace(ownerId)) return Unauthorized();
return Ok(await processor.CompleteAsync(id, ownerId, ct));
await processor.CompleteAsync(id, ownerId, ct);
return Ok((await schedule.GetAsync(id, ownerId, ct)).Podcast);
}
/// <summary>Serves a completed MP4 replay with authenticated byte-range support.</summary>
@ -126,7 +156,7 @@ public sealed class PodcastController(ILiveProcessor processor) : ControllerBase
{
var ownerId = User.GetUserId();
if (string.IsNullOrWhiteSpace(ownerId)) return Unauthorized();
await processor.DeleteAsync(id, ownerId, ct);
await schedule.DeleteAsync(id, ownerId, ct);
return NoContent();
}
}

View file

@ -2,6 +2,27 @@
@token = REPLACE_WITH_ACCESS_TOKEN
@id = REPLACE_WITH_PODCAST_ID
### Prepare a private emission (api scope; start/stop remain manual)
POST {{baseUrl}}/api/v1/podcasts/planned
Authorization: Bearer {{token}}
Content-Type: application/json
{"title":"Concert prepare","scheduledStartAt":"2026-12-01T20:00:00+01:00","plannedDurationMinutes":90}
### Edit before start and publish a public presentation; null/empty announcement unpublishes it
PUT {{baseUrl}}/api/v1/podcasts/{{id}}/planned
Authorization: Bearer {{token}}
Content-Type: application/json
{"title":"Concert prepare","scheduledStartAt":"2026-12-01T20:00:00+01:00","plannedDurationMinutes":90,"publicAnnouncement":"Venez ecouter notre concert"}
### Public presentation only (no token, no stream)
GET {{baseUrl}}/api/v1/podcasts/announcements
### Start the prepared emission manually, retaining its identifier (owner only)
POST {{baseUrl}}/api/v1/podcasts/{{id}}/start
Authorization: Bearer {{token}}
### Create a live broadcast
POST {{baseUrl}}/api/v1/podcasts
Authorization: Bearer {{token}}

View file

@ -0,0 +1,161 @@
#nullable enable
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Yavsc.Abstract.Streaming;
using Yavsc.Models;
using Yavsc.Models.Streaming;
using Yavsc.Services;
namespace Yavsc.Api.Services;
public interface IPodcastScheduleService
{
Task<PodcastResponse> PrepareAsync(string ownerId, PreparePodcastRequest request, Guid? id, CancellationToken ct);
Task<PodcastResponse> StartAsync(Guid id, string ownerId, CancellationToken ct);
Task<IReadOnlyList<PodcastResponse>> ListAsync(string ownerId, CancellationToken ct);
Task<PodcastManifestResponse> GetAsync(Guid id, string ownerId, CancellationToken ct);
Task<IReadOnlyList<PodcastAnnouncementResponse>> AnnouncementsAsync(CancellationToken ct);
Task DeleteAsync(Guid id, string ownerId, CancellationToken ct);
}
public sealed class PodcastScheduleService(ApplicationDbContext db, ILiveProcessor processor,
IOptions<PodcastOptions> options) : IPodcastScheduleService
{
// The media store is single-process; serialize database/media lifecycle changes on that same host.
private static readonly SemaphoreSlim Gate = new(1);
public async Task<PodcastResponse> PrepareAsync(string ownerId, PreparePodcastRequest request, Guid? id, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Title) || request.Title.Trim().Length > 160
|| request.ScheduledStartAt == default || request.PlannedDurationMinutes < 1
|| request.PublicAnnouncement?.Length > 1023)
throw new PodcastException(400, "Titre, debut et duree positive en minutes requis.");
await Gate.WaitAsync(ct);
try
{
var flow = id.HasValue ? await OwnedAsync(id.Value, ownerId, ct) : null;
if (flow != null && (flow.StartedAt != null
|| (await processor.ListAsync(ct)).Any(p => p.Id == id)))
throw new PodcastException(409, "Une emission demarree ne peut plus etre reprogrammee.");
if (flow == null)
{
if (!await db.Users.AnyAsync(u => u.Id == ownerId, ct))
throw new PodcastException(404, "Compte introuvable.");
if (await db.LiveFlow.CountAsync(f => f.OwnerId == ownerId && f.PodcastId != null && !f.PodcastDeleted, ct)
>= options.Value.MaxOwnerPodcasts)
throw new PodcastException(413, "Nombre maximal d'emissions atteint. Supprimez une ancienne emission.");
flow = new LiveFlow
{
PodcastId = Guid.NewGuid(), OwnerId = ownerId, PreparedAt = DateTimeOffset.UtcNow,
Title = "", Pitch = "", MediaType = "application/vnd.apple.mpegurl", DifferedFileName = ""
};
db.LiveFlow.Add(flow);
}
flow.Title = request.Title.Trim();
flow.ScheduledStartAt = request.ScheduledStartAt.ToUniversalTime();
flow.PlannedDurationMinutes = request.PlannedDurationMinutes;
flow.PublicAnnouncement = request.PublicAnnouncement?.Trim() ?? "";
await db.SaveChangesAsync(ct);
await db.Entry(flow).Reference(f => f.Owner).LoadAsync(ct);
return Planned(flow);
}
finally { Gate.Release(); }
}
public async Task<PodcastResponse> StartAsync(Guid id, string ownerId, CancellationToken ct)
{
await Gate.WaitAsync(ct);
try
{
var flow = await OwnedAsync(id, ownerId, ct);
var existing = (await processor.ListAsync(ct)).SingleOrDefault(p => p.Id == id);
if (existing != null && existing.State != PodcastState.Live)
throw new PodcastException(409, "Cette emission est terminee. Preparez une nouvelle emission.");
if (flow.StartedAt != null && existing == null)
throw new PodcastException(404, "Les medias de cette emission sont introuvables.");
var podcast = await processor.CreateAsync(ownerId, flow.Owner.UserName ?? ownerId, flow.Title, ct, id);
flow.StartedAt ??= podcast.CreatedAt;
await db.SaveChangesAsync(ct);
return Enrich(podcast, flow);
}
finally { Gate.Release(); }
}
public async Task<IReadOnlyList<PodcastResponse>> ListAsync(string ownerId, CancellationToken ct)
{
var media = await processor.ListAsync(ct);
var flows = await db.LiveFlow.AsNoTracking().Include(f => f.Owner)
.Where(f => f.PodcastId != null).ToListAsync(ct);
var byId = flows.ToDictionary(f => f.PodcastId!.Value);
var result = media.Where(p => !byId.TryGetValue(p.Id, out var f) || !f.PodcastDeleted)
.Select(p => byId.TryGetValue(p.Id, out var f) ? Enrich(p, f) : p).ToList();
var mediaIds = media.Select(p => p.Id).ToHashSet();
result.AddRange(flows.Where(f => !f.PodcastDeleted && f.StartedAt == null && f.OwnerId == ownerId
&& !mediaIds.Contains(f.PodcastId!.Value)).Select(Planned));
return result.OrderByDescending(p => p.CreatedAt).ToArray();
}
public async Task<PodcastManifestResponse> GetAsync(Guid id, string ownerId, CancellationToken ct)
{
var flow = await db.LiveFlow.AsNoTracking().Include(f => f.Owner).SingleOrDefaultAsync(f => f.PodcastId == id, ct);
if (flow == null) return await processor.GetAsync(id, ct);
if (flow.PodcastDeleted) throw new PodcastException(404, "Podcast introuvable.");
var podcast = (await processor.ListAsync(ct)).SingleOrDefault(p => p.Id == id);
if (podcast != null)
{
var manifest = await processor.GetAsync(id, ct);
return manifest with { Podcast = Enrich(manifest.Podcast, flow) };
}
if (flow.OwnerId != ownerId || flow.StartedAt != null)
throw new PodcastException(404, "Podcast introuvable.");
return new(Planned(flow), []);
}
public async Task<IReadOnlyList<PodcastAnnouncementResponse>> AnnouncementsAsync(CancellationToken ct)
{
var flows = await db.LiveFlow.AsNoTracking().Include(f => f.Owner)
.Where(f => f.PodcastId != null && !f.PodcastDeleted && f.ScheduledStartAt != null
&& f.PlannedDurationMinutes != null && f.PublicAnnouncement != "").ToListAsync(ct);
return flows.OrderBy(f => f.ScheduledStartAt).Select(f => new PodcastAnnouncementResponse(
f.PodcastId!.Value, f.Title, f.Owner.UserName ?? f.OwnerId, f.ScheduledStartAt!.Value,
f.PlannedDurationMinutes!.Value, f.PublicAnnouncement)).ToArray();
}
public async Task DeleteAsync(Guid id, string ownerId, CancellationToken ct)
{
await Gate.WaitAsync(ct);
try
{
var flow = await db.LiveFlow.SingleOrDefaultAsync(f => f.PodcastId == id, ct);
if (flow == null) { await processor.DeleteAsync(id, ownerId, ct); return; }
if (flow.OwnerId != ownerId) throw new PodcastException(403, "Seul l'auteur peut supprimer ce podcast.");
var podcast = (await processor.ListAsync(ct)).SingleOrDefault(p => p.Id == id);
if (podcast?.State == PodcastState.Live)
throw new PodcastException(409, "Terminez le direct avant de le supprimer.");
if (podcast != null) await processor.DeleteAsync(id, ownerId, ct);
flow.PodcastDeleted = true;
await db.SaveChangesAsync(ct);
}
finally { Gate.Release(); }
}
private async Task<LiveFlow> OwnedAsync(Guid id, string ownerId, CancellationToken ct)
{
var flow = await db.LiveFlow.Include(f => f.Owner).SingleOrDefaultAsync(f => f.PodcastId == id, ct);
if (flow == null || flow.PodcastDeleted) throw new PodcastException(404, "Emission introuvable.");
if (flow.OwnerId != ownerId) throw new PodcastException(403, "Seul l'auteur peut modifier cette emission.");
return flow;
}
private static PodcastResponse Planned(LiveFlow flow) => Enrich(new(
flow.PodcastId!.Value, flow.Title, flow.OwnerId, flow.Owner.UserName ?? flow.OwnerId,
flow.PreparedAt!.Value, flow.PreparedAt.Value, PodcastState.Planned, 0, 0, 0), flow);
private static PodcastResponse Enrich(PodcastResponse podcast, LiveFlow flow) => podcast with
{
FlowId = flow.Id, ScheduledStartAt = flow.ScheduledStartAt,
PlannedDurationMinutes = flow.PlannedDurationMinutes,
PublicAnnouncement = string.IsNullOrEmpty(flow.PublicAnnouncement) ? null : flow.PublicAnnouncement
};
}

View file

@ -8,6 +8,7 @@ public static class PodcastServiceCollectionExtensions
{
services.Configure<PodcastOptions>(configuration.GetSection("Podcast"));
services.AddSingleton<ILiveProcessor, LiveProcessor>();
services.AddScoped<IPodcastScheduleService, PodcastScheduleService>();
services.AddSingleton<IPodcastReplayRemuxer, FfmpegPodcastReplayRemuxer>();
services.AddHostedService<PodcastReplayWorker>();
services.AddOpenApi("podcasts", options => options.ShouldInclude = description => description.GroupName == "podcasts");

View file

@ -23,6 +23,15 @@ public sealed class PodcastController(
ApplicationDbContext db,
ILogger<PodcastController> logger) : Controller
{
[AllowAnonymous, HttpGet]
public async Task<IActionResult> Announcements(CancellationToken ct)
{
using var response = await api.SendAsync(HttpMethod.Get, "announcements", null, ct, publicAnnouncement: true);
var announcements = await response.Content.ReadFromJsonAsync<List<PodcastAnnouncementResponse>>(ct)
?? throw new JsonException("Empty podcast announcements response.");
return View(announcements);
}
[HttpGet]
public async Task<IActionResult> Index(CancellationToken ct)
{

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,101 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Yavsc.Migrations
{
/// <inheritdoc />
public partial class PodcastScheduling : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "PlannedDurationMinutes",
table: "LiveFlow",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "PodcastDeleted",
table: "LiveFlow",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<Guid>(
name: "PodcastId",
table: "LiveFlow",
type: "uuid",
nullable: true);
migrationBuilder.AddColumn<DateTimeOffset>(
name: "PreparedAt",
table: "LiveFlow",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "PublicAnnouncement",
table: "LiveFlow",
type: "character varying(1023)",
maxLength: 1023,
nullable: true);
migrationBuilder.AddColumn<DateTimeOffset>(
name: "ScheduledStartAt",
table: "LiveFlow",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<DateTimeOffset>(
name: "StartedAt",
table: "LiveFlow",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_LiveFlow_PodcastId",
table: "LiveFlow",
column: "PodcastId",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_LiveFlow_PodcastId",
table: "LiveFlow");
migrationBuilder.DropColumn(
name: "PlannedDurationMinutes",
table: "LiveFlow");
migrationBuilder.DropColumn(
name: "PodcastDeleted",
table: "LiveFlow");
migrationBuilder.DropColumn(
name: "PodcastId",
table: "LiveFlow");
migrationBuilder.DropColumn(
name: "PreparedAt",
table: "LiveFlow");
migrationBuilder.DropColumn(
name: "PublicAnnouncement",
table: "LiveFlow");
migrationBuilder.DropColumn(
name: "ScheduledStartAt",
table: "LiveFlow");
migrationBuilder.DropColumn(
name: "StartedAt",
table: "LiveFlow");
}
}
}

View file

@ -3082,9 +3082,31 @@ namespace Yavsc.Migrations
b.Property<string>("Pitch")
.HasColumnType("text");
b.Property<int?>("PlannedDurationMinutes")
.HasColumnType("integer");
b.Property<bool>("PodcastDeleted")
.HasColumnType("boolean");
b.Property<Guid?>("PodcastId")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("PreparedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("PublicAnnouncement")
.HasMaxLength(1023)
.HasColumnType("character varying(1023)");
b.Property<DateTimeOffset?>("ScheduledStartAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("SequenceNumber")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("StartedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Title")
.HasColumnType("text");
@ -3092,6 +3114,9 @@ namespace Yavsc.Migrations
b.HasIndex("OwnerId");
b.HasIndex("PodcastId")
.IsUnique();
b.ToTable("LiveFlow");
});

View file

@ -24,14 +24,16 @@ public sealed class PodcastWebClient(HttpClient http, IOptions<SiteSettings> opt
}
public async Task<HttpResponseMessage> SendAsync(HttpMethod method, string path,
RangeHeaderValue? range, CancellationToken ct)
RangeHeaderValue? range, CancellationToken ct, bool publicAnnouncement = false)
{
var settings = options.Value;
if (!Uri.TryCreate(settings.ApiUrl, UriKind.Absolute, out var address)
|| address.Scheme != Uri.UriSchemeHttps || !string.IsNullOrEmpty(address.UserInfo))
throw new PodcastException(503, "La connexion aux podcasts n'est pas configuree.");
if (_accessToken == null)
if (publicAnnouncement && (method != HttpMethod.Get || path != "announcements"))
throw new InvalidOperationException("Only podcast announcements may be requested anonymously.");
if (!publicAnnouncement && _accessToken == null)
{
var user = contextAccessor.HttpContext?.User;
var userId = user?.GetUserId();
@ -52,7 +54,8 @@ public sealed class PodcastWebClient(HttpClient http, IOptions<SiteSettings> opt
if (!address.AbsolutePath.EndsWith("/" + Constants.APIPrefix + "/", StringComparison.Ordinal))
address = new Uri(address, Constants.APIPrefix + "/");
using var request = new HttpRequestMessage(method, new Uri(address, "podcasts/" + path));
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
if (!publicAnnouncement)
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
request.Headers.Range = range;
var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
if (!response.IsSuccessStatusCode && response.StatusCode != HttpStatusCode.RequestedRangeNotSatisfiable)

View file

@ -0,0 +1,21 @@
@model IReadOnlyList<Yavsc.Abstract.Streaming.PodcastAnnouncementResponse>
@{ ViewBag.Title = Localizer["Emissions annoncees"]; }
<h1>@Localizer["Emissions annoncees"]</h1>
<p>@Localizer["Horaires previsionnels. La lecture des directs et replays necessite une connexion."]</p>
<a asp-action="Index" class="btn btn-primary">@Localizer["Se connecter et ecouter les podcasts"]</a>
@if (Model.Count == 0)
{
<p>@Localizer["Aucune emission annoncee pour le moment."]</p>
}
@foreach (var announcement in Model)
{
<article class="card my-3">
<div class="card-body">
<h2 class="h4">@announcement.Title</h2>
<p>@announcement.OwnerName</p>
<p><time datetime="@announcement.ScheduledStartAt.ToString("O")">@announcement.ScheduledStartAt.ToString("u")</time>
· @Localizer["Duree prevue : {0} min", announcement.PlannedDurationMinutes]</p>
<p style="white-space: pre-wrap">@announcement.Announcement</p>
</div>
</article>
}

View file

@ -12,6 +12,7 @@
</div>
<div class="d-flex flex-wrap gap-2">
<a asp-action="Index" class="btn btn-outline-primary">@Localizer["Actualiser"]</a>
<a asp-action="Announcements" class="btn btn-outline-primary">@Localizer["Emissions annoncees"]</a>
<a asp-controller="Donation" asp-action="Settings" class="btn btn-outline-success">@Localizer["Configurer mes dons PayPal"]</a>
</div>
</header>
@ -34,6 +35,7 @@ else
{
PodcastState.Live => Localizer["En direct"],
PodcastState.Completed => Localizer["Replay"],
PodcastState.Planned => Localizer["Emission preparee"],
_ => Localizer["Direct interrompu"]
};
<article class="col-12 col-lg-6">
@ -47,6 +49,10 @@ else
@podcast.OwnerName · <time datetime="@podcast.CreatedAt.ToString("O")">@podcast.CreatedAt.ToLocalTime().ToString("g")</time>
</p>
<p>@Localizer["Durée : {0} s", podcast.DurationSeconds.ToString("0.##")]</p>
@if (podcast.ScheduledStartAt.HasValue)
{
<p>@Localizer["Debut prevu : {0} ; duree prevue : {1} min", podcast.ScheduledStartAt.Value.ToString("u"), podcast.PlannedDurationMinutes]</p>
}
@if (podcast.SegmentCount > 0)
{
<video controls playsinline preload="none" class="w-100"

View file

@ -16,6 +16,7 @@ background-attachment: fixed;">
<li class="nav-item"><a class="nav-link @PageHelpers.ActivePage(ViewContext, "")" href="/">@Localizer["Home"]</a></li>
<li class="nav-item"><a asp-controller="Blogspot" asp-action="Index" class="nav-link @PageHelpers.ActivePage(ViewContext, "Blogspot")">@Localizer["Blogs"]</a></li>
<li class="nav-item"><a asp-controller="Podcast" asp-action="Index" class="nav-link @PageHelpers.ActivePage(ViewContext, "Podcast")">@Localizer["Podcasts"]</a></li>
<li class="nav-item"><a asp-controller="Podcast" asp-action="Announcements" class="nav-link">@Localizer["Emissions annoncees"]</a></li>
<li class="nav-item"><a asp-controller="Home" asp-action="Contact" class="nav-link @PageHelpers.ActivePage(ViewContext, "Home", "Contact")">@Localizer["Contact"]</a></li>
<li class="nav-item"><a asp-controller="Home" asp-action="About" class="nav-link @PageHelpers.ActivePage(ViewContext, "Home", "About")">@Localizer["About"]</a></li>
<partial name="_LoginPartial" />

View file

@ -20,7 +20,7 @@ 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<PodcastResponse> CreateAsync(string ownerId, string ownerName, string title, CancellationToken ct, Guid? id = null);
Task<IReadOnlyList<PodcastResponse>> ListAsync(CancellationToken ct);
Task<PodcastManifestResponse> GetAsync(Guid id, CancellationToken ct);
Task<PodcastResponse> AppendAsync(Guid id, string ownerId, int sequence,

View file

@ -48,6 +48,8 @@ namespace Yavsc.Models
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.Entity<LiveFlow>().HasIndex(f => f.PodcastId).IsUnique();
builder.Entity<LiveFlow>().Property(f => f.PublicAnnouncement).HasMaxLength(1023);
if (Database.IsNpgsql())
{
NOW_SQL = "LOCALTIMESTAMP";
@ -499,7 +501,6 @@ namespace Yavsc.Models
public DbSet<Project> Project { get; set; }
[Obsolete("use signaled flows")]
public DbSet<LiveFlow> LiveFlow { get; set; }
public DbSet<ChatRoomAccess> ChatRoomAccess { get; set; }

View file

@ -8,6 +8,14 @@ namespace Yavsc.Models.Streaming
public partial class LiveFlow : ILiveFlow {
public Guid? PodcastId { get; set; }
public DateTimeOffset? ScheduledStartAt { get; set; }
public int? PlannedDurationMinutes { get; set; }
public string PublicAnnouncement { get; set; } = string.Empty;
public DateTimeOffset? PreparedAt { get; set; }
public DateTimeOffset? StartedAt { get; set; }
public bool PodcastDeleted { get; set; }
[Key(), DatabaseGenerated(DatabaseGeneratedOption.Identity)]
[Display(Name="FlowIdLabel", ResourceType=typeof(LiveFlow))]
// set by the server, unique

View file

@ -41,7 +41,7 @@ public sealed class LiveProcessor : ILiveProcessor, IDisposable
throw new InvalidOperationException("Invalid Podcast storage options.");
}
public async Task<PodcastResponse> CreateAsync(string ownerId, string ownerName, string title, CancellationToken ct)
public async Task<PodcastResponse> CreateAsync(string ownerId, string ownerName, string title, CancellationToken ct, Guid? id = null)
{
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.");
@ -50,6 +50,12 @@ public sealed class LiveProcessor : ILiveProcessor, IDisposable
{
await LoadAsync(ct);
await ExpireAsync(ct);
if (id.HasValue && _documents.TryGetValue(id.Value, out var existing))
{
if (existing.Manifest.Podcast.OwnerId != ownerId)
throw new PodcastException(403, "Seul l'auteur peut demarrer ce podcast.");
return existing.Manifest.Podcast;
}
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)
@ -57,7 +63,7 @@ public sealed class LiveProcessor : ILiveProcessor, IDisposable
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,
var podcast = new PodcastResponse(id ?? 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));

View file

@ -0,0 +1,165 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Abstract.Streaming;
using Yavsc.Api.Services;
using Yavsc.Api.Test.Fixtures;
using Yavsc.Models;
namespace Yavsc.Api.Test;
[Collection("Yavsc Api")]
public sealed class PodcastScheduleTests(ApiWebServerFixture fixture) : IClassFixture<ApiWebServerFixture>
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private HttpClient Client(string? user)
{
var client = new HttpClient(new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
}) { BaseAddress = new Uri(fixture.BaseAddress) };
if (user != null) client.DefaultRequestHeaders.Authorization = new("Bearer", Yavsc.Tests.Shared.TestTokenIssuer.Issue(user, "api"));
return client;
}
private async Task<string> OwnerAsync()
{
var id = "schedule-" + Guid.NewGuid().ToString("N");
using var scope = fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.Users.Add(new ApplicationUser { Id = id, UserName = id, Email = id + "@example.test" });
await db.SaveChangesAsync(Ct);
return id;
}
private static PreparePodcastRequest Request(string? announcement = null) => new()
{
Title = "Concert prepare", ScheduledStartAt = DateTimeOffset.UtcNow.AddDays(7),
PlannedDurationMinutes = 90, PublicAnnouncement = announcement
};
private async Task<PodcastResponse> PrepareAsync(HttpClient client, PreparePodcastRequest request)
{
using var response = await client.PostAsJsonAsync("/api/v1/podcasts/planned", request, Ct);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
Assert.NotNull(response.Headers.Location);
return (await response.Content.ReadFromJsonAsync<PodcastResponse>(Ct))!;
}
[Fact]
public async Task Plans_are_persisted_in_LiveFlow_private_editable_and_cancelable()
{
var ownerId = await OwnerAsync();
using var owner = Client(ownerId);
using var other = Client("other");
using var anonymous = Client(null);
var request = Request();
var plan = await PrepareAsync(owner, request);
var path = $"/api/v1/podcasts/{plan.Id}";
Assert.Equal(PodcastState.Planned, plan.State);
Assert.NotNull(plan.FlowId);
Assert.Equal(request.ScheduledStartAt, plan.ScheduledStartAt);
Assert.Equal(90, plan.PlannedDurationMinutes);
using (var scope = fixture.Services.CreateScope())
{
var flow = await scope.ServiceProvider.GetRequiredService<ApplicationDbContext>().LiveFlow
.AsNoTracking().SingleAsync(f => f.Id == plan.FlowId, Ct);
Assert.Equal(plan.Id, flow.PodcastId);
var reloaded = await scope.ServiceProvider.GetRequiredService<IPodcastScheduleService>().GetAsync(plan.Id, ownerId, Ct);
Assert.Equal(plan, reloaded.Podcast);
}
var list = await other.GetFromJsonAsync<List<PodcastResponse>>("/api/v1/podcasts", Ct);
Assert.DoesNotContain(list!, p => p.Id == plan.Id);
using var missing = await other.GetAsync(path, Ct);
Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode);
using var denied = await other.PutAsJsonAsync(path + "/planned", Request("changed"), Ct);
Assert.Equal(HttpStatusCode.Forbidden, denied.StatusCode);
using var startDenied = await other.PostAsync(path + "/start", null, Ct);
Assert.Equal(HttpStatusCode.Forbidden, startDenied.StatusCode);
using var updated = await owner.PutAsJsonAsync(path + "/planned", request with { Title = "Nouveau titre" }, Ct);
Assert.Equal(HttpStatusCode.OK, updated.StatusCode);
Assert.Equal("Nouveau titre", (await owner.GetFromJsonAsync<PodcastManifestResponse>(path, Ct))?.Podcast.Title);
using var streamDenied = await anonymous.GetAsync(path + "/index.m3u8", Ct);
Assert.Equal(HttpStatusCode.Unauthorized, streamDenied.StatusCode);
using var cancelled = await owner.DeleteAsync(path, Ct);
Assert.Equal(HttpStatusCode.NoContent, cancelled.StatusCode);
using var cancelledGet = await owner.GetAsync(path, Ct);
Assert.Equal(HttpStatusCode.NotFound, cancelledGet.StatusCode);
}
[Fact]
public async Task Public_announcements_have_only_presentation_data_and_can_be_unpublished()
{
using var owner = Client(await OwnerAsync());
using var anonymous = Client(null);
var privatePlan = await PrepareAsync(owner, Request());
var publicPlan = await PrepareAsync(owner, Request("Venez au concert"));
using var response = await anonymous.GetAsync("/api/v1/podcasts/announcements", Ct);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var items = await response.Content.ReadFromJsonAsync<List<PodcastAnnouncementResponse>>(Ct);
Assert.Contains(items!, p => p.Id == publicPlan.Id && p.Announcement == "Venez au concert");
Assert.DoesNotContain(items!, p => p.Id == privatePlan.Id);
using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync(Ct));
Assert.All(json.RootElement.EnumerateArray(), item =>
Assert.Equal(new[] { "announcement", "id", "ownerName", "plannedDurationMinutes", "scheduledStartAt", "title" },
item.EnumerateObject().Select(p => p.Name).Order().ToArray()));
using var update = await owner.PutAsJsonAsync($"/api/v1/podcasts/{publicPlan.Id}/planned", Request(), Ct);
Assert.Equal(HttpStatusCode.OK, update.StatusCode);
Assert.DoesNotContain((await anonymous.GetFromJsonAsync<List<PodcastAnnouncementResponse>>("/api/v1/podcasts/announcements", Ct))!,
p => p.Id == publicPlan.Id);
using (await owner.DeleteAsync($"/api/v1/podcasts/{privatePlan.Id}", Ct)) { }
using (await owner.DeleteAsync($"/api/v1/podcasts/{publicPlan.Id}", Ct)) { }
}
[Fact]
public async Task Manual_start_retains_id_and_schedule_is_idempotent_and_prevents_later_edits()
{
using var owner = Client(await OwnerAsync());
var plan = await PrepareAsync(owner, Request("Annonce"));
var path = $"/api/v1/podcasts/{plan.Id}";
try
{
var responses = await Task.WhenAll(owner.PostAsync(path + "/start", null, Ct), owner.PostAsync(path + "/start", null, Ct));
foreach (var response in responses)
{
using (response)
{
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var live = await response.Content.ReadFromJsonAsync<PodcastResponse>(Ct);
Assert.Equal(plan.Id, live?.Id);
Assert.Equal(PodcastState.Live, live?.State);
Assert.Equal(plan.FlowId, live?.FlowId);
Assert.Equal(plan.ScheduledStartAt, live?.ScheduledStartAt);
}
}
using var edit = await owner.PutAsJsonAsync(path + "/planned", Request(), Ct);
Assert.Equal(HttpStatusCode.Conflict, edit.StatusCode);
using var completed = await owner.PostAsync(path + "/complete", null, Ct);
Assert.Equal(plan.FlowId, (await completed.Content.ReadFromJsonAsync<PodcastResponse>(Ct))?.FlowId);
using var restart = await owner.PostAsync(path + "/start", null, Ct);
Assert.Equal(HttpStatusCode.Conflict, restart.StatusCode);
}
finally
{
using (await owner.PostAsync(path + "/complete", null, Ct)) { }
using (await owner.DeleteAsync(path, Ct)) { }
}
}
[Theory]
[InlineData(0, "Concert")]
[InlineData(-1, "Concert")]
[InlineData(60, " ")]
public async Task Invalid_plans_are_rejected_without_creating_a_flow(int minutes, string title)
{
var id = await OwnerAsync();
using var owner = Client(id);
using var response = await owner.PostAsJsonAsync("/api/v1/podcasts/planned",
Request() with { PlannedDurationMinutes = minutes, Title = title }, Ct);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
using var scope = fixture.Services.CreateScope();
Assert.False(await scope.ServiceProvider.GetRequiredService<ApplicationDbContext>().LiveFlow.AnyAsync(f => f.OwnerId == id, Ct));
}
}

View file

@ -68,6 +68,22 @@ public sealed class PodcastWebTests : IClassFixture<PodcastWebTests.Factory>
Assert.Empty(_factory.Api.Requests);
}
[Fact]
public async Task Announcements_are_public_escaped_and_requested_without_a_user_token()
{
using var client = _factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
using var response = await client.GetAsync("/Podcast/Announcements", Ct);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var html = await response.Content.ReadAsStringAsync(Ct);
Assert.Contains("&lt;script&gt;public&lt;/script&gt;", html);
Assert.DoesNotContain("<script>public</script>", html);
Assert.DoesNotContain("data-podcast-player", html);
Assert.DoesNotContain("replay.mp4", html);
var request = Assert.Single(_factory.Api.Requests);
Assert.Equal("/api/v1/podcasts/announcements", request.Path);
Assert.Null(request.Authorization);
}
[Fact]
public async Task Catalogue_uses_the_authority_user_token_without_an_api_client_or_external_donation_link()
{
@ -288,6 +304,12 @@ public sealed class PodcastWebTests : IClassFixture<PodcastWebTests.Factory>
request.Headers.Authorization?.ToString(), request.Headers.Range?.ToString()));
if (Status != HttpStatusCode.OK)
return Task.FromResult(new HttpResponseMessage(Status));
if (path == "/api/v1/podcasts/announcements")
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = JsonContent.Create(new[] { new PodcastAnnouncementResponse(
PodcastId, "Concert", "Author", DateTimeOffset.UtcNow.AddDays(1), 60, "<script>public</script>") })
});
var podcast = new PodcastResponse(PodcastId, "<script>unsafe-title</script>",
Live ? "other-author" : "author", "Author", DateTimeOffset.UtcNow,
DateTimeOffset.UtcNow, Live ? PodcastState.Live : PodcastState.Completed, 1, 188, 2.5)