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

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