/* spod - static podcast page generator */ /* like stagit, but for podcasts */ #define _XOPEN_SOURCE 700 #include #include #include #include #include #include #include #include #include #include #include #include #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("\n\n\n" "\n" "\n" "", fp); xmlencode(fp, p->name); if (p->desc[0]) { fputs(" - ", fp); xmlencode(fp, p->desc); } fputs("\n" "\n" "\n\n", fp); /* top bar: logo + name + desc */ fputs("
", fp); if (favicon[0]) { fputs("" "\"\"" "", fp); } fputs("

", fp); xmlencode(fp, p->name); fputs("

", fp); xmlencode(fp, p->desc); fputs("
\n", fp); /* nav links: Log | Episodes | README | LICENSE */ fputs("Log | \n", fp); fputs("Episodes", fp); if (p->hasreadme) fputs(" | \nREADME", fp); if (p->haslicense) fputs(" | \nLICENSE", fp); fputs("\n
\n
\n
\n", fp); (void)page; } static void writefooter(FILE *fp) { fputs("
\n\n\n", fp); } /* index page: like stagit-index */ static void writeindexheader(FILE *fp) { fputs("\n\n\n" "\n" "\n" "", fp); xmlencode(fp, sitetitle); fputs("\n" "\n" "\n\n", fp); fputs("\n" "\n" "
", fp); if (favicon[0]) { fputs("\"\"", fp); } fputs("", fp); xmlencode(fp, sitetitle); if (sitedesc[0]) { fputs(" - ", fp); xmlencode(fp, sitedesc); } fputs("
\n
\n
\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("\n" "" "" "" "" "\n\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("\n", fp); } fputs("\n
NameDescriptionOwnerLast commit
", fp); xmlencode(fp, pods[i].name); fputs("", fp); xmlencode(fp, pods[i].desc); fputs("", fp); if (siteowner[0]) xmlencode(fp, siteowner); fputs("", fp); if (latest) xmlencode(fp, timebuf); fputs("
\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("
\ncover); fputs("\" alt=\"cover\" />\n
\n", fp); } fputs("\n" "" "" "" "\n\n", fp); for (i = 0; i < p->neps; i++) { formattime(timebuf, sizeof(timebuf), p->eps[i].mtime); fputs("\n", fp); } fputs("\n
DateEpisodeSize
", fp); xmlencode(fp, timebuf); fputs("eps[i].path); fputs("\">", fp); xmlencode(fp, p->eps[i].name); fputs("", fp); fputs(humansize(p->eps[i].size), fp); fputs("
\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("\n" "" "" "" "\n\n", fp); for (i = 0; i < p->neps; i++) { formattime(timebuf, sizeof(timebuf), p->eps[i].mtime); fputs("\n", fp); } /* also list README and LICENSE as files */ if (p->hasreadme) { fputs("" "" "\n", fp); } if (p->haslicense) { fputs("" "" "\n", fp); } if (p->cover[0]) { fputs("" "\n", fp); } fputs("\n
ModeNameSize
-rw-r--r--eps[i].path); fputs("\">", fp); xmlencode(fp, p->eps[i].path); fputs("", fp); fputs(humansize(p->eps[i].size), fp); fputs("
-rw-r--r--README
-rw-r--r--LICENSE
-rw-r--r--", fp); xmlencode(fp, p->cover); fputs("
\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("
", fp);
	xmlencode(fp, p->readme);
	fputs("
\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("
", fp);
	xmlencode(fp, p->license);
	fputs("
\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 [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; }