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

@ -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.

View file

@ -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

View file

@ -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

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)