radiostasis/scripts/generate-site.csx

402 lines
12 KiB
C#
Executable file

#!/usr/bin/env dotnet-script
#nullable enable
#r "nuget: Microsoft.Data.Sqlite, 7.0.4"
/* This script generates the static HTML pages for the series list
* pages and the series detail pages using the data in the sqlite
* database. */
using System.Globalization;
using System.Net;
using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.Data.Sqlite;
using SQLitePCL;
private readonly string BASE_PATH = Path.GetFullPath("..");
private readonly string OUTPUT_DIR = Path.Combine(BASE_PATH, "site", "partial");
private readonly string CONNECTION_STRING =
$"Data Source={Path.Combine(BASE_PATH, "db", "radiostasis.db")}";
private static readonly Regex cleanHost = new(@"^(en|www)\.", RegexOptions.Compiled);
private static readonly Regex cleanFilter = new(@"[^a-z0-9 ]", RegexOptions.Compiled);
private class Series {
public required string Slug { get; set; }
public required string Title { get; set; }
public required string TitleSort { get; set; }
public required DateTime DateAdded { get; set; }
public required short MinYear { get; set; }
public required short MaxYear { get; set; }
public required IEnumerable<string> Tags { get; set; }
public string? Description { get; set; }
public string? Actors { get; set; }
public required int EpisodeCount { get; set; }
public required IEnumerable<string> Urls { get; set; }
public string SlugEncoded { get => WebUtility.HtmlEncode(Slug); }
public string YearRange {
get => MinYear == MaxYear
? $"{MinYear}"
: $"{MinYear}-{MaxYear}";
}
public string TitleEncoded {
get => WebUtility.HtmlEncode(Title);
}
public string HtmlDescription {
get {
var sb = new StringBuilder();
if (!string.IsNullOrEmpty(Description)) {
sb.AppendLine("<article><p>");
sb.AppendLine(string.Join("</p><p>",
Description.Split("\n")
.Where(l => !string.IsNullOrWhiteSpace(l))
.Select(l => WebUtility.HtmlEncode(l))));
sb.AppendLine("</p>");
if (!string.IsNullOrEmpty(Actors)) {
sb.AppendLine(
$"<p><strong>Performers:</strong> {WebUtility.HtmlEncode(Actors)}</p>");
}
if (Urls.Count() > 0) {
sb.AppendLine("<p><strong>Sources:</strong> ");
sb.AppendLine(string.Join(", ",
Urls.Select(u =>
$"<a href='{u}' target='_blank'>{cleanHost.Replace(new Uri(u).Host, string.Empty)}</a>")));
sb.AppendLine("</p>");
}
sb.AppendLine("</article>");
}
return sb.ToString();
}
}
public string FilterText {
get {
var sb = new StringBuilder();
// add the show's title
sb.Append(Title.ToLowerInvariant());
// add all the actors
if (!string.IsNullOrEmpty(Actors)) {
sb.Append(" ");
sb.Append(cleanFilter.Replace(Actors.ToLowerInvariant(), string.Empty));
}
// add the decades like 1930s 1940s etc
var firstDecade = (int)(Math.Floor(MinYear / 10d) * 10);
var lastDecade = (int)(Math.Floor(MaxYear / 10d) * 10);
for (var decade = firstDecade; decade <= lastDecade; decade += 10) {
sb.Append($" {decade}s");
}
return WebUtility.HtmlEncode(sb.ToString());
}
}
}
private class Episode {
public required Series Series { get; set; }
public required string Slug { get; set; }
public required string Title { get; set; }
public required string FileName { get; set; }
public required long FileSize { get; set; }
public required int Length { get; set; }
public string? AirDate { get; set; }
public string TitleEncoded {
get => WebUtility.HtmlEncode(Title);
}
public string FileNameEncoded {
get => WebUtility.HtmlEncode(FileName);
}
public string LengthDisplay {
get => $"{Math.Round(Length / 60f)}mins";
}
public string AirDateDisplay {
get => !string.IsNullOrWhiteSpace(AirDate)
? $"Aired {AirDate}"
: "Air date unknown";
}
public string JsonEncoded {
get => WebUtility.HtmlEncode(JsonSerializer.Serialize(new {
id = $"{Series.Slug}/{Slug}",
slug = Slug,
title = Title,
file = FileName,
length = LengthDisplay,
size = FileSize,
series = new {
slug = Series.Slug,
title = Series.Title,
cover = $"/cover/sm/{Series.SlugEncoded}.jpg",
},
}));
}
}
private IEnumerable<string> GetTags() {
using var connection = new SqliteConnection(CONNECTION_STRING);
connection.Open();
var tags = new List<string>();
using var cmd = connection.CreateCommand();
cmd.CommandText = "select distinct tag from series_tags order by tag";
using var reader = cmd.ExecuteReader();
while (reader.Read()) {
tags.Add(reader.GetString(0));
}
return tags;
}
private IEnumerable<Series> GetSeries() {
using var connection = new SqliteConnection(CONNECTION_STRING);
connection.Open();
// get all series tags
var tags = new Dictionary<string, List<string>>();
using (var tagcmd = connection.CreateCommand()) {
tagcmd.CommandText = "select series_slug, tag from series_tags order by tag";
using var tagreader = tagcmd.ExecuteReader();
while (tagreader.Read()) {
var slug = tagreader.GetString(0);
var tag = tagreader.GetString(1);
if (!tags.ContainsKey(slug)) tags.Add(slug, new());
tags[slug].Add(tag);
}
}
// get all series
using var cmd = connection.CreateCommand();
cmd.CommandText =
@"select
s.series_slug,
s.title,
s.date_added,
s.min_year,
s.max_year,
s.description,
s.title_sort,
count(distinct e.episode_slug) episode_count,
group_concat(l.link_url, '||') urls,
s.actors
from series s
inner join episodes e on
e.series_slug=s.series_slug
left join series_links l on
l.series_slug=s.series_slug
group by s.series_slug
order by s.title_sort";
var series = new List<Series>();
using var reader = cmd.ExecuteReader();
while (reader.Read()) {
var slug = reader.GetString(0);
series.Add(new() {
Slug = slug,
Title = reader.GetString(1),
DateAdded = reader.GetDateTime(2),
MinYear = reader.GetInt16(3),
MaxYear = reader.GetInt16(4),
Description = reader.IsDBNull(5) ? null : reader.GetString(5),
TitleSort = reader.GetString(6),
EpisodeCount = reader.GetInt32(7),
Tags = tags[slug],
Urls = reader.GetString(8).Split("||").Distinct().Order().ToArray(),
Actors = reader.IsDBNull(9) ? null : reader.GetString(9),
});
}
return series;
}
private IEnumerable<Episode> GetEpisodes(Series series) {
using var connection = new SqliteConnection(CONNECTION_STRING);
connection.Open();
using var cmd = connection.CreateCommand();
cmd.CommandText =
@"select episode_slug, title, file_name, file_size, episode_length, air_date
from episodes where series_slug=@Series
order by episode_slug";
var param = cmd.CreateParameter();
param.ParameterName = "Series";
param.Value = series.Slug;
cmd.Parameters.Add(param);
var episodes = new List<Episode>();
using var reader = cmd.ExecuteReader();
while (reader.Read()) {
episodes.Add(new() {
Slug = reader.GetString(0),
Title = reader.GetString(1),
FileName = reader.GetString(2),
FileSize = reader.GetInt64(3),
Length = reader.GetInt32(4),
AirDate = reader.IsDBNull(5) ? null : reader.GetString(5),
Series = series,
});
}
return episodes;
}
private string DisplayTag(string tag) =>
CultureInfo.CurrentCulture.TextInfo.ToTitleCase(tag.ToLower().Replace("-", " "))
.Replace(" ", "-");
private void GenerateSeriesListFragment(
IEnumerable<Series> seriesList, string path, string title) {
using var outStream = File.Open(path, FileMode.Create);
using var sw = new StreamWriter(outStream);
sw.Write(
@$"<header>
<h2>{WebUtility.HtmlEncode(title)}</h2>
<input class='filter' type='search' placeholder='Filter Title, Actor, Decade'>
</header>
<div class='seriesList'>");
foreach (var series in seriesList) {
sw.Write(@$"
<section
hx-get='/partial/series/{series.SlugEncoded}.html'
hx-target='main'
hx-push-url='/series/{series.SlugEncoded}'
hx-swap='innerHTML show:top'
data-filter='{series.FilterText}'
title='{series.TitleEncoded}'>
<img alt='cover image' title='{series.TitleEncoded}'
src='/cover/sm/{series.SlugEncoded}.jpg'
loading='lazy'>
<div>
<ul>
{string.Join(string.Empty, series.Tags.Select(t =>
$"<li>{DisplayTag(t)}</li>"))}
</ul>
<label>{series.TitleEncoded}</label>
<aside>
<span>{series.EpisodeCount} episodes</span>
<span>Aired {series.YearRange}</span>
</aside>
</div>
</section>");
}
sw.Write("</div>");
}
private void GenerateSeriesDetailsFragment(Series series) {
var path = Path.Combine(OUTPUT_DIR, "series", $"{series.Slug}.html");
using var outStream = File.Open(path, FileMode.Create);
using var sw = new StreamWriter(outStream);
sw.Write(@$"
<div class='seriesDetails'>
<section>
<img alt='cover image' title='{series.TitleEncoded}'
src='/cover/sm/{series.SlugEncoded}.jpg'>
<div>
<ul>
{string.Join(string.Empty, series.Tags.Select(t =>
$"<li>{DisplayTag(t)}</li>"))}
</ul>
<label>{series.TitleEncoded}</label>
<aside>
<span>{series.EpisodeCount} episodes</span>
<span>Aired {series.YearRange}</span>
</aside>
<div class='controls'>
<a href='#'>Play Series</a>
<a href='#'>Queue Series</a>
</div>
</div>
</section>
<div class='detail'>
<ol>");
foreach (var episode in GetEpisodes(series)) {
sw.Write(
@$"<li class='episode' title='{episode.TitleEncoded}'
data-episode='{episode.JsonEncoded}'>
<label>{episode.TitleEncoded}</label>
<aside>
<span>{episode.LengthDisplay}</span>
<span>{episode.AirDateDisplay}</span>
</aside>
<div class='controls'>
<a href='#'>Play Episode</a>
<a href='#'>Queue Episode</a>
</div>
</li>");
}
sw.Write(
@$"</ol>
{series.HtmlDescription}
</div>
</div>");
}
private void InjectGenreList(IEnumerable<string> genres) {
var indexPath = Path.Combine(BASE_PATH, "site", "index.html");
var index = File.ReadAllLines(indexPath);
using var outStream = File.Create(indexPath);
using var sw = new StreamWriter(outStream);
var inGenres = false;
foreach (var line in index) {
if (inGenres) {
if (line.Contains("<!-- end genre list -->")) {
sw.WriteLine(line);
inGenres = false;
}
} else {
sw.WriteLine(line);
if (line.Contains("<!-- begin genre list -->")) {
foreach (var genre in genres) {
sw.WriteLine(
@$" <li hx-get='/partial/genre/{genre}.html'
hx-push-url='/genre/{genre}'
hx-target='main'
hx-swap='innerHTML show:top'>{DisplayTag(genre)}</li>");
}
inGenres = true;
}
}
}
}
private void BuildSite() {
Batteries.Init(); // initialize sqlite lib
// generate main series list
var seriesList = GetSeries();
GenerateSeriesListFragment(
seriesList, Path.Combine(OUTPUT_DIR, "series.html"), "All Series");
// inject genre list
var tags = GetTags();
InjectGenreList(tags);
// generate genre series lists
foreach (var tag in tags) {
var path = Path.Combine(OUTPUT_DIR, "genre", $"{tag}.html");
GenerateSeriesListFragment(seriesList.Where(s => s.Tags.Any(t => t == tag))
.OrderBy(s => s.TitleSort), path, $"{DisplayTag(tag)} Series");
}
// generate series details
foreach (var series in seriesList) {
GenerateSeriesDetailsFragment(series);
}
}
BuildSite();