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>