@@ 1,884 @@
+/* spod - static podcast page generator */
+/* like stagit, but for podcasts */
+
+#define _XOPEN_SOURCE 700
+
+#include <sys/stat.h>
+#include <sys/types.h>
+
+#include <dirent.h>
+#include <errno.h>
+#include <limits.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "config.h"
+
+#define LEN(s) (sizeof(s)/sizeof(*(s)))
+
+struct episode {
+ char name[PATH_MAX]; /* filename without extension */
+ char path[PATH_MAX]; /* relative path to file */
+ off_t size; /* file size in bytes */
+ time_t mtime; /* modification time */
+};
+
+struct podcast {
+ char name[PATH_MAX];
+ char dir[PATH_MAX];
+ char desc[4096]; /* description (2nd line+ of README) */
+ char readme[65536];
+ char license[65536];
+ char cover[PATH_MAX]; /* relative path to cover image */
+ char coverext[16]; /* image extension */
+ int hasreadme;
+ int haslicense;
+ struct episode *eps;
+ size_t neps;
+ size_t capsz;
+};
+
+static char outdir[PATH_MAX];
+
+static void
+die(const char *fmt, ...)
+{
+ va_list ap;
+
+ va_start(ap, fmt);
+ vfprintf(stderr, fmt, ap);
+ va_end(ap);
+ fputc('\n', stderr);
+ exit(1);
+}
+
+static void
+checkfileerror(FILE *fp, const char *name, int mode)
+{
+ if (mode == 'r' && ferror(fp))
+ die("read error: %s", name);
+ else if (mode == 'w' && (fflush(fp) || ferror(fp)))
+ die("write error: %s", name);
+}
+
+static void
+xmlencode(FILE *fp, const char *s)
+{
+ for (; *s; s++) {
+ switch (*s) {
+ case '<': fputs("<", fp); break;
+ case '>': fputs(">", fp); break;
+ case '\'': fputs("'", fp); break;
+ case '&': fputs("&", fp); break;
+ case '"': fputs(""", fp); break;
+ default: fputc(*s, fp);
+ }
+ }
+}
+
+static const char *
+humansize(off_t bytes)
+{
+ static char buf[32];
+ double s = bytes;
+
+ if (s < 1024.0)
+ snprintf(buf, sizeof(buf), "%.0fB", s);
+ else if (s < 1048576.0)
+ snprintf(buf, sizeof(buf), "%.1fKB", s / 1024.0);
+ else if (s < 1073741824.0)
+ snprintf(buf, sizeof(buf), "%.1fMB", s / 1048576.0);
+ else
+ snprintf(buf, sizeof(buf), "%.1fGB", s / 1073741824.0);
+
+ return buf;
+}
+
+static void
+formattime(char *buf, size_t bufsz, time_t t)
+{
+ struct tm *tm;
+
+ tm = localtime(&t);
+ if (tm)
+ strftime(buf, bufsz, "%Y-%m-%d %H:%M", tm);
+ else
+ buf[0] = '\0';
+}
+
+static void
+formattimeshort(char *buf, size_t bufsz, time_t t)
+{
+ struct tm *tm;
+
+ tm = localtime(&t);
+ if (tm)
+ strftime(buf, bufsz, "%Y-%m-%d", tm);
+ else
+ buf[0] = '\0';
+}
+
+static int
+mkdirp(const char *path)
+{
+ char tmp[PATH_MAX];
+ char *p;
+ size_t len;
+
+ snprintf(tmp, sizeof(tmp), "%s", path);
+ len = strlen(tmp);
+ if (len > 0 && tmp[len - 1] == '/')
+ tmp[len - 1] = '\0';
+
+ for (p = tmp + 1; *p; p++) {
+ if (*p == '/') {
+ *p = '\0';
+ if (mkdir(tmp, 0755) < 0 && errno != EEXIST)
+ return -1;
+ *p = '/';
+ }
+ }
+ if (mkdir(tmp, 0755) < 0 && errno != EEXIST)
+ return -1;
+
+ return 0;
+}
+
+static int
+isimgext(const char *ext)
+{
+ return (!strcmp(ext, ".jpg") || !strcmp(ext, ".jpeg") ||
+ !strcmp(ext, ".png") || !strcmp(ext, ".gif") ||
+ !strcmp(ext, ".webp") || !strcmp(ext, ".svg") ||
+ !strcmp(ext, ".bmp") || !strcmp(ext, ".ico"));
+}
+
+static int
+ismediaext(const char *ext)
+{
+ return (!strcmp(ext, ".mp3") || !strcmp(ext, ".ogg") ||
+ !strcmp(ext, ".opus") || !strcmp(ext, ".m4a") ||
+ !strcmp(ext, ".flac") || !strcmp(ext, ".wav") ||
+ !strcmp(ext, ".mp4") || !strcmp(ext, ".mkv") ||
+ !strcmp(ext, ".webm"));
+}
+
+static int
+epcmp(const void *a, const void *b)
+{
+ const struct episode *ea = a, *eb = b;
+
+ /* newest first */
+ if (ea->mtime > eb->mtime)
+ return -1;
+ if (ea->mtime < eb->mtime)
+ return 1;
+ return strcmp(ea->name, eb->name);
+}
+
+static void
+ep_add(struct podcast *p, const char *fname, const char *relpath,
+ off_t size, time_t mtime)
+{
+ char *dot;
+
+ if (p->neps >= p->capsz) {
+ p->capsz = p->capsz ? p->capsz * 2 : 16;
+ p->eps = realloc(p->eps, p->capsz * sizeof(*p->eps));
+ if (!p->eps)
+ die("realloc");
+ }
+ snprintf(p->eps[p->neps].path, sizeof(p->eps[p->neps].path),
+ "%s", relpath);
+ snprintf(p->eps[p->neps].name, sizeof(p->eps[p->neps].name),
+ "%s", fname);
+ dot = strrchr(p->eps[p->neps].name, '.');
+ if (dot)
+ *dot = '\0';
+ p->eps[p->neps].size = size;
+ p->eps[p->neps].mtime = mtime;
+ p->neps++;
+}
+
+static void
+readfile(const char *path, char *buf, size_t bufsz)
+{
+ FILE *fp;
+ size_t n;
+
+ fp = fopen(path, "r");
+ if (!fp) {
+ buf[0] = '\0';
+ return;
+ }
+ n = fread(buf, 1, bufsz - 1, fp);
+ buf[n] = '\0';
+ fclose(fp);
+}
+
+/* extract description: skip first line (heading), take rest */
+static void
+extractdesc(const char *readme, char *desc, size_t descsz)
+{
+ const char *p;
+
+ desc[0] = '\0';
+ if (!readme[0])
+ return;
+
+ /* skip first line */
+ p = strchr(readme, '\n');
+ if (!p || !*(p + 1))
+ return;
+ p++;
+
+ /* skip blank lines after heading */
+ while (*p == '\n' || *p == '\r')
+ p++;
+
+ if (!*p)
+ return;
+
+ snprintf(desc, descsz, "%s", p);
+
+ /* truncate at first newline for index use */
+ {
+ char *nl = strchr(desc, '\n');
+ if (nl)
+ *nl = '\0';
+ }
+}
+
+static void
+podcast_scan(struct podcast *p, const char *dir)
+{
+ DIR *dp;
+ struct dirent *de;
+ struct stat st;
+ char path[PATH_MAX], *ext;
+
+ snprintf(p->dir, sizeof(p->dir), "%s", dir);
+
+ /* extract podcast name from directory basename */
+ {
+ const char *base = strrchr(dir, '/');
+ base = base ? base + 1 : dir;
+ snprintf(p->name, sizeof(p->name), "%s", base);
+ }
+
+ dp = opendir(dir);
+ if (!dp)
+ die("opendir: %s: %s", dir, strerror(errno));
+
+ p->readme[0] = '\0';
+ p->license[0] = '\0';
+ p->desc[0] = '\0';
+ p->cover[0] = '\0';
+ p->coverext[0] = '\0';
+ p->hasreadme = 0;
+ p->haslicense = 0;
+ p->eps = NULL;
+ p->neps = 0;
+ p->capsz = 0;
+
+ while ((de = readdir(dp))) {
+ if (de->d_name[0] == '.')
+ continue;
+
+ snprintf(path, sizeof(path), "%s/%s", dir, de->d_name);
+ if (stat(path, &st) < 0)
+ continue;
+ if (!S_ISREG(st.st_mode))
+ continue;
+
+ /* README */
+ if (!strcmp(de->d_name, "README") ||
+ !strcmp(de->d_name, "README.md") ||
+ !strcmp(de->d_name, "README.txt")) {
+ readfile(path, p->readme, sizeof(p->readme));
+ p->hasreadme = 1;
+ extractdesc(p->readme, p->desc, sizeof(p->desc));
+ continue;
+ }
+
+ /* LICENSE */
+ if (!strcmp(de->d_name, "LICENSE") ||
+ !strcmp(de->d_name, "LICENSE.md") ||
+ !strcmp(de->d_name, "LICENSE.txt") ||
+ !strcmp(de->d_name, "COPYING")) {
+ readfile(path, p->license, sizeof(p->license));
+ p->haslicense = 1;
+ continue;
+ }
+
+ ext = strrchr(de->d_name, '.');
+ if (!ext)
+ continue;
+
+ /* cover image */
+ if (isimgext(ext) &&
+ (!strncasecmp(de->d_name, "COVER", 5) ||
+ !strncasecmp(de->d_name, "cover", 5) ||
+ !strncasecmp(de->d_name, "artwork", 7) ||
+ !strncasecmp(de->d_name, "poster", 6) ||
+ !strncasecmp(de->d_name, "logo", 4))) {
+ snprintf(p->cover, sizeof(p->cover), "%s", de->d_name);
+ snprintf(p->coverext, sizeof(p->coverext), "%s", ext);
+ continue;
+ }
+
+ /* media files (audio + video) */
+ if (ismediaext(ext))
+ ep_add(p, de->d_name, de->d_name, st.st_size,
+ st.st_mtime);
+ }
+
+ closedir(dp);
+ qsort(p->eps, p->neps, sizeof(*p->eps), epcmp);
+}
+
+/* write header exactly like stagit */
+static void
+writeheader(FILE *fp, const struct podcast *p, const char *page)
+{
+ fputs("<!DOCTYPE html>\n<html>\n<head>\n"
+ "<meta http-equiv=\"Content-Type\""
+ " content=\"text/html; charset=UTF-8\" />\n"
+ "<meta name=\"viewport\""
+ " content=\"width=device-width, initial-scale=1\" />\n"
+ "<title>", fp);
+ xmlencode(fp, p->name);
+ if (p->desc[0]) {
+ fputs(" - ", fp);
+ xmlencode(fp, p->desc);
+ }
+ fputs("</title>\n"
+ "<link rel=\"stylesheet\" type=\"text/css\""
+ " href=\"style.css\" />\n"
+ "</head>\n<body>\n", fp);
+
+ /* top bar: logo + name + desc */
+ fputs("<table><tr><td>", fp);
+ if (favicon[0]) {
+ fputs("<a href=\"../index.html\">"
+ "<img src=\"", fp);
+ xmlencode(fp, favicon);
+ fputs("\" alt=\"\" width=\"32\" height=\"32\" />"
+ "</a>", fp);
+ }
+ fputs("</td><td><h1>", fp);
+ xmlencode(fp, p->name);
+ fputs("</h1><span class=\"desc\">", fp);
+ xmlencode(fp, p->desc);
+ fputs("</span></td></tr><tr><td></td><td>\n", fp);
+
+ /* nav links: Log | Episodes | README | LICENSE */
+ fputs("<a href=\"log.html\">Log</a> | \n", fp);
+ fputs("<a href=\"episodes.html\">Episodes</a>", fp);
+ if (p->hasreadme)
+ fputs(" | \n<a href=\"readme.html\">README</a>", fp);
+ if (p->haslicense)
+ fputs(" | \n<a href=\"license.html\">LICENSE</a>", fp);
+
+ fputs("\n</td></tr></table>\n<hr/>\n<div id=\"content\">\n", fp);
+
+ (void)page;
+}
+
+static void
+writefooter(FILE *fp)
+{
+ fputs("</div>\n</body>\n</html>\n", fp);
+}
+
+/* index page: like stagit-index */
+static void
+writeindexheader(FILE *fp)
+{
+ fputs("<!DOCTYPE html>\n<html>\n<head>\n"
+ "<meta http-equiv=\"Content-Type\""
+ " content=\"text/html; charset=UTF-8\" />\n"
+ "<meta name=\"viewport\""
+ " content=\"width=device-width, initial-scale=1\" />\n"
+ "<title>", fp);
+ xmlencode(fp, sitetitle);
+ fputs("</title>\n"
+ "<link rel=\"stylesheet\" type=\"text/css\""
+ " href=\"style.css\" />\n"
+ "</head>\n<body>\n", fp);
+
+ fputs("<table><tr><td>", fp);
+ if (favicon[0]) {
+ fputs("<img src=\"", fp);
+ xmlencode(fp, favicon);
+ fputs("\" alt=\"\" width=\"32\" height=\"32\" />", fp);
+ }
+ fputs("</td><td><span class=\"desc\">", fp);
+ xmlencode(fp, sitetitle);
+ if (sitedesc[0]) {
+ fputs(" - ", fp);
+ xmlencode(fp, sitedesc);
+ }
+ fputs("</span></td></tr>\n"
+ "<tr><td></td><td></td></tr>\n"
+ "</table>\n<hr/>\n<div id=\"content\">\n", fp);
+}
+
+static void
+writeindex(struct podcast *pods, size_t npods)
+{
+ FILE *fp;
+ char path[PATH_MAX], timebuf[64];
+ size_t i;
+ time_t latest;
+
+ snprintf(path, sizeof(path), "%s/index.html", outdir);
+ fp = fopen(path, "w");
+ if (!fp)
+ die("fopen: %s: %s", path, strerror(errno));
+
+ writeindexheader(fp);
+
+ fputs("<table id=\"index\"><thead>\n<tr>"
+ "<td><b>Name</b></td>"
+ "<td><b>Description</b></td>"
+ "<td><b>Owner</b></td>"
+ "<td><b>Last commit</b></td>"
+ "</tr>\n</thead><tbody>\n", fp);
+
+ for (i = 0; i < npods; i++) {
+ latest = 0;
+ if (pods[i].neps > 0)
+ latest = pods[i].eps[0].mtime;
+
+ formattimeshort(timebuf, sizeof(timebuf), latest);
+
+ fputs("<tr><td><a href=\"", fp);
+ xmlencode(fp, pods[i].name);
+ fputs("/log.html\">", fp);
+ xmlencode(fp, pods[i].name);
+ fputs("</a></td><td>", fp);
+ xmlencode(fp, pods[i].desc);
+ fputs("</td><td>", fp);
+ if (siteowner[0])
+ xmlencode(fp, siteowner);
+ fputs("</td><td>", fp);
+ if (latest)
+ xmlencode(fp, timebuf);
+ fputs("</td></tr>\n", fp);
+ }
+
+ fputs("</tbody>\n</table>\n", fp);
+ writefooter(fp);
+ checkfileerror(fp, path, 'w');
+ fclose(fp);
+}
+
+static void
+copyfile(const char *src, const char *dst)
+{
+ FILE *in, *out;
+ char buf[8192];
+ size_t n;
+
+ in = fopen(src, "rb");
+ if (!in)
+ return;
+
+ out = fopen(dst, "wb");
+ if (!out) {
+ fclose(in);
+ return;
+ }
+
+ while ((n = fread(buf, 1, sizeof(buf), in)) > 0)
+ fwrite(buf, 1, n, out);
+
+ fclose(in);
+ fclose(out);
+}
+
+/* log.html — like stagit's log page */
+static void
+writelog(const struct podcast *p, const char *poddir)
+{
+ FILE *fp;
+ char path[PATH_MAX], timebuf[64];
+ size_t i;
+
+ snprintf(path, sizeof(path), "%s/log.html", poddir);
+ fp = fopen(path, "w");
+ if (!fp)
+ die("fopen: %s: %s", path, strerror(errno));
+
+ writeheader(fp, p, "log");
+
+ /* cover image at top */
+ if (p->cover[0]) {
+ fputs("<div id=\"cover\">\n<img src=\"", fp);
+ xmlencode(fp, p->cover);
+ fputs("\" alt=\"cover\" />\n</div>\n", fp);
+ }
+
+ fputs("<table id=\"log\"><thead>\n<tr>"
+ "<td><b>Date</b></td>"
+ "<td><b>Episode</b></td>"
+ "<td class=\"num\" align=\"right\"><b>Size</b></td>"
+ "</tr>\n</thead><tbody>\n", fp);
+
+ for (i = 0; i < p->neps; i++) {
+ formattime(timebuf, sizeof(timebuf), p->eps[i].mtime);
+ fputs("<tr><td>", fp);
+ xmlencode(fp, timebuf);
+ fputs("</td><td><a href=\"", fp);
+ xmlencode(fp, p->eps[i].path);
+ fputs("\">", fp);
+ xmlencode(fp, p->eps[i].name);
+ fputs("</a></td><td class=\"num\" align=\"right\">", fp);
+ fputs(humansize(p->eps[i].size), fp);
+ fputs("</td></tr>\n", fp);
+ }
+
+ fputs("</tbody>\n</table>\n", fp);
+ writefooter(fp);
+ checkfileerror(fp, path, 'w');
+ fclose(fp);
+}
+
+/* episodes.html — like stagit's files page */
+static void
+writeepisodes(const struct podcast *p, const char *poddir)
+{
+ FILE *fp;
+ char path[PATH_MAX], timebuf[64];
+ size_t i;
+
+ snprintf(path, sizeof(path), "%s/episodes.html", poddir);
+ fp = fopen(path, "w");
+ if (!fp)
+ die("fopen: %s: %s", path, strerror(errno));
+
+ writeheader(fp, p, "episodes");
+
+ fputs("<table id=\"files\"><thead>\n<tr>"
+ "<td><b>Mode</b></td>"
+ "<td><b>Name</b></td>"
+ "<td class=\"num\" align=\"right\"><b>Size</b></td>"
+ "</tr>\n</thead><tbody>\n", fp);
+
+ for (i = 0; i < p->neps; i++) {
+ formattime(timebuf, sizeof(timebuf), p->eps[i].mtime);
+ fputs("<tr><td>-rw-r--r--</td><td><a href=\"", fp);
+ xmlencode(fp, p->eps[i].path);
+ fputs("\">", fp);
+ xmlencode(fp, p->eps[i].path);
+ fputs("</a></td><td class=\"num\" align=\"right\">", fp);
+ fputs(humansize(p->eps[i].size), fp);
+ fputs("</td></tr>\n", fp);
+ }
+
+ /* also list README and LICENSE as files */
+ if (p->hasreadme) {
+ fputs("<tr><td>-rw-r--r--</td>"
+ "<td><a href=\"readme.html\">README</a></td>"
+ "<td class=\"num\" align=\"right\"></td></tr>\n", fp);
+ }
+ if (p->haslicense) {
+ fputs("<tr><td>-rw-r--r--</td>"
+ "<td><a href=\"license.html\">LICENSE</a></td>"
+ "<td class=\"num\" align=\"right\"></td></tr>\n", fp);
+ }
+ if (p->cover[0]) {
+ fputs("<tr><td>-rw-r--r--</td><td>", fp);
+ xmlencode(fp, p->cover);
+ fputs("</td><td class=\"num\" align=\"right\"></td>"
+ "</tr>\n", fp);
+ }
+
+ fputs("</tbody>\n</table>\n", fp);
+ writefooter(fp);
+ checkfileerror(fp, path, 'w');
+ fclose(fp);
+}
+
+/* readme.html */
+static void
+writereadme(const struct podcast *p, const char *poddir)
+{
+ FILE *fp;
+ char path[PATH_MAX];
+
+ if (!p->hasreadme)
+ return;
+
+ snprintf(path, sizeof(path), "%s/readme.html", poddir);
+ fp = fopen(path, "w");
+ if (!fp)
+ die("fopen: %s: %s", path, strerror(errno));
+
+ writeheader(fp, p, "readme");
+ fputs("<pre id=\"readme\">", fp);
+ xmlencode(fp, p->readme);
+ fputs("</pre>\n", fp);
+ writefooter(fp);
+ checkfileerror(fp, path, 'w');
+ fclose(fp);
+}
+
+/* license.html */
+static void
+writelicense(const struct podcast *p, const char *poddir)
+{
+ FILE *fp;
+ char path[PATH_MAX];
+
+ if (!p->haslicense)
+ return;
+
+ snprintf(path, sizeof(path), "%s/license.html", poddir);
+ fp = fopen(path, "w");
+ if (!fp)
+ die("fopen: %s: %s", path, strerror(errno));
+
+ writeheader(fp, p, "license");
+ fputs("<pre id=\"license\">", fp);
+ xmlencode(fp, p->license);
+ fputs("</pre>\n", fp);
+ writefooter(fp);
+ checkfileerror(fp, path, 'w');
+ fclose(fp);
+}
+
+static void
+writepodcast(const struct podcast *p)
+{
+ char poddir[PATH_MAX];
+ size_t i;
+
+ snprintf(poddir, sizeof(poddir), "%s/%s", outdir, p->name);
+ if (mkdirp(poddir) < 0)
+ die("mkdirp: %s: %s", poddir, strerror(errno));
+
+ /* symlink media files instead of copying */
+ for (i = 0; i < p->neps; i++) {
+ char src[PATH_MAX], dst[PATH_MAX], abssrc[PATH_MAX];
+
+ snprintf(src, sizeof(src), "%s/%s",
+ p->dir, p->eps[i].path);
+ snprintf(dst, sizeof(dst), "%s/%s",
+ poddir, p->eps[i].path);
+
+ if (!realpath(src, abssrc))
+ die("realpath: %s: %s", src, strerror(errno));
+
+ unlink(dst);
+ if (symlink(abssrc, dst) < 0)
+ die("symlink: %s -> %s: %s", dst, abssrc,
+ strerror(errno));
+ }
+
+ /* copy cover image */
+ if (p->cover[0]) {
+ char src[PATH_MAX], dst[PATH_MAX];
+ snprintf(src, sizeof(src), "%s/%s", p->dir, p->cover);
+ snprintf(dst, sizeof(dst), "%s/%s", poddir, p->cover);
+ copyfile(src, dst);
+ }
+
+ /* generate pages */
+ writelog(p, poddir);
+ writeepisodes(p, poddir);
+ writereadme(p, poddir);
+ writelicense(p, poddir);
+}
+
+static void
+writestyle(void)
+{
+ FILE *fp;
+ char path[PATH_MAX];
+
+ snprintf(path, sizeof(path), "%s/style.css", outdir);
+ fp = fopen(path, "w");
+ if (!fp)
+ die("fopen: %s: %s", path, strerror(errno));
+
+ fputs(
+ "body {\n"
+ "\tcolor: #000;\n"
+ "\tbackground-color: #fff;\n"
+ "\tfont-family: monospace;\n"
+ "}\n"
+ "h1, h2, h3, h4, h5, h6 {\n"
+ "\tfont-size: 1em;\n"
+ "\tmargin: 0;\n"
+ "}\n"
+ "img, h1, h2 {\n"
+ "\tvertical-align: middle;\n"
+ "}\n"
+ "img {\n"
+ "\tborder: 0;\n"
+ "}\n"
+ "#cover img {\n"
+ "\tmax-width: 200px;\n"
+ "\tmax-height: 200px;\n"
+ "\tmargin: 0.5em 0;\n"
+ "}\n"
+ "a {\n"
+ "\tcolor: #00e;\n"
+ "\ttext-decoration: none;\n"
+ "}\n"
+ "a:hover {\n"
+ "\ttext-decoration: underline;\n"
+ "}\n"
+ "table thead td {\n"
+ "\tfont-weight: bold;\n"
+ "}\n"
+ "table td {\n"
+ "\tpadding: 0 0.4em;\n"
+ "}\n"
+ "#content table td {\n"
+ "\tvertical-align: top;\n"
+ "\twhite-space: nowrap;\n"
+ "}\n"
+ "#index tr:hover td,\n"
+ "#log tr:hover td,\n"
+ "#files tr:hover td {\n"
+ "\tbackground-color: #eee;\n"
+ "}\n"
+ "#index tr td:nth-child(2),\n"
+ "#log tr td:nth-child(2) {\n"
+ "\twhite-space: normal;\n"
+ "}\n"
+ "td.num {\n"
+ "\ttext-align: right;\n"
+ "}\n"
+ ".desc {\n"
+ "\tcolor: #555;\n"
+ "}\n"
+ "hr {\n"
+ "\tborder: 0;\n"
+ "\tborder-top: 1px solid #555;\n"
+ "\theight: 1px;\n"
+ "}\n"
+ "pre {\n"
+ "\tfont-family: monospace;\n"
+ "}\n"
+ "pre#readme,\n"
+ "pre#license {\n"
+ "\twhite-space: pre-wrap;\n"
+ "\tword-wrap: break-word;\n"
+ "\tline-height: 1.5;\n"
+ "}\n"
+ "@media (prefers-color-scheme: dark) {\n"
+ "\tbody {\n"
+ "\t\tbackground-color: #000;\n"
+ "\t\tcolor: #bdbdbd;\n"
+ "\t}\n"
+ "\thr {\n"
+ "\t\tborder-color: #222;\n"
+ "\t}\n"
+ "\ta {\n"
+ "\t\tcolor: #56c8ff;\n"
+ "\t}\n"
+ "\t.desc {\n"
+ "\t\tcolor: #aaa;\n"
+ "\t}\n"
+ "\t#index tr:hover td,\n"
+ "\t#log tr:hover td,\n"
+ "\t#files tr:hover td {\n"
+ "\t\tbackground-color: #111;\n"
+ "\t}\n"
+ "}\n", fp);
+
+ checkfileerror(fp, path, 'w');
+ fclose(fp);
+}
+
+static int
+podcmp(const void *a, const void *b)
+{
+ const struct podcast *pa = a, *pb = b;
+
+ return strcmp(pa->name, pb->name);
+}
+
+int
+main(int argc, char *argv[])
+{
+ DIR *dp;
+ struct dirent *de;
+ struct stat st;
+ struct podcast *pods;
+ size_t npods, capsz;
+ char path[PATH_MAX];
+
+ if (argc < 2 || argc > 3) {
+ fprintf(stderr, "usage: %s <podcastdir> [outputdir]\n",
+ argv[0]);
+ return 1;
+ }
+
+ if (argc == 3)
+ snprintf(outdir, sizeof(outdir), "%s", argv[2]);
+ else
+ snprintf(outdir, sizeof(outdir), "out");
+
+ if (mkdirp(outdir) < 0)
+ die("mkdirp: %s: %s", outdir, strerror(errno));
+
+ dp = opendir(argv[1]);
+ if (!dp)
+ die("opendir: %s: %s", argv[1], strerror(errno));
+
+ pods = NULL;
+ npods = 0;
+ capsz = 0;
+
+ while ((de = readdir(dp))) {
+ if (de->d_name[0] == '.')
+ continue;
+
+ snprintf(path, sizeof(path), "%s/%s", argv[1], de->d_name);
+ if (stat(path, &st) < 0)
+ continue;
+ if (!S_ISDIR(st.st_mode))
+ continue;
+
+ if (npods >= capsz) {
+ capsz = capsz ? capsz * 2 : 8;
+ pods = realloc(pods, capsz * sizeof(*pods));
+ if (!pods)
+ die("realloc");
+ }
+
+ podcast_scan(&pods[npods], path);
+ npods++;
+ }
+
+ closedir(dp);
+
+ if (npods == 0)
+ die("no podcast directories found in %s", argv[1]);
+
+ qsort(pods, npods, sizeof(*pods), podcmp);
+
+ /* generate output */
+ writestyle();
+ writeindex(pods, npods);
+
+ for (size_t i = 0; i < npods; i++)
+ writepodcast(&pods[i]);
+
+ /* cleanup */
+ for (size_t i = 0; i < npods; i++)
+ free(pods[i].eps);
+ free(pods);
+
+ return 0;
+}