diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ee921d5e..412139a3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added +* [Podcasts] Reuse persisted LiveFlow entities to prepare, edit and delete emissions from PostIt with a title, scheduled start and estimated duration. Keep start/stop manual and plans private; optionally publish presentation-only announcements on Yavsc.Org without opening anonymous playback. Preserve broadcast identifiers, legacy recordings and authenticated HLS/MP4 access; add a scheduling migration and lifecycle/privacy regressions. * [PostIt/API/Yavsc.Org] Expose the private PayPal recipient setting in PostIt's performer editor and authenticated profile API; validate addresses, allow disabling with an empty value and preserve the setting for older clients. Make Web donation settings discoverable from the performer profile and podcast catalogue. * [API] Initialize the required address field when creating a performer profile so a first configuration can be saved. * [Yavsc.Org] Add an authenticated Razor podcast catalogue with same-origin API-backed HLS/MP4 playback and replay byte-range support. Reuse Site.ApiUrl and the authority's signing credentials to issue short-lived user tokens, without a separate OAuth client or PodcastWeb configuration. Distinguish an empty catalogue from API/configuration failures. diff --git a/doc/Architecture.md b/doc/Architecture.md index d8f1406d7..fb56fcb52 100644 --- a/doc/Architecture.md +++ b/doc/Architecture.md @@ -84,6 +84,46 @@ connecté (`sub`, audience `api`, scope `api` attendu par Yavsc.Api). Ce jeton reste côté serveur. Une adresse API manquante renvoie 503 ; une panne de l'API renvoie une erreur explicite, pas un catalogue vide. +### Emissions preparees + +PostIt permet de preparer, modifier et supprimer une emission avant son +demarrage : titre, date/heure locale, duree entiere positive en minutes et +annonce publique optionnelle. Les horaires sont previsionnels : la camera, +le demarrage et l'arret restent manuels. Les heures locales inexistantes ou +ambigues au changement d'heure sont refusees. + +La table existante `LiveFlow` conserve la preparation et son proprietaire ; +`PodcastId` (GUID unique) relie cette emission au manifeste media sur disque. +`PodcastResponse` reste un DTO, enrichi de `FlowId`, `ScheduledStartAt`, +`PlannedDurationMinutes` et `PublicAnnouncement`. Un etat `Planned` +precede le direct ; la date previsionnelle n'est jamais confondue avec +`CreatedAt` du direct ni la duree previsionnelle avec les secondes enregistrees. +Les anciens manifestes sans `LiveFlow` restent lisibles ; aucune copie ou +migration de leurs medias n'est requise. Le demarrage immediat historique +reste disponible sans preparation. + +L'API propose `POST /api/v1/podcasts/planned`, +`PUT /api/v1/podcasts/{id}/planned` et `POST /api/v1/podcasts/{id}/start`. +La preparation n'est visible que du proprietaire, meme avec une annonce ; +les nouvelles routes de mutation exigent le scope `api`. Le demarrage +conserve le GUID et les tentatives concurrentes reutilisent le meme direct. +Une emission demarree ne peut plus etre reprogrammee ou redemarree apres +sa fin. Les medias restent geres par `ILiveProcessor`, avec ses quotas, +expiration, reprise et preparation MP4. Une suppression conserve un +marqueur en base pour ne pas ressusciter une emission deja supprimee. +Le verrou de coordination suit le deploiement mono-processus du stockage +media actuel ; un deploiement multi-instance necessiterait un stockage et +une coordination distribues. + +Seul `GET /api/v1/podcasts/announcements` est anonyme : titre, nom d'auteur, +horaire UTC, duree et texte public, sans URL de flux ni metadonnees privees. +`/Podcast/Announcements` affiche ces annonces avec encodage Razor et sans +jeton utilisateur. Retirer le texte public avant le debut retire l'annonce. +Les annonces restent disponibles apres le direct jusqu'a suppression du +podcast. Les pages et toutes les routes de lecture restent authentifiees. +Appliquer la migration `PodcastScheduling` au deploiement, apres celle des +dons ; aucune migration de production n'est appliquee automatiquement ici. + ### Dons PayPal aux performers Les dons sont gérés par Yavsc.Org, pas par un lien de paiement externe diff --git a/doc/testing.md b/doc/testing.md index 7444d3273..5b41ddba3 100644 --- a/doc/testing.md +++ b/doc/testing.md @@ -82,13 +82,16 @@ dotnet test --project src/test/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj \ --filter-class '*PodcastWebTests' --filter-class '*PerformerDonationTests' dotnet test --project src/test/Yavsc.Api.Tests/Yavsc.Api.Tests.csproj \ --filter-class '*LiveProcessorTests' --filter-class '*PodcastControllerTests' \ - --filter-class '*PodcastReplayTests' --filter-class '*PerformerDonationSettingsTests' + --filter-class '*PodcastReplayTests' --filter-class '*PodcastScheduleTests' \ + --filter-class '*PerformerDonationSettingsTests' dotnet test --project src/PostIt/PostIt.Tests/PostIt.Tests.csproj \ --filter-class '*PodcastTests' --filter-class '*PodcastReplayTests' \ --filter-class '*YavscApiClientTests' --filter-class '*PodcastNativeCaptureTests' \ --filter-class '*PerformerDonationSettingsTests' dotnet test --project src/PostIt/PostIt.Tests/PostIt.Tests.csproj \ --filter-method '*Shared_page_exposes_running_capture_preview_and_clears_it_before_disposal' +dotnet test --project src/PostIt/PostIt.Tests/PostIt.Tests.csproj \ + --filter-class '*PodcastScheduleTests' ``` Les tests serveur couvrent HTTP/authentification, proprietaire, sequences, @@ -97,6 +100,12 @@ apres interruption/redemarrage, dans des dossiers temporaires isoles. Les tests de configuration PayPal couvrent la lecture privee, la creation, la validation avant toute sauvegarde, l'effacement, la compatibilite des anciens clients, ainsi que le chargement, la liaison du champ et l'enregistrement PostIt. +Les tests `PodcastScheduleTests` couvrent la preparation persistante dans +`LiveFlow`, la confidentialite, l'annonce/unpublication, les controles +proprietaire, la validation et le demarrage manuel idempotent. +La preparation PostIt et les tests de capture headless ont ete verifies +separement : leur selection groupee peut rester bloquee alors que chaque +test cible reussit isole. Installer FFmpeg 5+ et ffprobe pour ces tests serveur : ils generent une source H.264/AAC synthetique de quatre secondes, verifient le remuxage reel (pistes, duree, faststart, decodage et seek a deux secondes), puis les reponses diff --git a/src/PostIt/PostIt.Tests/PodcastBroadcasterTests.cs b/src/PostIt/PostIt.Tests/PodcastBroadcasterTests.cs index fe766335f..5e4f9e2ef 100644 --- a/src/PostIt/PostIt.Tests/PodcastBroadcasterTests.cs +++ b/src/PostIt/PostIt.Tests/PodcastBroadcasterTests.cs @@ -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(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 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 CallAsync(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); } diff --git a/src/PostIt/PostIt.Tests/PodcastScheduleTests.cs b/src/PostIt/PostIt.Tests/PodcastScheduleTests.cs new file mode 100644 index 000000000..b3b49bbad --- /dev/null +++ b/src/PostIt/PostIt.Tests/PodcastScheduleTests.cs @@ -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 CallAsync(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 { Plan }; + else + { + Saved = Assert.IsType(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); + } + } +} diff --git a/src/PostIt/PostIt/Services/PodcastBroadcaster.cs b/src/PostIt/PostIt/Services/PodcastBroadcaster.cs index 0b931b6b0..2094abc99 100644 --- a/src/PostIt/PostIt/Services/PodcastBroadcaster.cs +++ b/src/PostIt/PostIt/Services/PodcastBroadcaster.cs @@ -52,10 +52,10 @@ public sealed class PodcastBroadcaster : IAsyncDisposable } public static async Task 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(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 StartAsync(PodcastApiClient api, string title, Func createMedia, CancellationToken ct) @@ -63,7 +63,7 @@ public sealed class PodcastBroadcaster : IAsyncDisposable (directory, _) => Task.FromResult(new LibVlcPodcastCapture(createMedia, directory)), ct); public static async Task StartAsync(PodcastApiClient api, string title, - Func> createCapture, CancellationToken ct) + Func> 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); diff --git a/src/PostIt/PostIt/ViewModels/PodcastsPageViewModel.cs b/src/PostIt/PostIt/ViewModels/PodcastsPageViewModel.cs index 420024342..2e38a9dd6 100644 --- a/src/PostIt/PostIt/ViewModels/PodcastsPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/PodcastsPageViewModel.cs @@ -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; diff --git a/src/PostIt/PostIt/Views/Video/PodcastsPage.axaml b/src/PostIt/PostIt/Views/Video/PodcastsPage.axaml index 0456e3718..9af54d2cc 100644 --- a/src/PostIt/PostIt/Views/Video/PodcastsPage.axaml +++ b/src/PostIt/PostIt/Views/Video/PodcastsPage.axaml @@ -50,6 +50,26 @@ + + + + + + + + + + + + +