391 lines
12 KiB
C#
Executable File
391 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.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 YearRange {
|
|
get => MinYear == MaxYear
|
|
? $"{MinYear}"
|
|
: $"{MinYear}-{MaxYear}";
|
|
}
|
|
|
|
public string SlugEncoded {
|
|
get => WebUtility.HtmlEncode(Slug);
|
|
}
|
|
|
|
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 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 SlugEncoded {
|
|
get => WebUtility.HtmlEncode(Slug);
|
|
}
|
|
|
|
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";
|
|
}
|
|
}
|
|
|
|
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(string seriesSlug) {
|
|
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 = seriesSlug;
|
|
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),
|
|
});
|
|
}
|
|
|
|
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'>
|
|
<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.Slug)) {
|
|
sw.Write(
|
|
@$"<li class='episode' title='{episode.TitleEncoded}'
|
|
data-cover='/cover/sm/{series.SlugEncoded}.jpg'
|
|
data-series='{series.TitleEncoded}'
|
|
data-file='{episode.FileNameEncoded}'>
|
|
<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();
|