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:
parent
e3b4414ab1
commit
4a5a3c8844
28 changed files with 5960 additions and 29 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
88
src/PostIt/PostIt.Tests/PodcastScheduleTests.cs
Normal file
88
src/PostIt/PostIt.Tests/PodcastScheduleTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
|
|||
161
src/Yavsc.Api/Services/PodcastScheduleService.cs
Normal file
161
src/Yavsc.Api/Services/PodcastScheduleService.cs
Normal 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
|
||||
};
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
5078
src/Yavsc.Org/Migrations/20261102122758_PodcastScheduling.Designer.cs
generated
Normal file
5078
src/Yavsc.Org/Migrations/20261102122758_PodcastScheduling.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
101
src/Yavsc.Org/Migrations/20261102122758_PodcastScheduling.cs
Normal file
101
src/Yavsc.Org/Migrations/20261102122758_PodcastScheduling.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
21
src/Yavsc.Org/Views/Podcast/Announcements.cshtml
Normal file
21
src/Yavsc.Org/Views/Podcast/Announcements.cshtml
Normal 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>
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
165
src/test/Yavsc.Api.Tests/PodcastScheduleTests.cs
Normal file
165
src/test/Yavsc.Api.Tests/PodcastScheduleTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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("<script>public</script>", 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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue