#!/usr/bin/env python3 "Creates a static site for redacted.life" import argparse from collections import UserList from datetime import datetime import json import os import os.path as path import re import subprocess import shutil import sys import jinja2 import markdown from mutagen.mp3 import MP3 def gen_name(date, slug): "Returns to file name" return date.strftime("%Y-%m-%d-") + slug def seconds_to_str(seconds): "Convert seconds to a string of hh:mm:ss" seconds = int(seconds) return f"{seconds // 3600:02}:{(seconds % 3600) // 60:02}:{seconds % 60:02}" class EpisodeList(UserList): "Represents list of episodes" def __init__(self, url, data, output, template, archives): super().__init__(data) self.url = url self.output = output self.template = template self.archives = archives def sort(self, *_args, **_kwargs): "Sorts the EpisodeList" super().sort(key=lambda x: x.date, reverse=False) def generate_thumbnails(self): "Generates thumbnails for all the videos" if not path.isdir(self.output + "assets"): os.mkdir(self.output + "assets") if not path.isdir(self.output + "assets/thumbnails"): os.mkdir(self.output + "assets/thumbnails") for episode in self.data: location = (self.output + "assets/thumbnails/" + gen_name(episode.date, episode.slug) + ".jpg") episode.store_thumbnail(location) def generate_rss(self, header): "Generates the RSS Feed" with open(self.output + "feed_mp3.rss", "w") as mp3, \ open(self.output + "feed_ogg.rss", "w") as ogg: # pylint: disable = invalid-name for s, ext in ((mp3, "mp3"), (ogg, "ogg")): s.write(header) for ep in self.data: s.write("") s.write("\n") # Title s.write(f"<![CDATA[{ep.title}]]>") s.write("\n") # Description s.write("") s.write("\n") # Date datestring = ep.date.strftime( '%a, %d %b, %Y %H:%M:%Sz GMT' ) s.write(f"{datestring}") s.write("\n") # iTunes: explicit, author, subtitle, keywords s.write(f"{ep.config['explicit']}" "") s.write("\n") s.write( f"" "" ) s.write("\n") s.write( "" ) s.write("\n") s.write( f"{','.join(ep.config['tags'])}" "" ) s.write("\n") s.write(f"{seconds_to_str(len(ep))}" "") s.write("\n") # Content (show_notes) s.write( f"" "" ) s.write("\n") # GUID s.write( f"{self.url}{ep.slug}" ".html" ) s.write("\n") # Enclosure audio = f'{self.url}assets/music/{ep.slug}.{ext}' size = path.getsize(f"{ep.audio}.{ext}") s.write( f'' ) s.write("\n") # Categories for tag in ep.config["tags"]: s.write(f"") s.write("\n") s.write("") s.write("\n") s.write("") s.write("\n") s.write("") def generate_archives(self): "Generates archives page" with open(self.output + "archives.html", "w") as file: episodes = [{ "slug": gen_name(i.date, i.slug) + ".html", "title": i.title } for i in self.data[::-1]] file.write(self.archives.render(episodes=episodes, title="Archives")) def generate_site(self, root): "Generates the entire site" # Generate CSS from SCSS subprocess.run(["sass", "--update", f"{root}scss:{root}assets/css"], check=True) # Copy the existing assets shutil.copytree(root + "assets", self.output + "assets", dirs_exist_ok=True) # Create the required directories paths = [ "assets", "assets/audio", "assets/videos", ] for directory in paths: if not path.isdir(self.output + directory): os.mkdir(self.output + directory) # Render episodes and copy data for episode in self.data: html = f"{self.output}{gen_name(episode.date, episode.slug)}.html" thumbnail = ("assets/thumbnails/" + gen_name(episode.date, episode.slug) + ".jpg") video = (self.output + "assets/videos/" + gen_name(episode.date, episode.slug) + ".mp4") audio = (self.output + "assets/audio/" + gen_name(episode.date, episode.slug) + ".mp3") shutil.copy2(episode.video, video) shutil.copy2(episode.audio + ".mp3", audio) shutil.copy2(episode.audio + ".ogg", audio) with open(html, "w") as file: file.write(episode.render(self.template, thumbnail)) last = self.data[-1] last_name = f"{self.output}{gen_name(last.date, last.slug)}.html" shutil.copy2(last_name, self.output + "index.html") class Episode: "Represents one episode of podcast" def __init__(self, date, slug, title, show_notes, video_src, audio_src, config): self.date = date self.slug = slug self.title = title.strip() self.show_notes = markdown.markdown(show_notes) self.video = video_src self.audio = audio_src self.config = config self.length = MP3(audio_src + ".mp3").info.length def render(self, template, thumbnail_src): "Renders the Episode with the given template" return template.render( title=self.title, show_notes=jinja2.Markup(self.show_notes), thumbnail_src=thumbnail_src, video_src=f"assets/videos/{path.basename(self.video)}" ) def store_thumbnail(self, location): "Stores the thumbnail for given image at path" args = ["ffmpeg", "-i", self.video, "-ss", "00:00:01.000", "-vframes", "1", location] subprocess.run(args, check=False) def __len__(self): return int(self.length) def parse_args(): "Parses arguments" parser = argparse.ArgumentParser() parser.add_argument("input_dir", help="Input directory") parser.add_argument("output_dir", help="Output directory") parser.add_argument("url", help="Base URL of website") args = parser.parse_args() input_dir = args.input_dir.rstrip("/") + "/" output_dir = args.output_dir.rstrip("/") + "/" url = args.url.rstrip("/") + "/" return input_dir, output_dir, url class ParseError(ValueError): "Error raised while parsing a file" def parse_file(file, array_keys=("tags")): "Parses a file" config = {} kv_re = re.compile(r"(?P\w+):\s*(?P.*)") while line := file.readline(): if line.rstrip("\n") == "---": break if line.strip() == "": continue if match := kv_re.match(line): if match.group("key").strip().lower() in array_keys: config[match.group("key")] = [i.strip() for i in match.group("value").split(",")] else: config[match.group("key")] = match.group("value") else: raise ParseError(f"Invalid line {line}") return (config, file.read()) def main(args): "Main method" root = path.dirname(sys.argv[0]).rstrip("/") + "/" input_dir, output_dir, url = args # Input validation paths = [ input_dir, input_dir + "md", input_dir + "videos", input_dir + "audio", ] if not all(path.isdir((fail := i)) for i in paths): print(f"Invalid Input. {fail} is not a directory.", file=sys.stderr) return 1 if not path.isdir(output_dir): os.mkdir(output_dir) env = jinja2.Environment( loader=jinja2.FileSystemLoader(root), autoescape=jinja2.select_autoescape("html") ) podcast = EpisodeList( url, [], output_dir, env.get_template("index.html"), env.get_template("archives.html") ) split = re.compile(r"((?P\d{4}-[01]?\d-[0123]?\d)-(?P.*).md)") for file in os.listdir(input_dir + "md"): match = split.match(file) if not match: print(f"Invalid filename: {file}", file=sys.stderr) continue date = datetime.strptime(match.group("date"), "%Y-%M-%d") slug = match.group("slug") with open(input_dir + "md/" + file) as episode: try: config, show_notes = parse_file(episode) except ParseError as err: print(f"Error while parsing file: {file}") print(err) return 2 podcast.append( Episode( date, slug, config["title"], show_notes, input_dir + "videos/" + gen_name(date, slug) + ".mp4", input_dir + "audio/" + gen_name(date, slug), config ) ) with open(input_dir + "subscribe.json") as subscribe, \ open(output_dir + "subscribe.html", "w") as html: html.write(env.get_template("subscribe.html").render( subscribtions=json.load(subscribe) )) with open(input_dir + "donate.json") as donate, \ open(output_dir + "donate.html", "w") as html: html.write(env.get_template("donate.html").render( donations=json.load(donate) )) podcast.sort() podcast.generate_thumbnails() podcast.generate_archives() with open(input_dir + "header.rss") as header: podcast.generate_rss(header.read()) podcast.generate_site(root) shutil.copytree(input_dir + "overrides", output_dir, dirs_exist_ok=True) return 0 if __name__ == "__main__": sys.exit(main(parse_args()))