initial: modular iptv-dl with runtime config from ~/.iptv-downloader/config.json

This commit is contained in:
2026-06-09 00:31:08 +02:00
commit f8af224580
48 changed files with 2140 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
CC = gcc
CFLAGS = -O2 -Wall -Wextra -I. -pthread
LDFLAGS = -pthread -lcurl
TARGET = iptv-dl
SRCS = main.c config.c buf.c json.c http.c discord.c jellyfin.c \
iptv_api.c queue.c notify.c handlers.c server.c
OBJS = $(SRCS:.c=.o)
# Install paths (override with: make install PREFIX=/usr/local)
PREFIX = /usr/local
BINDIR = $(PREFIX)/bin
SHAREDIR = $(PREFIX)/share/iptv-dl
SYSCONFDIR = /etc/iptv-downloader
all: $(TARGET)
$(TARGET): $(OBJS)
$(CC) $(OBJS) $(LDFLAGS) -o $@
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
install: $(TARGET)
install -d $(BINDIR) $(SHAREDIR) $(SYSCONFDIR)
install -m 755 $(TARGET) $(BINDIR)/iptv-dl
install -m 644 static/iptv.css $(SHAREDIR)/iptv.css
install -m 644 static/iptv.js $(SHAREDIR)/iptv.js
install -m 644 static/downloads.js $(SHAREDIR)/downloads.js
install -m 644 static/series_show.js $(SHAREDIR)/series_show.js
install -m 644 static/header.html $(SHAREDIR)/header.html
install -m 644 static/footer.html $(SHAREDIR)/footer.html
@echo "Installed. Create config at $(SYSCONFDIR)/config.json or ~/.iptv-downloader/config.json"
@echo "Run: $(BINDIR)/iptv-dl --dump-config (to see defaults)"
clean:
rm -f $(OBJS) $(TARGET)
.PHONY: all install clean
Executable
+52
View File
@@ -0,0 +1,52 @@
#!/bin/sh
# alioth smoke tests — exits 1 if any check fails
FAIL=0
ok() { printf " PASS %s\n" "$1"; }
fail() { printf " FAIL %s\n" "$1"; FAIL=1; }
check() {
name="$1"; url="$2"; want="${3:-200}"
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$url")
if [ "$code" = "$want" ] || { [ "$want" = "2xx" ] && [ "${code%?}" = "20" ]; }; then
ok "$name ($code)"
else
fail "$name — got $code, want $want ($url)"
fi
}
echo "=== dashboard ==="
check "GET /" http://127.0.0.1:9090/
check "GET /style.css" http://127.0.0.1:9090/style.css
check "GET /app.js" http://127.0.0.1:9090/app.js
check "GET /api/stats" http://127.0.0.1:9090/api/stats
echo "=== iptv-dl ==="
check "GET /" http://127.0.0.1:8787/
check "GET /iptv.css" http://127.0.0.1:8787/iptv.css
check "GET /downloads" http://127.0.0.1:8787/downloads
check "GET /api/downloads" http://127.0.0.1:8787/api/downloads
check "GET /movies" http://127.0.0.1:8787/movies
check "GET /series" http://127.0.0.1:8787/series
echo "=== arr stack ==="
check "jellyfin" http://127.0.0.1:8096/jellyfin/health
check "sonarr" http://127.0.0.1:8989/sonarr/ping
check "radarr" http://127.0.0.1:7878/radarr/ping
check "prowlarr" http://127.0.0.1:9696/prowlarr/ping
check "qbit" http://127.0.0.1:8080/
echo "=== nginx proxy ==="
check "nginx /" http://127.0.0.1/
check "nginx /style.css" http://127.0.0.1/style.css
check "nginx /app.js" http://127.0.0.1/app.js
check "nginx /iptv/" http://127.0.0.1/iptv/
check "nginx /iptv/iptv.css" http://127.0.0.1/iptv/iptv.css
echo ""
if [ "$FAIL" -eq 0 ]; then
echo "ALL PASS"
else
echo "SOME FAILED"
fi
exit $FAIL
+28
View File
@@ -0,0 +1,28 @@
#include "buf.h"
#include <stdlib.h>
#include <string.h>
#include <stdarg.h>
#include <stdio.h>
void buf_init(Buf *b) {
b->data = malloc(4096); b->len = 0; b->cap = 4096; b->data[0] = 0;
}
void buf_free(Buf *b) {
free(b->data); b->data = NULL; b->len = 0; b->cap = 0;
}
void buf_append(Buf *b, const char *s, size_t n) {
while (b->len + n + 1 > b->cap) { b->cap *= 2; b->data = realloc(b->data, b->cap); }
memcpy(b->data + b->len, s, n);
b->len += n;
b->data[b->len] = 0;
}
void buf_str(Buf *b, const char *s) { buf_append(b, s, strlen(s)); }
void buf_fmt(Buf *b, const char *fmt, ...) {
char tmp[4096]; va_list ap; va_start(ap, fmt);
vsnprintf(tmp, sizeof(tmp), fmt, ap); va_end(ap);
buf_str(b, tmp);
}
+10
View File
@@ -0,0 +1,10 @@
#pragma once
#include <stddef.h>
typedef struct { char *data; size_t len; size_t cap; } Buf;
void buf_init(Buf *b);
void buf_free(Buf *b);
void buf_append(Buf *b, const char *s, size_t n);
void buf_str(Buf *b, const char *s);
void buf_fmt(Buf *b, const char *fmt, ...) __attribute__((format(printf,2,3)));
BIN
View File
Binary file not shown.
+175
View File
@@ -0,0 +1,175 @@
#include "config.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#include <pwd.h>
Config g_cfg;
static char g_cfg_path[512] = "";
static void set_defaults(void) {
memset(&g_cfg, 0, sizeof(g_cfg));
g_cfg.port = 8787;
strncpy(g_cfg.bind_iface, "wg0-mullvad", sizeof(g_cfg.bind_iface)-1);
strncpy(g_cfg.iptv_api, "http://newlogin.freeopened.com/player_api.php", sizeof(g_cfg.iptv_api)-1);
strncpy(g_cfg.dl_dir_tv, "/mnt/media/TV", sizeof(g_cfg.dl_dir_tv)-1);
strncpy(g_cfg.dl_dir_mov, "/mnt/media/Movies", sizeof(g_cfg.dl_dir_mov)-1);
strncpy(g_cfg.template_dir, "/usr/local/share/iptv-dl", sizeof(g_cfg.template_dir)-1);
strncpy(g_cfg.data_dir, "/var/lib/iptv-dl", sizeof(g_cfg.data_dir)-1);
g_cfg.max_recv_speed = 20L * 1024 * 1024; /* 20 MiB/s */
}
/* minimal JSON string field parser — no full JSON lib dependency */
static void cfg_read_str(const char *json, const char *key, char *dst, size_t dsz) {
char needle[128]; snprintf(needle, sizeof(needle), "\"%s\"", key);
const char *p = strstr(json, needle);
if (!p) return;
p += strlen(needle);
while (*p == ' ' || *p == ':') p++;
if (*p != '"') return;
p++;
size_t i = 0;
while (*p && *p != '"' && i < dsz-1) {
if (*p == '\\' && *(p+1)) { p++; dst[i++] = *p++; }
else dst[i++] = *p++;
}
dst[i] = 0;
}
static int cfg_read_int(const char *json, const char *key, int def) {
char needle[128]; snprintf(needle, sizeof(needle), "\"%s\"", key);
const char *p = strstr(json, needle);
if (!p) return def;
p += strlen(needle);
while (*p == ' ' || *p == ':') p++;
if (*p < '0' || *p > '9') return def;
return atoi(p);
}
static long cfg_read_long(const char *json, const char *key, long def) {
char needle[128]; snprintf(needle, sizeof(needle), "\"%s\"", key);
const char *p = strstr(json, needle);
if (!p) return def;
p += strlen(needle);
while (*p == ' ' || *p == ':') p++;
if (*p < '0' || *p > '9') return def;
return atol(p);
}
static int try_load(const char *path) {
FILE *f = fopen(path, "r");
if (!f) return 0;
fseek(f, 0, SEEK_END); long sz = ftell(f); fseek(f, 0, SEEK_SET);
char *buf = malloc(sz + 1);
if (!buf) { fclose(f); return 0; }
fread(buf, 1, sz, f); buf[sz] = 0; fclose(f);
cfg_read_str(buf, "bind_iface", g_cfg.bind_iface, sizeof(g_cfg.bind_iface));
cfg_read_str(buf, "iptv_api", g_cfg.iptv_api, sizeof(g_cfg.iptv_api));
cfg_read_str(buf, "iptv_user", g_cfg.iptv_user, sizeof(g_cfg.iptv_user));
cfg_read_str(buf, "iptv_pass", g_cfg.iptv_pass, sizeof(g_cfg.iptv_pass));
cfg_read_str(buf, "stream_base", g_cfg.stream_base, sizeof(g_cfg.stream_base));
cfg_read_str(buf, "dl_dir_tv", g_cfg.dl_dir_tv, sizeof(g_cfg.dl_dir_tv));
cfg_read_str(buf, "dl_dir_mov", g_cfg.dl_dir_mov, sizeof(g_cfg.dl_dir_mov));
cfg_read_str(buf, "discord_webhook", g_cfg.discord_webhook, sizeof(g_cfg.discord_webhook));
cfg_read_str(buf, "jellyfin_url", g_cfg.jellyfin_url, sizeof(g_cfg.jellyfin_url));
cfg_read_str(buf, "jellyfin_token", g_cfg.jellyfin_token, sizeof(g_cfg.jellyfin_token));
cfg_read_str(buf, "template_dir", g_cfg.template_dir, sizeof(g_cfg.template_dir));
cfg_read_str(buf, "data_dir", g_cfg.data_dir, sizeof(g_cfg.data_dir));
g_cfg.port = cfg_read_int(buf, "port", g_cfg.port);
g_cfg.max_recv_speed = cfg_read_long(buf, "max_recv_speed", g_cfg.max_recv_speed);
free(buf);
strncpy(g_cfg_path, path, sizeof(g_cfg_path)-1);
return 1;
}
const char *config_load(const char *explicit_path) {
set_defaults();
/* 1. explicit argument */
if (explicit_path && *explicit_path) {
if (try_load(explicit_path)) return g_cfg_path;
fprintf(stderr, "iptv-dl: warning: cannot read config '%s', using defaults\n", explicit_path);
return NULL;
}
/* 2. env var */
const char *env = getenv("IPTV_DL_CONFIG");
if (env && *env) {
if (try_load(env)) return g_cfg_path;
}
/* 3. ~/.iptv-downloader/config.json */
char home_cfg[512];
const char *home = getenv("HOME");
if (!home) {
struct passwd *pw = getpwuid(getuid());
if (pw) home = pw->pw_dir;
}
if (home) {
snprintf(home_cfg, sizeof(home_cfg), "%s/.iptv-downloader/config.json", home);
if (try_load(home_cfg)) return g_cfg_path;
}
/* 4. /etc/iptv-downloader/config.json */
if (try_load("/etc/iptv-downloader/config.json")) return g_cfg_path;
/* 5. defaults only */
fprintf(stderr, "iptv-dl: no config file found, using built-in defaults\n");
return NULL;
}
void config_save(void) {
if (!g_cfg_path[0]) {
fprintf(stderr, "iptv-dl: no config path set, cannot save\n");
return;
}
FILE *f = fopen(g_cfg_path, "w");
if (!f) { perror("config_save"); return; }
fprintf(f, "{\n");
fprintf(f, " \"port\": %d,\n", g_cfg.port);
fprintf(f, " \"bind_iface\": \"%s\",\n", g_cfg.bind_iface);
fprintf(f, " \"iptv_api\": \"%s\",\n", g_cfg.iptv_api);
fprintf(f, " \"iptv_user\": \"%s\",\n", g_cfg.iptv_user);
fprintf(f, " \"iptv_pass\": \"%s\",\n", g_cfg.iptv_pass);
fprintf(f, " \"stream_base\": \"%s\",\n", g_cfg.stream_base);
fprintf(f, " \"dl_dir_tv\": \"%s\",\n", g_cfg.dl_dir_tv);
fprintf(f, " \"dl_dir_mov\": \"%s\",\n", g_cfg.dl_dir_mov);
fprintf(f, " \"discord_webhook\": \"%s\",\n", g_cfg.discord_webhook);
fprintf(f, " \"jellyfin_url\": \"%s\",\n", g_cfg.jellyfin_url);
fprintf(f, " \"jellyfin_token\": \"%s\",\n", g_cfg.jellyfin_token);
fprintf(f, " \"template_dir\": \"%s\",\n", g_cfg.template_dir);
fprintf(f, " \"data_dir\": \"%s\",\n", g_cfg.data_dir);
fprintf(f, " \"max_recv_speed\": %ld\n", g_cfg.max_recv_speed);
fprintf(f, "}\n");
fclose(f);
}
void config_dump(void) {
fprintf(stderr,
"iptv-dl config:\n"
" port=%d bind_iface=%s\n"
" iptv_api=%s user=%s\n"
" stream_base=%s\n"
" dl_dir_tv=%s dl_dir_mov=%s\n"
" template_dir=%s data_dir=%s\n"
" max_recv_speed=%ld B/s\n",
g_cfg.port, g_cfg.bind_iface,
g_cfg.iptv_api, g_cfg.iptv_user,
g_cfg.stream_base,
g_cfg.dl_dir_tv, g_cfg.dl_dir_mov,
g_cfg.template_dir, g_cfg.data_dir,
g_cfg.max_recv_speed);
}
void config_history_path(char *buf, size_t n) {
snprintf(buf, n, "%s/history.json", g_cfg.data_dir);
}
void config_notif_path(char *buf, size_t n) {
snprintf(buf, n, "%s/notifications.json", g_cfg.data_dir);
}
+63
View File
@@ -0,0 +1,63 @@
#pragma once
#include <stddef.h>
/* ── Compile-time limits (not user-configurable) ───────────────── */
#define MAX_DL 256
#define MAX_NOTIF 50
#define BACKLOG 32
#define CURL_DL_TIMEOUT 7200L
#define CURL_API_TIMEOUT 20L
/* ── Runtime configuration struct ─────────────────────────────── */
typedef struct {
/* Network */
int port;
char bind_iface[64]; /* "" = no binding, "wg0-mullvad" = VPN only */
/* IPTV provider (Xtream Codes) */
char iptv_api[512];
char iptv_user[128];
char iptv_pass[128];
char stream_base[512];
/* Download directories */
char dl_dir_tv[512];
char dl_dir_mov[512];
/* Integrations */
char discord_webhook[512];
char jellyfin_url[512];
char jellyfin_token[256];
/* Paths */
char template_dir[512]; /* dir containing header.html, footer.html, iptv.css, *.js */
char data_dir[512]; /* dir for history.json, notifications.json */
/* Limits */
long max_recv_speed; /* bytes/sec, 0 = unlimited */
} Config;
extern Config g_cfg;
/*
* Config load order (first found wins):
* 1. Path passed to config_load(path) if non-NULL
* 2. $IPTV_DL_CONFIG env var
* 3. ~/.iptv-downloader/config.json
* 4. /etc/iptv-downloader/config.json
* 5. Built-in defaults (works with no config file)
*
* Returns path used (static buffer), or NULL if using defaults only.
*/
const char *config_load(const char *explicit_path);
/* Write current g_cfg back to the path that was loaded */
void config_save(void);
/* Print current config as JSON to stderr */
void config_dump(void);
/* Return history file path: data_dir/history.json */
void config_history_path(char *buf, size_t n);
/* Return notifications file path: data_dir/notifications.json */
void config_notif_path(char *buf, size_t n);
BIN
View File
Binary file not shown.
+25
View File
@@ -0,0 +1,25 @@
#include "discord.h"
#include "config.h"
#include <curl/curl.h>
#include <stdio.h>
#include <string.h>
void discord_notify(const char *msg) {
if (!g_cfg.discord_webhook[0]) return;
CURL *c = curl_easy_init();
if (!c) return;
char payload[1024];
snprintf(payload, sizeof(payload),
"{\"username\":\"IPTV Downloader\","
"\"avatar_url\":\"https://i.imgur.com/ryNVNoI.png\","
"\"content\":\"%s\"}", msg);
struct curl_slist *hdrs = curl_slist_append(NULL, "Content-Type: application/json");
curl_easy_setopt(c, CURLOPT_URL, g_cfg.discord_webhook);
curl_easy_setopt(c, CURLOPT_POSTFIELDS, payload);
curl_easy_setopt(c, CURLOPT_HTTPHEADER, hdrs);
curl_easy_setopt(c, CURLOPT_TIMEOUT, 10L);
curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, NULL);
curl_easy_perform(c);
curl_slist_free_all(hdrs);
curl_easy_cleanup(c);
}
+2
View File
@@ -0,0 +1,2 @@
#pragma once
void discord_notify(const char *msg);
BIN
View File
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
</div></body></html>
+454
View File
@@ -0,0 +1,454 @@
#include "handlers.h"
#include "config.h"
#include "buf.h"
#include "json.h"
#include "http.h"
#include "queue.h"
#include "notify.h"
#include "iptv_api.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <ctype.h>
#include <pthread.h>
#include <sys/stat.h>
/* ── safe_title: strip path-unsafe chars from a title ─────────── */
static void safe_title(char *dst, size_t dsz, const char *src) {
size_t i = 0;
for (const char *p = src; *p && i < dsz-1; p++) {
if ((*p>='A'&&*p<='Z')||(*p>='a'&&*p<='z')||(*p>='0'&&*p<='9')||
*p==' '||*p=='-'||*p=='_'||*p=='('||*p==')')
dst[i++] = *p;
}
dst[i] = 0;
}
/* ── Page handlers ─────────────────────────────────────────────── */
void handle_index(int fd) {
Buf b; buf_init(&b);
buf_str(&b, "<p class=lbl>iptv downloader</p>"
"<p style='font-size:12px;color:var(--dim)'>"
"Browse <a href='series' style='color:var(--strawberry)'>series</a>, "
"<a href='movies' style='color:var(--strawberry)'>movies</a>, or "
"<a href='search' style='color:var(--strawberry)'>search by name</a>."
"</p>");
send_page(fd, "IPTV Downloader", &b, NULL);
buf_free(&b);
}
void handle_series_cats(int fd) {
char *json = api_get("get_series_categories", "");
int n; char **arr = json_array(json, &n);
Buf b; buf_init(&b);
buf_str(&b, "<p class=lbl>series categories</p>"
"<input class=search placeholder='filter...' oninput=filter(this)>"
"<div class=grid id=g>");
for (int i = 0; i < n; i++) {
char *id = json_str(arr[i],"category_id"), *name = json_str(arr[i],"category_name");
buf_fmt(&b, "<a class=card href='series/cat?id=%s'><div class=card-name>", id?id:"");
html_esc(&b, name?name:"?"); buf_str(&b, "</div></a>");
free(id); free(name); free(arr[i]);
}
buf_str(&b, "</div>"); free(arr); free(json);
send_page(fd, "Series", &b, NULL);
buf_free(&b);
}
void handle_series_list(int fd, const char *qs) {
char *cat_id = qparam(qs, "id");
char extra[128]; snprintf(extra, sizeof(extra), "&category_id=%s", cat_id?cat_id:"");
char *json = api_get("get_series", extra);
int n; char **arr = json_array(json, &n);
Buf b; buf_init(&b);
buf_fmt(&b, "<p class=bc><a href='series'>series</a> / category</p>"
"<p class=lbl>series — %d results</p>", n);
buf_str(&b, "<input class=search placeholder='Filter...' oninput=filter(this)><div class=grid id=g>");
for (int i = 0; i < n; i++) {
char *sid = json_str(arr[i],"series_id"), *name = json_str(arr[i],"name"), *cover = json_str(arr[i],"cover");
buf_fmt(&b, "<a class=card href='series/show?id=%s'>", sid?sid:"");
if (cover && cover[0]) {
buf_str(&b,"<div class=card-img><img src='"); html_esc(&b, cover);
buf_str(&b,"' loading=lazy onerror=\"this.parentNode.style.display='none'\"></div>");
}
buf_str(&b,"<div class=card-name>"); html_esc(&b, name?name:"?"); buf_str(&b,"</div></a>");
free(sid); free(name); free(cover); free(arr[i]);
}
buf_str(&b, "</div>"); free(cat_id); free(arr); free(json);
send_page(fd, "Series", &b, NULL);
buf_free(&b);
}
void handle_series_show(int fd, const char *qs) {
char *sid = qparam(qs, "id");
char extra[64]; snprintf(extra, sizeof(extra), "&series_id=%s", sid?sid:"");
char *json = api_get("get_series_info", extra);
const char *info_sec = strstr(json, "\"info\":");
char *title_raw = json_str(info_sec ? info_sec : json, "name");
char *cover_raw = json_str(info_sec ? info_sec : json, "cover");
const char *title = title_raw ? title_raw : "Unknown";
const char *eps_start = strstr(json, "\"episodes\":");
Buf b; buf_init(&b);
buf_str(&b, "<p class=bc><a href='series'>series</a> / "); html_esc(&b, title);
buf_str(&b, "</p><p class=lbl>"); html_esc(&b, title); buf_str(&b, "</p>");
buf_str(&b, "<div id=tabs style='margin-bottom:10px'></div>");
buf_str(&b, "<div style='margin-bottom:10px'>"
"<button class='btn btn-g' onclick=dlSelected()>⬇ Download Selected</button>"
"</div>");
buf_str(&b, "<div id=seasons></div>");
/* Inject TITLE, COVER, SEASONS as window vars before the external script */
Buf inl; buf_init(&inl);
buf_str(&inl, "<script>");
buf_str(&inl, "var TITLE='"); js_esc(&inl, title); buf_str(&inl, "';\n");
buf_str(&inl, "var COVER='"); if (cover_raw) js_esc(&inl, cover_raw); buf_str(&inl, "';\n");
buf_str(&inl, "var SEASONS=");
if (eps_start) {
eps_start += strlen("\"episodes\":");
int depth = 0; const char *p = eps_start; const char *obj_end = NULL;
for (; *p; p++) { if(*p=='{') depth++; else if(*p=='}'){depth--;if(depth==0){obj_end=p+1;break;}} }
if (obj_end) buf_append(&inl, eps_start, obj_end-eps_start); else buf_str(&inl, "{}");
} else buf_str(&inl, "{}");
buf_str(&inl, ";</script>");
buf_append(&b, inl.data, inl.len);
buf_free(&inl);
free(sid); free(title_raw); free(cover_raw); free(json);
send_page(fd, "Series", &b, "/series_show.js");
buf_free(&b);
}
void handle_movie_cats(int fd) {
char *json = api_get("get_vod_categories", "");
int n; char **arr = json_array(json, &n);
Buf b; buf_init(&b);
buf_str(&b, "<p class=lbl>movie categories</p>"
"<input class=search placeholder='filter...' oninput=filter(this)>"
"<div class=grid id=g>");
for (int i = 0; i < n; i++) {
char *id = json_str(arr[i],"category_id"), *name = json_str(arr[i],"category_name");
buf_fmt(&b, "<a class=card href='movies/cat?id=%s'><div class=card-name>", id?id:"");
html_esc(&b, name?name:"?"); buf_str(&b, "</div></a>");
free(id); free(name); free(arr[i]);
}
buf_str(&b, "</div>"); free(arr); free(json);
send_page(fd, "Movies", &b, NULL);
buf_free(&b);
}
void handle_movie_list(int fd, const char *qs) {
char *cat_id = qparam(qs, "id");
char extra[128]; snprintf(extra, sizeof(extra), "&category_id=%s", cat_id?cat_id:"");
char *json = api_get("get_vod_streams", extra);
int n; char **arr = json_array(json, &n);
Buf b; buf_init(&b);
buf_fmt(&b, "<p class=bc><a href='movies'>movies</a> / category</p>"
"<p class=lbl>movies — %d results</p>", n);
buf_str(&b, "<input class=search placeholder='Filter...' oninput=filter(this)><div class=grid id=g>");
for (int i = 0; i < n; i++) {
char *vid = json_str(arr[i],"stream_id"), *name = json_str(arr[i],"name");
char *ext = json_str(arr[i],"container_extension"), *icon = json_str(arr[i],"stream_icon");
buf_fmt(&b, "<a class=card href='#' onclick=\"dlMov('%s','", vid?vid:"");
js_esc(&b, name?name:"?");
buf_fmt(&b, "','%s','", ext?ext:"mp4");
js_esc(&b, icon?icon:"");
buf_str(&b, "');return false\">");
if (icon && icon[0]) {
buf_str(&b,"<div class=card-img><img src='"); html_esc(&b,icon);
buf_str(&b,"' loading=lazy onerror=\"this.parentNode.style.display='none'\"></div>");
}
buf_str(&b, "<div class=card-name>"); html_esc(&b, name?name:"?"); buf_str(&b, "</div></a>");
free(vid); free(name); free(ext); free(icon); free(arr[i]);
}
buf_str(&b, "</div>"); free(cat_id); free(arr); free(json);
send_page(fd, "Movies", &b, NULL);
buf_free(&b);
}
void handle_search(int fd, const char *qs) {
char *q = qparam(qs, "q"), *type = qparam(qs, "type");
int is_series = !type || strcmp(type, "movies") != 0;
Buf b; buf_init(&b);
buf_str(&b, "<p class=lbl>search</p><form method=get action='search' class=sform>");
buf_str(&b, "<input class=search name=q placeholder='name...' value='");
if (q) html_esc(&b, q);
buf_fmt(&b, "' style='margin-bottom:0'>"
"<select name=type><option value=series%s>series</option>"
"<option value=movies%s>movies</option></select>",
is_series?" selected":"", !is_series?" selected":"");
buf_str(&b, "<button type=submit class='btn btn-s'>go</button></form>");
if (!q || !q[0]) {
send_page(fd, "Search", &b, NULL);
buf_free(&b); free(q); free(type); return;
}
char *json = is_series ? api_get("get_series","") : api_get("get_vod_streams","");
int n; char **arr = json_array(json, &n); int count = 0;
buf_str(&b, "<div class=grid id=g>");
for (int i = 0; i < n; i++) {
char *name = json_str(arr[i], "name");
if (!str_icontains(name, q)) { free(name); free(arr[i]); continue; }
if (is_series) {
char *sid = json_str(arr[i],"series_id"), *cover = json_str(arr[i],"cover");
buf_fmt(&b, "<a class=card href='series/show?id=%s'>", sid?sid:"");
if (cover && cover[0]) { buf_str(&b,"<div class=card-img><img src='"); html_esc(&b,cover); buf_str(&b,"' loading=lazy onerror=\"this.parentNode.style.display='none'\"></div>"); }
buf_str(&b,"<div class=card-name>"); html_esc(&b,name); buf_str(&b,"</div></a>");
free(sid); free(cover);
} else {
char *vid = json_str(arr[i],"stream_id"), *ext = json_str(arr[i],"container_extension"), *icon = json_str(arr[i],"stream_icon");
buf_fmt(&b,"<a class=card href='#' onclick=\"dlMov('%s','",vid?vid:""); js_esc(&b,name);
buf_fmt(&b,"','%s','",ext?ext:"mp4"); js_esc(&b,icon?icon:""); buf_str(&b,"');return false\">");
if (icon&&icon[0]){buf_str(&b,"<div class=card-img><img src='");html_esc(&b,icon);buf_str(&b,"' loading=lazy onerror=\"this.parentNode.style.display='none'\"></div>");}
buf_str(&b,"<div class=card-name>"); html_esc(&b,name); buf_str(&b,"</div></a>");
free(vid); free(ext); free(icon);
}
free(name); free(arr[i]); count++;
}
buf_str(&b, "</div>"); free(arr); free(json);
char cnt[64]; snprintf(cnt,sizeof(cnt),"<p class=lbl style='margin-bottom:12px'>%d results</p>",count);
Buf out; buf_init(&out);
const char *gp = strstr(b.data,"<div class=grid");
if (gp) { buf_append(&out,b.data,gp-b.data); buf_str(&out,cnt); buf_str(&out,gp); }
else { buf_str(&out,cnt); buf_append(&out,b.data,b.len); }
send_page(fd, "Search", &out, NULL);
buf_free(&b); buf_free(&out); free(q); free(type);
}
void handle_downloads(int fd) {
Buf b; buf_init(&b);
buf_str(&b, "<p class=lbl>downloads</p>"
"<div style='margin-bottom:16px;display:flex;gap:8px;flex-wrap:wrap'>"
"<button class='btn' onclick=clearDl()>clear all finished</button>"
"<button class='btn' onclick=clearCancelled()>clear cancelled</button>"
"<button class='btn btn-g' onclick=retryInterrupted()>retry interrupted</button>"
"<button class='btn btn-r' onclick=cleanPartials()>clean partial files</button>"
"</div>"
"<div id=dl-table><p style='color:var(--dim)'>loading...</p></div>");
send_page(fd, "Downloads", &b, "/downloads.js");
buf_free(&b);
}
/* ── API handlers ──────────────────────────────────────────────── */
void handle_api_downloads(int fd) {
Buf b; buf_init(&b);
buf_str(&b, "[");
pthread_mutex_lock(&g_dl_mutex);
for (int i = 0; i < g_dl_count; i++) {
Download *d = &g_downloads[i];
int pct = d->total > 0 ? (int)(d->downloaded*100/d->total) : 0;
long speed = (long)d->speed_bps;
int eta = -1;
if (d->speed_bps > 0 && d->total > d->downloaded)
eta = (int)((d->total - d->downloaded) / d->speed_bps);
if (i > 0) buf_str(&b, ",");
buf_fmt(&b, "{\"id\":\"%s\",\"name\":\"%s\",\"status\":\"%s\","
"\"downloaded\":%ld,\"total\":%ld,\"pct\":%d,"
"\"speed_bps\":%ld,\"eta_s\":%d,\"cover_url\":\"%s\"}",
d->id, d->name, d->status, d->downloaded, d->total,
pct, speed, eta, d->cover_url);
}
pthread_mutex_unlock(&g_dl_mutex);
buf_str(&b, "]");
send_json_buf(fd, &b); buf_free(&b);
}
void handle_api_cancel(int fd, const char *body) {
char *id = json_str(body, "id");
if (id) {
pthread_mutex_lock(&g_dl_mutex);
for (int i = 0; i < g_dl_count; i++) {
if (strcmp(g_downloads[i].id, id) == 0) {
g_downloads[i].cancelled = 1;
if (strcmp(g_downloads[i].status, "queued") == 0)
strncpy(g_downloads[i].status, "cancelled", 63);
break;
}
}
pthread_mutex_unlock(&g_dl_mutex);
pthread_mutex_lock(&g_q_mutex);
QNode *prev = NULL, *node = g_q_head;
while (node) {
if (strcmp(node->task->id, id) == 0) {
if (prev) prev->next = node->next; else g_q_head = node->next;
if (g_q_tail == node) g_q_tail = prev;
free(node->task); free(node); break;
}
prev = node; node = node->next;
}
pthread_mutex_unlock(&g_q_mutex);
history_save();
free(id);
}
send_json(fd, "{\"ok\":1}");
}
void handle_api_clear(int fd) {
pthread_mutex_lock(&g_dl_mutex);
int nc = 0;
for (int i = 0; i < g_dl_count; i++) {
const char *s = g_downloads[i].status;
if (strcmp(s,"done")!=0 && strncmp(s,"error",5)!=0 &&
strcmp(s,"cancelled")!=0 && strcmp(s,"interrupted")!=0) {
if (nc != i) g_downloads[nc] = g_downloads[i];
nc++;
}
}
g_dl_count = nc;
pthread_mutex_unlock(&g_dl_mutex);
history_save();
send_json(fd, "{\"ok\":1}");
}
void handle_api_clear_cancelled(int fd) {
pthread_mutex_lock(&g_dl_mutex);
int nc = 0;
for (int i = 0; i < g_dl_count; i++) {
const char *s = g_downloads[i].status;
if (strcmp(s,"cancelled")!=0 && strcmp(s,"interrupted")!=0) {
if (nc != i) g_downloads[nc] = g_downloads[i];
nc++;
}
}
g_dl_count = nc;
pthread_mutex_unlock(&g_dl_mutex);
history_save();
send_json(fd, "{\"ok\":1}");
}
void handle_api_clean_partials(int fd) {
clean_partials();
send_json(fd, "{\"ok\":1}");
}
void handle_api_retry_interrupted(int fd) {
pthread_mutex_lock(&g_dl_mutex);
int n = g_dl_count;
DlTask *tasks[MAX_DL]; int tc = 0;
for (int i = 0; i < n; i++) {
Download *d = &g_downloads[i];
if (strcmp(d->status, "interrupted") != 0) continue;
DlTask *t = calloc(1, sizeof(DlTask));
if (d->url[0]) {
strncpy(t->url, d->url, sizeof(t->url)-1);
} else {
snprintf(t->url, sizeof(t->url), "%s/series/%s/%s/%s",
g_cfg.stream_base, g_cfg.iptv_user, g_cfg.iptv_pass, d->id);
}
if (d->dest_dir[0]) {
strncpy(t->dest_dir, d->dest_dir, sizeof(t->dest_dir)-1);
} else {
char title[256]; strncpy(title, d->name, sizeof(title)-1);
int season = 0;
for (char *p = title; *p; p++) {
if (*p==' ' && *(p+1)=='S' && isdigit((unsigned char)*(p+2)) && isdigit((unsigned char)*(p+3))) {
season = atoi(p+2); *p = 0;
}
}
char safe[256]; safe_title(safe, sizeof(safe), title);
snprintf(t->dest_dir, sizeof(t->dest_dir), "%s/%s/Season %02d", g_cfg.dl_dir_tv, safe, season);
}
if (d->filename[0]) {
strncpy(t->filename, d->filename, sizeof(t->filename)-1);
} else {
const char *dot = strrchr(d->id, '.'); const char *ext = dot ? dot+1 : "mp4";
snprintf(t->filename, sizeof(t->filename), "%s.%s", d->name, ext);
}
strncpy(t->name, d->name, sizeof(t->name)-1);
strncpy(t->id, d->id, sizeof(t->id)-1);
strncpy(t->cover_url,d->cover_url,sizeof(t->cover_url)-1);
strncpy(d->status, "queued", 63);
d->downloaded = 0; d->total = 0; d->speed_bps = 0; d->cancelled = 0;
tasks[tc++] = t;
}
pthread_mutex_unlock(&g_dl_mutex);
history_save();
for (int i = 0; i < tc; i++) {
QNode *node = malloc(sizeof(QNode));
node->task = tasks[i]; node->next = NULL;
pthread_mutex_lock(&g_q_mutex);
if (g_q_tail) g_q_tail->next = node; else g_q_head = node;
g_q_tail = node;
pthread_cond_signal(&g_q_cond);
pthread_mutex_unlock(&g_q_mutex);
}
char resp[32]; snprintf(resp, sizeof(resp), "{\"retried\":%d}", tc);
send_json(fd, resp);
}
void handle_api_download(int fd, const char *body) {
char *title_raw = json_str(body,"series_title"), *season_raw = json_str(body,"season");
char *cover = json_str(body,"cover_url");
const char *title = title_raw?title_raw:"Unknown", *season = season_raw?season_raw:"01";
char safe[256]; safe_title(safe, sizeof(safe), title);
char dest[512]; snprintf(dest, sizeof(dest), "%s/%s/Season %02d", g_cfg.dl_dir_tv, safe, atoi(season));
const char *eps_json = strstr(body, "\"episodes\":");
if (!eps_json) { send_json(fd,"{\"queued\":0}"); free(title_raw);free(season_raw);free(cover); return; }
int n; char **arr = json_array(eps_json + strlen("\"episodes\":"), &n);
int queued = 0;
for (int i = 0; i < n; i++) {
char *eid = json_str(arr[i],"id"), *epn = json_str(arr[i],"episode_num"), *ext = json_str(arr[i],"ext");
const char *ex = (ext && *ext) ? ext : "mp4";
if (eid) {
char fname[256], name[256];
snprintf(fname, sizeof(fname), "%s S%02dE%s.%s", safe, atoi(season), epn?epn:"??", ex);
snprintf(name, sizeof(name), "%s S%02dE%s", title, atoi(season), epn?epn:"??");
char sf[128]; snprintf(sf, sizeof(sf), "%s.%s", eid, ex);
queue_download(sf, fname, dest, name, 0, cover); queued++;
}
free(eid); free(epn); free(ext); free(arr[i]);
}
free(arr); free(title_raw); free(season_raw); free(cover);
char resp[64]; snprintf(resp, sizeof(resp), "{\"queued\":%d}", queued);
send_json(fd, resp);
}
void handle_api_download_movie(int fd, const char *body) {
char *id = json_str(body,"id"), *title = json_str(body,"title");
char *ext = json_str(body,"ext"), *cover = json_str(body,"cover_url");
if (!ext || !*ext) { free(ext); ext = strdup("mp4"); }
const char *t = title ? title : "Movie";
char safe[256]; safe_title(safe, sizeof(safe), t);
char dest[512]; snprintf(dest, sizeof(dest), "%s/%s", g_cfg.dl_dir_mov, safe);
char fname[256]; snprintf(fname, sizeof(fname), "%s.%s", safe, ext);
char sf[128]; snprintf(sf, sizeof(sf), "%s.%s", id?id:"0", ext);
queue_download(sf, fname, dest, safe, 1, cover);
free(id); free(title); free(ext); free(cover);
send_json(fd, "{\"queued\":1}");
}
void handle_api_notifications(int fd) {
Buf b; buf_init(&b);
buf_str(&b, "[");
int start = (g_notif_count < MAX_NOTIF) ? 0 : g_notif_tail;
for (int i = g_notif_count - 1; i >= 0; i--) {
Notification *n = &g_notifs[(start + i) % MAX_NOTIF];
if (i < g_notif_count - 1) buf_str(&b, ",");
buf_fmt(&b, "{\"ts\":%ld,\"type\":\"%s\",\"msg\":", (long)n->ts, n->type);
buf_str(&b, "\"");
for (const char *p = n->msg; *p; p++) {
if (*p == '"') buf_str(&b, "\\\"");
else if (*p == '\\') buf_str(&b, "\\\\");
else { char ch[2] = {*p, 0}; buf_str(&b, ch); }
}
buf_str(&b, "\"}");
}
buf_str(&b, "]");
send_json_buf(fd, &b); buf_free(&b);
}
void handle_api_notifications_test(int fd) {
notify_add(":white_check_mark: Downloaded: **Test Episode S01E99**", "done");
notify_add(":x: Download failed: **Test Episode S01E00** — connection timeout", "error");
send_json(fd, "{\"ok\":1,\"added\":2}");
}
void handle_api_notifications_dismiss(int fd, const char *body) {
char *ts_s = json_str(body, "ts");
if (!ts_s) { send_json(fd, "{\"ok\":0}"); return; }
time_t ts = (time_t)atol(ts_s); free(ts_s);
int found = notify_dismiss(ts);
send_json(fd, found ? "{\"ok\":1}" : "{\"ok\":0}");
}
+24
View File
@@ -0,0 +1,24 @@
#pragma once
/* Page handlers */
void handle_index(int fd);
void handle_series_cats(int fd);
void handle_series_list(int fd, const char *qs);
void handle_series_show(int fd, const char *qs);
void handle_movie_cats(int fd);
void handle_movie_list(int fd, const char *qs);
void handle_search(int fd, const char *qs);
void handle_downloads(int fd);
/* API handlers */
void handle_api_downloads(int fd);
void handle_api_cancel(int fd, const char *body);
void handle_api_clear(int fd);
void handle_api_clear_cancelled(int fd);
void handle_api_clean_partials(int fd);
void handle_api_retry_interrupted(int fd);
void handle_api_download(int fd, const char *body);
void handle_api_download_movie(int fd, const char *body);
void handle_api_notifications(int fd);
void handle_api_notifications_test(int fd);
void handle_api_notifications_dismiss(int fd, const char *body);
BIN
View File
Binary file not shown.
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html><html lang=en><head><meta charset=utf-8>
<base href='/iptv/'>
<title>{{TITLE}} — iptv</title>
<meta name=viewport content='width=device-width,initial-scale=1'>
<link rel=stylesheet href='iptv.css'>
<script>var BASE='/iptv';</script>
</head>
<body>
<header><h1>iptv</h1><nav>
<a href='series'>series</a>
<a href='movies'>movies</a>
<a href='search'>search</a>
<a href='downloads'>downloads</a>
</nav></header>
<div class=c>
+144
View File
@@ -0,0 +1,144 @@
#include "http.h"
#include "config.h" /* g_cfg */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
char *g_header = NULL; int g_header_len = 0;
char *g_footer = NULL; int g_footer_len = 0;
char *g_css = NULL; int g_css_len = 0;
StaticJS g_js[] = {
{ "/iptv.js", "iptv.js", NULL, 0 },
{ "/downloads.js", "downloads.js", NULL, 0 },
{ "/series_show.js", "series_show.js", NULL, 0 },
{ NULL, NULL, NULL, 0 }
};
static int load_file(const char *path, char **out, int *out_len) {
FILE *f = fopen(path, "rb");
if (!f) { fprintf(stderr, "iptv-dl: cannot open %s: %s\n", path, strerror(errno)); return 0; }
fseek(f, 0, SEEK_END); long sz = ftell(f); fseek(f, 0, SEEK_SET);
*out = malloc(sz + 1);
if (!*out) { fclose(f); return 0; }
*out_len = (int)fread(*out, 1, sz, f);
(*out)[*out_len] = '\0';
fclose(f);
return 1;
}
int http_load_templates(void) {
char path[512];
snprintf(path, sizeof(path), "%s/header.html", g_cfg.template_dir);
if (!load_file(path, &g_header, &g_header_len)) return 0;
snprintf(path, sizeof(path), "%s/footer.html", g_cfg.template_dir);
if (!load_file(path, &g_footer, &g_footer_len)) return 0;
snprintf(path, sizeof(path), "%s/iptv.css", g_cfg.template_dir);
if (!load_file(path, &g_css, &g_css_len)) return 0;
for (int i = 0; g_js[i].url_path; i++) {
snprintf(path, sizeof(path), "%s/%s", g_cfg.template_dir, g_js[i].fs_name);
/* non-fatal if JS file missing */
load_file(path, &g_js[i].data, &g_js[i].len);
}
fprintf(stderr, "iptv-dl: templates loaded (header=%d footer=%d css=%d bytes)\n",
g_header_len, g_footer_len, g_css_len);
return 1;
}
int parse_request(int fd, Req *req) {
char buf[131072]; int total = 0, n;
memset(req, 0, sizeof(*req));
while ((n = read(fd, buf+total, sizeof(buf)-1-total)) > 0) {
total += n; buf[total] = 0;
if (strstr(buf, "\r\n\r\n")) break;
}
if (total <= 0) return -1;
buf[total] = 0;
char *sp1 = strchr(buf, ' '); if (!sp1) return -1;
size_t mlen = sp1 - buf; if (mlen >= 8) mlen = 7;
memcpy(req->method, buf, mlen); req->method[mlen] = 0;
char *sp2 = strchr(sp1+1, ' '); if (!sp2) return -1;
size_t plen = sp2 - sp1 - 1; if (plen >= 256) plen = 255;
char full[256]; memcpy(full, sp1+1, plen); full[plen] = 0;
char *qm = strchr(full, '?');
if (qm) { *qm = 0; strncpy(req->query, qm+1, sizeof(req->query)-1); }
strncpy(req->path, full, sizeof(req->path)-1);
char *body_start = strstr(buf, "\r\n\r\n");
if (body_start) {
body_start += 4;
int blen = total - (int)(body_start - buf);
if (blen > 0 && blen < (int)sizeof(req->body)) { memcpy(req->body, body_start, blen); req->body_len = blen; }
}
return 0;
}
void send_page(int fd, const char *title, Buf *body, const char *script_src) {
Buf page; buf_init(&page);
buf_str(&page, "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nConnection: close\r\n\r\n");
const char *ph = strstr(g_header, "{{TITLE}}");
if (ph) {
buf_append(&page, g_header, ph - g_header);
buf_str(&page, title);
buf_str(&page, ph + 9);
} else {
buf_str(&page, g_header);
}
buf_append(&page, body->data, body->len);
if (script_src && script_src[0])
buf_fmt(&page, "<script src=\"%s\"></script>", script_src);
buf_str(&page, g_footer);
write(fd, page.data, page.len);
buf_free(&page);
}
void send_css(int fd) {
char hdr[256];
int hl = snprintf(hdr, sizeof(hdr),
"HTTP/1.1 200 OK\r\nContent-Type: text/css\r\n"
"Content-Length: %d\r\nConnection: close\r\n\r\n", g_css_len);
write(fd, hdr, hl);
write(fd, g_css, g_css_len);
}
void send_static_js(int fd, const char *path) {
for (int i = 0; g_js[i].url_path; i++) {
if (strcmp(g_js[i].url_path, path) == 0) {
if (!g_js[i].data) { send_404(fd); return; }
char hdr[256];
int hl = snprintf(hdr, sizeof(hdr),
"HTTP/1.1 200 OK\r\nContent-Type: application/javascript\r\n"
"Content-Length: %d\r\nConnection: close\r\n\r\n", g_js[i].len);
write(fd, hdr, hl);
write(fd, g_js[i].data, g_js[i].len);
return;
}
}
send_404(fd);
}
void send_json(int fd, const char *json) {
char hdr[256];
snprintf(hdr, sizeof(hdr),
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n"
"Access-Control-Allow-Origin: *\r\n"
"Content-Length: %zu\r\nConnection: close\r\n\r\n", strlen(json));
write(fd, hdr, strlen(hdr));
write(fd, json, strlen(json));
}
void send_json_buf(int fd, Buf *b) {
char hdr[256];
snprintf(hdr, sizeof(hdr),
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n"
"Access-Control-Allow-Origin: *\r\n"
"Content-Length: %zu\r\nConnection: close\r\n\r\n", b->len);
write(fd, hdr, strlen(hdr));
write(fd, b->data, b->len);
}
void send_404(int fd) {
const char *r = "HTTP/1.1 404 Not Found\r\nContent-Length: 9\r\nConnection: close\r\n\r\nNot Found";
write(fd, r, strlen(r));
}
+31
View File
@@ -0,0 +1,31 @@
#pragma once
#include "buf.h"
typedef struct {
char method[8];
char path[256];
char query[1024];
char body[65536];
int body_len;
} Req;
/* Template globals — initialised by http_load_templates() */
extern char *g_header;
extern int g_header_len;
extern char *g_footer;
extern int g_footer_len;
extern char *g_css;
extern int g_css_len;
/* Static JS files served verbatim */
typedef struct { const char *url_path; const char *fs_name; char *data; int len; } StaticJS;
extern StaticJS g_js[];
int http_load_templates(void);
int parse_request(int fd, Req *req);
void send_page(int fd, const char *title, Buf *body, const char *script_src);
void send_css(int fd);
void send_static_js(int fd, const char *path);
void send_json(int fd, const char *json);
void send_json_buf(int fd, Buf *b);
void send_404(int fd);
BIN
View File
Binary file not shown.
Executable
BIN
View File
Binary file not shown.
+41
View File
@@ -0,0 +1,41 @@
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;700&display=swap');
:root{--black:#000;--offblack:#0a0a0a;--terminal:#ccc;--dim:#555;--border:#171717;--strawberry:#e8547a;--green:#4ade80}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--black);color:var(--terminal);font-family:'JetBrains Mono',monospace;min-height:100vh;padding:24px 16px 48px;-webkit-font-smoothing:antialiased}
header{border-bottom:1px solid var(--border);padding-bottom:20px;margin-bottom:24px;display:flex;align-items:baseline;gap:16px}
header h1{font-size:22px;font-weight:700;letter-spacing:-.04em;color:#fff}
nav{display:flex;gap:6px;flex-wrap:wrap}
nav a{font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--dim);text-decoration:none;padding:4px 10px;border:1px solid var(--border);border-radius:6px;transition:border-color .15s,color .15s}
nav a:hover{border-color:var(--strawberry);color:var(--strawberry)}
.c{max-width:960px;margin:0 auto}
.lbl{font-size:9px;letter-spacing:.1em;text-transform:uppercase;color:var(--dim);margin-bottom:12px}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:10px;margin-bottom:28px}
.card{background:var(--offblack);border:1px solid var(--border);border-radius:12px;padding:14px 12px 10px;cursor:pointer;text-decoration:none;display:block;color:var(--terminal);transition:border-color .15s}
.card:hover{border-color:#2a2a2a}
.card:active{background:#0f0f0f}
.card-img{width:100%;aspect-ratio:2/3;background:var(--border);border-radius:6px;overflow:hidden;margin-bottom:8px}
.card-img img{width:100%;height:100%;object-fit:cover;display:block}
.card-name{font-size:12px;font-weight:700;color:#fff;letter-spacing:-.01em;margin-bottom:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.card-sub{font-size:10px;color:var(--dim);letter-spacing:.04em}
table{width:100%;border-collapse:collapse;font-size:12px}
th{text-align:left;padding:8px 10px;background:var(--offblack);border-bottom:1px solid var(--border);font-size:9px;letter-spacing:.08em;text-transform:uppercase;color:var(--dim);position:sticky;top:0}
td{padding:8px 10px;border-bottom:1px solid var(--border);vertical-align:middle}
tr:hover td{background:var(--offblack)}
.btn{display:inline-block;padding:4px 12px;border-radius:6px;border:1px solid var(--border);cursor:pointer;font-size:10px;font-family:inherit;letter-spacing:.06em;text-transform:uppercase;text-decoration:none;transition:border-color .15s,color .15s;background:transparent;color:var(--terminal)}
.btn:hover{border-color:var(--strawberry);color:var(--strawberry)}
.btn-g{border-color:var(--green);color:var(--green)}.btn-g:hover{border-color:#22d360;color:#22d360}
.btn-s{border-color:var(--strawberry);color:var(--strawberry)}.btn-r{border-color:#ef4444;color:#ef4444}.btn-r:hover{border-color:#f87171;color:#f87171}
input[type=checkbox]{cursor:pointer;accent-color:var(--strawberry)}
.search{width:100%;padding:8px 12px;background:var(--offblack);border:1px solid var(--border);color:var(--terminal);border-radius:8px;font-size:12px;font-family:inherit;margin-bottom:14px;outline:none;transition:border-color .15s}
.search:focus{border-color:#2a2a2a}
select{background:var(--offblack);border:1px solid var(--border);color:var(--terminal);border-radius:8px;font-size:12px;font-family:inherit;padding:8px 12px;outline:none;cursor:pointer}
.pbar{background:var(--border);border-radius:2px;height:2px;overflow:hidden;min-width:80px;display:inline-block;vertical-align:middle}
.pfill{background:var(--green);height:100%}
.done{color:var(--green)}.err{color:var(--strawberry)}.dl{color:#fbbf24}
.bc{font-size:10px;color:var(--dim);letter-spacing:.04em;margin-bottom:16px}
.bc a{color:var(--strawberry);text-decoration:none}
.sep{border:none;border-top:1px solid var(--border);margin:20px 0}
.sform{display:flex;gap:8px;margin-bottom:20px;flex-wrap:wrap}
.sform input{flex:1;min-width:160px;margin-bottom:0}
.sform select{margin-bottom:0}
@media(min-width:480px){.grid{grid-template-columns:repeat(auto-fill,minmax(160px,1fr))}}
+26
View File
@@ -0,0 +1,26 @@
#include "iptv_api.h"
#include "config.h"
#include "buf.h"
#include <curl/curl.h>
#include <stdio.h>
static size_t curl_write_buf(char *ptr, size_t sz, size_t n, void *ud) {
buf_append((Buf*)ud, ptr, sz*n); return sz*n;
}
char *api_get(const char *action, const char *extra) {
CURL *c = curl_easy_init();
Buf b; buf_init(&b);
char url[1024];
snprintf(url, sizeof(url), "%s?username=%s&password=%s&action=%s%s",
g_cfg.iptv_api, g_cfg.iptv_user, g_cfg.iptv_pass,
action, extra ? extra : "");
curl_easy_setopt(c, CURLOPT_URL, url);
curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, curl_write_buf);
curl_easy_setopt(c, CURLOPT_WRITEDATA, &b);
curl_easy_setopt(c, CURLOPT_TIMEOUT, CURL_API_TIMEOUT);
curl_easy_setopt(c, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_perform(c);
curl_easy_cleanup(c);
return b.data; /* caller frees */
}
+3
View File
@@ -0,0 +1,3 @@
#pragma once
/* Fetch Xtream Codes API endpoint, return malloc'd JSON string (caller frees) */
char *api_get(const char *action, const char *extra);
BIN
View File
Binary file not shown.
+74
View File
@@ -0,0 +1,74 @@
#include "jellyfin.h"
#include "config.h"
#include <curl/curl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <dirent.h>
#include <sys/stat.h>
#include <time.h>
void trigger_jellyfin_scan(void) {
if (!g_cfg.jellyfin_url[0]) return;
CURL *c = curl_easy_init();
if (!c) return;
curl_easy_setopt(c, CURLOPT_URL, g_cfg.jellyfin_url);
curl_easy_setopt(c, CURLOPT_POST, 1L);
curl_easy_setopt(c, CURLOPT_POSTFIELDSIZE, 0L);
curl_easy_setopt(c, CURLOPT_TIMEOUT, 10L);
curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, NULL);
curl_easy_perform(c);
curl_easy_cleanup(c);
}
static int cmp_str(const void *a, const void *b) { return strcmp(*(const char **)a, *(const char **)b); }
void update_show_manifest(const char *season_dir, const char *title_hint) {
(void)title_hint;
char show_dir[512];
strncpy(show_dir, season_dir, sizeof(show_dir)-1); show_dir[sizeof(show_dir)-1] = 0;
char *slash = strrchr(show_dir, '/');
if (!slash) return;
*slash = 0;
const char *title = strrchr(show_dir, '/');
title = title ? title+1 : show_dir;
char manifest[640]; snprintf(manifest, sizeof(manifest), "%s/show.json", show_dir);
char **files = NULL; int fc = 0, fcap = 0;
DIR *sd = opendir(show_dir);
if (!sd) return;
struct dirent *se;
while ((se = readdir(sd))) {
if (strncmp(se->d_name, "Season ", 7) != 0) continue;
char sdir[640]; snprintf(sdir, sizeof(sdir), "%s/%s", show_dir, se->d_name);
DIR *ed = opendir(sdir);
if (!ed) continue;
struct dirent *ee;
while ((ee = readdir(ed))) {
if (ee->d_name[0] == '.') continue;
char fpath[768]; snprintf(fpath, sizeof(fpath), "%s/%s", sdir, ee->d_name);
struct stat st; if (stat(fpath, &st) != 0 || !S_ISREG(st.st_mode)) continue;
char entry[900];
snprintf(entry, sizeof(entry), "{\"file\":\"%s/%s\",\"size\":%lld}",
se->d_name, ee->d_name, (long long)st.st_size);
if (fc >= fcap) { fcap = fcap ? fcap*2 : 32; files = realloc(files, fcap*sizeof(char*)); }
files[fc++] = strdup(entry);
}
closedir(ed);
}
closedir(sd);
if (fc > 1) qsort(files, fc, sizeof(char*), cmp_str);
FILE *f = fopen(manifest, "w");
if (f) {
fprintf(f, "{\"title\":\"%s\",\"updated\":%ld,\"count\":%d,\"episodes\":[",
title, (long)time(NULL), fc);
for (int i = 0; i < fc; i++) { if (i) fputc(',', f); fputs(files[i], f); }
fputs("]}", f);
fclose(f);
}
for (int i = 0; i < fc; i++) free(files[i]);
free(files);
}
+3
View File
@@ -0,0 +1,3 @@
#pragma once
void trigger_jellyfin_scan(void);
void update_show_manifest(const char *season_dir, const char *title_hint);
BIN
View File
Binary file not shown.
+116
View File
@@ -0,0 +1,116 @@
#include "json.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
char *json_str(const char *json, const char *key) {
if (!json || !key) return NULL;
char needle[128]; snprintf(needle, sizeof(needle), "\"%s\"", key);
const char *p = strstr(json, needle);
if (!p) return NULL;
p += strlen(needle);
while (*p == ' ' || *p == ':') p++;
if (*p == '"') {
p++;
const char *end = p;
while (*end && !(*end == '"' && *(end-1) != '\\')) end++;
size_t len = end - p;
char *r = malloc(len+1); memcpy(r, p, len); r[len] = 0; return r;
}
const char *end = p;
while (*end && *end != ',' && *end != '}' && *end != ']') end++;
size_t len = end - p;
char *r = malloc(len+1); memcpy(r, p, len); r[len] = 0; return r;
}
char **json_array(const char *json, int *n) {
*n = 0;
if (!json) return NULL;
const char *p = json;
while (*p && *p != '[') p++;
if (!*p) return NULL;
p++;
int cap = 64;
char **arr = malloc(cap * sizeof(char*));
int depth = 0; const char *obj_start = NULL; int in_str = 0;
for (; *p; p++) {
if (*p == '"' && (p == json || *(p-1) != '\\')) { in_str = !in_str; continue; }
if (in_str) continue;
if (*p == '{' || *p == '[') { if (depth == 0) obj_start = p; depth++; }
else if (*p == '}' || *p == ']') {
depth--;
if (depth == 0 && obj_start) {
size_t len = p - obj_start + 1;
char *obj = malloc(len+1); memcpy(obj, obj_start, len); obj[len] = 0;
if (*n >= cap) { cap *= 2; arr = realloc(arr, cap*sizeof(char*)); }
arr[(*n)++] = obj;
obj_start = NULL;
if (*p == ']') break;
}
}
}
return arr;
}
void urldecode(char *s) {
char *w = s, *r = s;
while (*r) {
if (*r == '%' && r[1] && r[2]) {
char hex[3] = {r[1], r[2], 0}; *w++ = (char)strtol(hex, NULL, 16); r += 3;
} else if (*r == '+') { *w++ = ' '; r++; }
else { *w++ = *r++; }
}
*w = 0;
}
char *qparam(const char *qs, const char *key) {
if (!qs || !key) return NULL;
char needle[128]; snprintf(needle, sizeof(needle), "%s=", key);
const char *p = strstr(qs, needle);
if (!p) return NULL;
p += strlen(needle);
const char *end = strchr(p, '&');
size_t len = end ? (size_t)(end-p) : strlen(p);
char *r = malloc(len+1); memcpy(r, p, len); r[len] = 0;
urldecode(r);
return r;
}
void html_esc(Buf *b, const char *s) {
for (; *s; s++) {
if (*s == '<') buf_str(b, "&lt;");
else if (*s == '>') buf_str(b, "&gt;");
else if (*s == '&') buf_str(b, "&amp;");
else if (*s == '"') buf_str(b, "&quot;");
else buf_append(b, s, 1);
}
}
void js_esc(Buf *b, const char *s) {
for (; *s; s++) {
if (*s == '\'') buf_str(b, "\\'");
else if (*s == '\\') buf_str(b, "\\\\");
else if (*s == '\n' || *s == '\r') {}
else buf_append(b, s, 1);
}
}
void fwrite_json_str(FILE *f, const char *s) {
fputc('"', f);
for (; *s; s++) {
if (*s == '"' || *s == '\\') fputc('\\', f);
if ((unsigned char)*s >= 0x20) fputc(*s, f);
}
fputc('"', f);
}
int str_icontains(const char *hay, const char *needle) {
if (!hay || !needle || !*needle) return 1;
char h[2048], n[256];
strncpy(h, hay, sizeof(h)-1); h[sizeof(h)-1] = 0;
strncpy(n, needle, sizeof(n)-1); n[sizeof(n)-1] = 0;
for (char *p = h; *p; p++) if (*p >= 'A' && *p <= 'Z') *p += 32;
for (char *p = n; *p; p++) if (*p >= 'A' && *p <= 'Z') *p += 32;
return strstr(h, n) != NULL;
}
+19
View File
@@ -0,0 +1,19 @@
#pragma once
#include <stdio.h>
#include "buf.h"
/* JSON micro-parser */
char *json_str(const char *json, const char *key);
char **json_array(const char *json, int *n);
/* URL helpers */
void urldecode(char *s);
char *qparam(const char *qs, const char *key);
/* Escaping */
void html_esc(Buf *b, const char *s);
void js_esc(Buf *b, const char *s);
void fwrite_json_str(FILE *f, const char *s);
/* String utils */
int str_icontains(const char *hay, const char *needle);
BIN
View File
Binary file not shown.
+80
View File
@@ -0,0 +1,80 @@
/*
* iptv-dl — IPTV Downloader
* Config: ~/.iptv-downloader/config.json (or $IPTV_DL_CONFIG, or --config PATH)
* Build: make
*/
#include "config.h"
#include "http.h"
#include "queue.h"
#include "notify.h"
#include "server.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <netinet/in.h>
#include <signal.h>
#include <curl/curl.h>
int main(int argc, char **argv) {
const char *cfg_path = NULL;
for (int i = 1; i < argc; i++) {
if (!strcmp(argv[i], "--config") && i+1 < argc) cfg_path = argv[++i];
else if (!strcmp(argv[i], "--help") || !strcmp(argv[i], "-h")) {
fprintf(stderr,
"Usage: %s [--config PATH] [--dump-config]\n"
" --config PATH load config from PATH\n"
" --dump-config print active config and exit\n"
"Config search order:\n"
" --config arg → $IPTV_DL_CONFIG → ~/.iptv-downloader/config.json\n"
" → /etc/iptv-downloader/config.json → built-in defaults\n",
argv[0]);
return 0;
} else if (!strcmp(argv[i], "--dump-config")) {
config_load(cfg_path);
config_dump();
return 0;
}
}
config_load(cfg_path);
if (!http_load_templates()) {
fprintf(stderr, "iptv-dl: failed to load templates from %s/\n", g_cfg.template_dir);
return 1;
}
mkdir(g_cfg.data_dir, 0755);
history_load();
notify_load();
clean_partials();
curl_global_init(CURL_GLOBAL_ALL);
signal(SIGPIPE, SIG_IGN);
pthread_t worker;
pthread_create(&worker, NULL, dl_worker, NULL);
pthread_detach(worker);
int srv = socket(AF_INET, SOCK_STREAM, 0);
int opt = 1; setsockopt(srv, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_port = htons((unsigned short)g_cfg.port);
addr.sin_addr.s_addr = INADDR_ANY;
if (bind(srv, (struct sockaddr*)&addr, sizeof(addr)) < 0) { perror("bind"); return 1; }
listen(srv, BACKLOG);
fprintf(stderr, "iptv-dl :%d (template_dir=%s data_dir=%s)\n",
g_cfg.port, g_cfg.template_dir, g_cfg.data_dir);
for (;;) {
struct sockaddr_in cli; socklen_t clen = sizeof(cli);
int cfd = accept(srv, (struct sockaddr*)&cli, &clen);
if (cfd < 0) continue;
int *p = malloc(sizeof(int)); *p = cfd;
pthread_t tid; pthread_create(&tid, NULL, handle_conn, p); pthread_detach(tid);
}
}
BIN
View File
Binary file not shown.
+97
View File
@@ -0,0 +1,97 @@
#include "notify.h"
#include "config.h"
#include "json.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>
Notification g_notifs[MAX_NOTIF];
int g_notif_count = 0;
int g_notif_tail = 0;
static pthread_mutex_t notif_mutex = PTHREAD_MUTEX_INITIALIZER;
void notify_add(const char *msg, const char *type) {
pthread_mutex_lock(&notif_mutex);
Notification *nf = &g_notifs[g_notif_tail];
nf->ts = time(NULL);
strncpy(nf->type, type, sizeof(nf->type)-1);
strncpy(nf->msg, msg, sizeof(nf->msg)-1);
g_notif_tail = (g_notif_tail + 1) % MAX_NOTIF;
if (g_notif_count < MAX_NOTIF) g_notif_count++;
pthread_mutex_unlock(&notif_mutex);
notify_save();
}
void notify_save(void) {
char path[512]; config_notif_path(path, sizeof(path));
FILE *f = fopen(path, "w");
if (!f) return;
pthread_mutex_lock(&notif_mutex);
fputc('[', f);
int start = (g_notif_count < MAX_NOTIF) ? 0 : g_notif_tail;
for (int i = 0; i < g_notif_count; i++) {
Notification *n = &g_notifs[(start + i) % MAX_NOTIF];
if (i > 0) fputc(',', f);
fprintf(f, "{\"ts\":%ld,\"type\":\"%s\",\"msg\":", (long)n->ts, n->type);
fwrite_json_str(f, n->msg);
fputc('}', f);
}
fprintf(f, "]\n");
pthread_mutex_unlock(&notif_mutex);
fclose(f);
}
void notify_load(void) {
char path[512]; config_notif_path(path, sizeof(path));
FILE *f = fopen(path, "r");
if (!f) return;
fseek(f, 0, SEEK_END); long sz = ftell(f); fseek(f, 0, SEEK_SET);
char *buf = malloc(sz + 1);
if (!buf) { fclose(f); return; }
fread(buf, 1, sz, f); buf[sz] = 0; fclose(f);
int n; char **arr = json_array(buf, &n);
free(buf);
if (!arr) return;
for (int i = 0; i < n; i++) {
char *ts_s = json_str(arr[i], "ts");
char *type = json_str(arr[i], "type");
char *msg = json_str(arr[i], "msg");
if (ts_s && type && msg) {
Notification *nf = &g_notifs[g_notif_tail];
nf->ts = (time_t)atol(ts_s);
strncpy(nf->type, type, sizeof(nf->type)-1);
strncpy(nf->msg, msg, sizeof(nf->msg)-1);
g_notif_tail = (g_notif_tail + 1) % MAX_NOTIF;
if (g_notif_count < MAX_NOTIF) g_notif_count++;
}
free(ts_s); free(type); free(msg); free(arr[i]);
}
free(arr);
fprintf(stderr, "iptv-dl: loaded %d notifications\n", g_notif_count);
}
int notify_dismiss(time_t ts) {
pthread_mutex_lock(&notif_mutex);
int start = (g_notif_count < MAX_NOTIF) ? 0 : g_notif_tail;
int found = 0;
for (int i = 0; i < g_notif_count; i++) {
int idx = (start + i) % MAX_NOTIF;
if (g_notifs[idx].ts == ts) {
for (int j = i; j < g_notif_count - 1; j++) {
int cur = (start + j) % MAX_NOTIF;
int nxt = (start + j + 1) % MAX_NOTIF;
g_notifs[cur] = g_notifs[nxt];
}
g_notif_count--;
if (g_notif_tail == 0) g_notif_tail = MAX_NOTIF - 1;
else g_notif_tail--;
found = 1;
break;
}
}
pthread_mutex_unlock(&notif_mutex);
if (found) notify_save();
return found;
}
+19
View File
@@ -0,0 +1,19 @@
#pragma once
#include <time.h>
typedef struct {
time_t ts;
char type[16]; /* "done" | "error" */
char msg[512];
} Notification;
extern Notification g_notifs[/* MAX_NOTIF */];
extern int g_notif_count;
extern int g_notif_tail;
void notify_add(const char *msg, const char *type);
void notify_save(void);
void notify_load(void);
/* Dismiss by timestamp; returns 1 if found */
int notify_dismiss(time_t ts);
BIN
View File
Binary file not shown.
+301
View File
@@ -0,0 +1,301 @@
#include "queue.h"
#include "config.h"
#include "json.h"
#include "discord.h"
#include "notify.h"
#include "jellyfin.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <time.h>
#include <ctype.h>
#include <sys/stat.h>
#include <curl/curl.h>
Download g_downloads[MAX_DL];
int g_dl_count = 0;
pthread_mutex_t g_dl_mutex = PTHREAD_MUTEX_INITIALIZER;
QNode *g_q_head = NULL;
QNode *g_q_tail = NULL;
pthread_mutex_t g_q_mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t g_q_cond = PTHREAD_COND_INITIALIZER;
/* ── History ──────────────────────────────────────────────────── */
void history_save(void) {
char path[512]; config_history_path(path, sizeof(path));
FILE *f = fopen(path, "w");
if (!f) return;
pthread_mutex_lock(&g_dl_mutex);
fputc('[', f);
for (int i = 0; i < g_dl_count; i++) {
if (i > 0) fputc(',', f);
fprintf(f, "{\"id\":"); fwrite_json_str(f, g_downloads[i].id);
fprintf(f, ",\"name\":"); fwrite_json_str(f, g_downloads[i].name);
fprintf(f, ",\"status\":"); fwrite_json_str(f, g_downloads[i].status);
fprintf(f, ",\"cover_url\":"); fwrite_json_str(f, g_downloads[i].cover_url);
fprintf(f, ",\"url\":"); fwrite_json_str(f, g_downloads[i].url);
fprintf(f, ",\"dest_dir\":"); fwrite_json_str(f, g_downloads[i].dest_dir);
fprintf(f, ",\"filename\":"); fwrite_json_str(f, g_downloads[i].filename);
fprintf(f, ",\"downloaded\":%ld,\"total\":%ld}", g_downloads[i].downloaded, g_downloads[i].total);
}
fprintf(f, "]\n");
pthread_mutex_unlock(&g_dl_mutex);
fclose(f);
}
void history_load(void) {
char path[512]; config_history_path(path, sizeof(path));
FILE *f = fopen(path, "r");
if (!f) return;
fseek(f, 0, SEEK_END); long sz = ftell(f); fseek(f, 0, SEEK_SET);
char *buf = malloc(sz + 1);
if (!buf) { fclose(f); return; }
fread(buf, 1, sz, f); buf[sz] = 0; fclose(f);
int n; char **arr = json_array(buf, &n);
free(buf);
if (!arr) return;
for (int i = 0; i < n && g_dl_count < MAX_DL; i++) {
char *id = json_str(arr[i], "id");
char *name = json_str(arr[i], "name");
char *status = json_str(arr[i], "status");
char *cover = json_str(arr[i], "cover_url");
char *url = json_str(arr[i], "url");
char *destdir = json_str(arr[i], "dest_dir");
char *fname = json_str(arr[i], "filename");
char *dl_s = json_str(arr[i], "downloaded");
char *tot_s = json_str(arr[i], "total");
if (id && name && status) {
Download *d = &g_downloads[g_dl_count++];
d->active = 1; d->cancelled = 0;
strncpy(d->id, id, 63);
strncpy(d->name, name, 255);
strncpy(d->status, status, 63);
strncpy(d->cover_url,cover?cover:"", 511);
strncpy(d->url, url?url:"", 511);
strncpy(d->dest_dir, destdir?destdir:"", 511);
strncpy(d->filename, fname?fname:"", 255);
d->downloaded = dl_s ? atol(dl_s) : 0;
d->total = tot_s ? atol(tot_s) : 0;
d->speed_bps = 0; d->speed_ts = 0; d->speed_bytes = 0;
if (strcmp(d->status,"downloading")==0 || strcmp(d->status,"queued")==0)
strncpy(d->status, "interrupted", 63);
}
free(id); free(name); free(status); free(cover);
free(url); free(destdir); free(fname); free(dl_s); free(tot_s);
free(arr[i]);
}
free(arr);
fprintf(stderr, "iptv-dl: loaded %d history entries\n", g_dl_count);
}
void clean_partials(void) {
char cmd[512];
snprintf(cmd, sizeof(cmd), "find '%s' '%s' -name '*.part' -delete 2>/dev/null",
g_cfg.dl_dir_tv, g_cfg.dl_dir_mov);
system(cmd);
}
/* ── Download worker ──────────────────────────────────────────── */
static int xprogress(void *ud, curl_off_t total, curl_off_t now,
curl_off_t ul_total, curl_off_t ul_now) {
(void)ul_total; (void)ul_now;
const char *id = (const char*)ud;
int cancel = 0;
time_t cur = time(NULL);
pthread_mutex_lock(&g_dl_mutex);
for (int i = 0; i < g_dl_count; i++) {
if (strcmp(g_downloads[i].id, id) == 0) {
g_downloads[i].downloaded = (long)now;
g_downloads[i].total = (long)total;
cancel = g_downloads[i].cancelled;
if (g_downloads[i].speed_ts && cur != g_downloads[i].speed_ts) {
long dt = (long)(cur - g_downloads[i].speed_ts);
long db = (long)now - g_downloads[i].speed_bytes;
if (dt > 0 && db >= 0) g_downloads[i].speed_bps = (float)db / dt;
}
if (cur != g_downloads[i].speed_ts) {
g_downloads[i].speed_ts = cur;
g_downloads[i].speed_bytes = (long)now;
}
break;
}
}
pthread_mutex_unlock(&g_dl_mutex);
return cancel;
}
static void do_download(DlTask *t) {
char cmd[1024];
snprintf(cmd, sizeof(cmd), "mkdir -p '%s'", t->dest_dir);
system(cmd);
char dest[768];
snprintf(dest, sizeof(dest), "%s/%s", t->dest_dir, t->filename);
char dest_part[780];
snprintf(dest_part, sizeof(dest_part), "%s.part", dest);
pthread_mutex_lock(&g_dl_mutex);
int already_cancelled = 0;
for (int i = 0; i < g_dl_count; i++) {
if (strcmp(g_downloads[i].id, t->id) == 0) {
if (g_downloads[i].cancelled) {
strncpy(g_downloads[i].status, "cancelled", 63);
already_cancelled = 1;
} else {
strncpy(g_downloads[i].status, "downloading", 63);
g_downloads[i].speed_bps = 0; g_downloads[i].speed_ts = 0; g_downloads[i].speed_bytes = 0;
}
break;
}
}
pthread_mutex_unlock(&g_dl_mutex);
if (already_cancelled) { history_save(); free(t); return; }
history_save();
CURL *c = curl_easy_init();
FILE *f = fopen(dest_part, "wb");
if (!f) {
pthread_mutex_lock(&g_dl_mutex);
for (int i = 0; i < g_dl_count; i++) {
if (strcmp(g_downloads[i].id, t->id) == 0) {
snprintf(g_downloads[i].status, 63, "error: can't open file"); break;
}
}
pthread_mutex_unlock(&g_dl_mutex);
history_save(); curl_easy_cleanup(c); free(t); return;
}
curl_easy_setopt(c, CURLOPT_URL, t->url);
curl_easy_setopt(c, CURLOPT_WRITEDATA, f);
curl_easy_setopt(c, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(c, CURLOPT_TIMEOUT, CURL_DL_TIMEOUT);
curl_easy_setopt(c, CURLOPT_XFERINFOFUNCTION, xprogress);
curl_easy_setopt(c, CURLOPT_XFERINFODATA, t->id);
curl_easy_setopt(c, CURLOPT_NOPROGRESS, 0L);
if (g_cfg.bind_iface[0])
curl_easy_setopt(c, CURLOPT_INTERFACE, g_cfg.bind_iface);
if (g_cfg.max_recv_speed > 0)
curl_easy_setopt(c, CURLOPT_MAX_RECV_SPEED_LARGE, (curl_off_t)g_cfg.max_recv_speed);
CURLcode res = curl_easy_perform(c);
fclose(f);
curl_easy_cleanup(c);
struct stat _st; int _nonempty = (stat(dest_part, &_st) == 0 && _st.st_size > 0);
char notify_msg[512] = ""; int scan_jf = 0;
pthread_mutex_lock(&g_dl_mutex);
for (int i = 0; i < g_dl_count; i++) {
if (strcmp(g_downloads[i].id, t->id) == 0) {
g_downloads[i].speed_bps = 0;
if (g_downloads[i].cancelled) {
strncpy(g_downloads[i].status, "cancelled", 63);
remove(dest_part);
} else if (res == CURLE_OK && _nonempty) {
rename(dest_part, dest);
strncpy(g_downloads[i].status, "done", 63);
snprintf(notify_msg, sizeof(notify_msg),
":white_check_mark: Downloaded: **%s**", t->name);
scan_jf = 1;
} else if (res == CURLE_OK && !_nonempty) {
strncpy(g_downloads[i].status, "error: empty file (bad url?)", 63);
remove(dest_part);
snprintf(notify_msg, sizeof(notify_msg),
":x: Download failed: **%s** — empty file", t->name);
} else {
remove(dest_part);
snprintf(g_downloads[i].status, 63, "error: %s", curl_easy_strerror(res));
snprintf(notify_msg, sizeof(notify_msg),
":x: Download failed: **%s** — %s", t->name, curl_easy_strerror(res));
}
break;
}
}
pthread_mutex_unlock(&g_dl_mutex);
history_save();
if (notify_msg[0]) {
discord_notify(notify_msg);
notify_add(notify_msg, scan_jf ? "done" : "error");
}
if (scan_jf) { trigger_jellyfin_scan(); update_show_manifest(t->dest_dir, t->name); }
free(t);
}
void *dl_worker(void *arg) {
(void)arg;
for (;;) {
pthread_mutex_lock(&g_q_mutex);
while (!g_q_head) pthread_cond_wait(&g_q_cond, &g_q_mutex);
QNode *node = g_q_head;
g_q_head = g_q_head->next;
if (!g_q_head) g_q_tail = NULL;
pthread_mutex_unlock(&g_q_mutex);
do_download(node->task);
free(node);
}
return NULL;
}
/* ── Queue entry point ────────────────────────────────────────── */
void queue_download(const char *stream_id, const char *filename,
const char *dest_dir, const char *name,
int is_movie, const char *cover_url) {
DlTask *t = calloc(1, sizeof(DlTask));
if (is_movie)
snprintf(t->url, sizeof(t->url), "%s/movie/%s/%s/%s",
g_cfg.stream_base, g_cfg.iptv_user, g_cfg.iptv_pass, stream_id);
else
snprintf(t->url, sizeof(t->url), "%s/series/%s/%s/%s",
g_cfg.stream_base, g_cfg.iptv_user, g_cfg.iptv_pass, stream_id);
strncpy(t->dest_dir, dest_dir, sizeof(t->dest_dir)-1);
strncpy(t->filename, filename, sizeof(t->filename)-1);
strncpy(t->name, name, sizeof(t->name)-1);
strncpy(t->id, stream_id, sizeof(t->id)-1);
strncpy(t->cover_url, cover_url?cover_url:"", sizeof(t->cover_url)-1);
char full_path[800];
snprintf(full_path, sizeof(full_path), "%s/%s", dest_dir, filename);
struct stat _exist;
if (stat(full_path, &_exist) == 0 && _exist.st_size > 0) { free(t); return; }
pthread_mutex_lock(&g_dl_mutex);
for (int i = 0; i < g_dl_count; i++) {
if (strcmp(g_downloads[i].id, stream_id) == 0 &&
(strcmp(g_downloads[i].status, "queued") == 0 ||
strcmp(g_downloads[i].status, "downloading") == 0)) {
pthread_mutex_unlock(&g_dl_mutex);
free(t); return;
}
}
if (g_dl_count < MAX_DL) {
Download *d = &g_downloads[g_dl_count++];
d->active = 1; d->cancelled = 0;
strncpy(d->id, stream_id, 63);
strncpy(d->name, name, 255);
strncpy(d->status, "queued", 63);
strncpy(d->cover_url,cover_url?cover_url:"", 511);
strncpy(d->url, t->url, 511);
strncpy(d->dest_dir, dest_dir, 511);
strncpy(d->filename, filename, 255);
d->is_movie = is_movie;
d->downloaded = 0; d->total = 0; d->speed_bps = 0; d->speed_ts = 0; d->speed_bytes = 0;
}
pthread_mutex_unlock(&g_dl_mutex);
history_save();
QNode *node = malloc(sizeof(QNode));
node->task = t; node->next = NULL;
pthread_mutex_lock(&g_q_mutex);
if (g_q_tail) g_q_tail->next = node; else g_q_head = node;
g_q_tail = node;
pthread_cond_signal(&g_q_cond);
pthread_mutex_unlock(&g_q_mutex);
}
+56
View File
@@ -0,0 +1,56 @@
#pragma once
#include <time.h>
#include <pthread.h>
typedef struct {
int active;
int cancelled;
char id[64];
char name[256];
char status[64];
char cover_url[512];
char url[512];
char dest_dir[512];
char filename[256];
int is_movie;
long downloaded;
long total;
time_t speed_ts;
long speed_bytes;
float speed_bps;
} Download;
typedef struct {
char url[512];
char dest_dir[512];
char filename[256];
char name[256];
char id[64];
char cover_url[512];
} DlTask;
typedef struct QNode { DlTask *task; struct QNode *next; } QNode;
extern Download g_downloads[/* MAX_DL */];
extern int g_dl_count;
extern pthread_mutex_t g_dl_mutex;
extern QNode *g_q_head;
extern QNode *g_q_tail;
extern pthread_mutex_t g_q_mutex;
extern pthread_cond_t g_q_cond;
/* Enqueue a download; skips if file exists or already queued/downloading */
void queue_download(const char *stream_id, const char *filename,
const char *dest_dir, const char *name,
int is_movie, const char *cover_url);
/* Worker thread entry point */
void *dl_worker(void *arg);
/* History persistence */
void history_save(void);
void history_load(void);
/* Delete orphaned .part files from both dl dirs */
void clean_partials(void);
BIN
View File
Binary file not shown.
Executable
+2
View File
@@ -0,0 +1,2 @@
#!/bin/sh
exec /usr/local/bin/iptv-dl 2>&1
+44
View File
@@ -0,0 +1,44 @@
#include "server.h"
#include "http.h"
#include "handlers.h"
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
void *handle_conn(void *arg) {
int fd = *(int*)arg; free(arg);
Req req;
if (parse_request(fd, &req) < 0) { close(fd); return NULL; }
if (strcmp(req.method, "GET") == 0) {
if (!strcmp(req.path,"/") || !strcmp(req.path,"")) handle_index(fd);
else if (!strcmp(req.path,"/iptv.css")) send_css(fd);
else if (!strcmp(req.path,"/series")) handle_series_cats(fd);
else if (!strcmp(req.path,"/series/cat")) handle_series_list(fd, req.query);
else if (!strcmp(req.path,"/series/show")) handle_series_show(fd, req.query);
else if (!strcmp(req.path,"/movies")) handle_movie_cats(fd);
else if (!strcmp(req.path,"/movies/cat")) handle_movie_list(fd, req.query);
else if (!strcmp(req.path,"/search")) handle_search(fd, req.query);
else if (!strcmp(req.path,"/downloads")) handle_downloads(fd);
else if (!strcmp(req.path,"/api/downloads")) handle_api_downloads(fd);
else if (!strcmp(req.path,"/api/notifications")) handle_api_notifications(fd);
else {
/* try static JS files */
send_static_js(fd, req.path);
}
} else if (strcmp(req.method, "POST") == 0) {
if (!strcmp(req.path,"/api/download")) handle_api_download(fd, req.body);
else if (!strcmp(req.path,"/api/download_movie")) handle_api_download_movie(fd, req.body);
else if (!strcmp(req.path,"/api/cancel")) handle_api_cancel(fd, req.body);
else if (!strcmp(req.path,"/api/clear")) handle_api_clear(fd);
else if (!strcmp(req.path,"/api/clear-cancelled")) handle_api_clear_cancelled(fd);
else if (!strcmp(req.path,"/api/retry-interrupted")) handle_api_retry_interrupted(fd);
else if (!strcmp(req.path,"/api/clean-partials")) handle_api_clean_partials(fd);
else if (!strcmp(req.path,"/api/notifications/test")) handle_api_notifications_test(fd);
else if (!strcmp(req.path,"/api/notifications/dismiss")) handle_api_notifications_dismiss(fd, req.body);
else send_404(fd);
} else send_404(fd);
close(fd);
return NULL;
}
+2
View File
@@ -0,0 +1,2 @@
#pragma once
void *handle_conn(void *arg);
BIN
View File
Binary file not shown.
+49
View File
@@ -0,0 +1,49 @@
/* ── Downloads page ─────────────────────────────────────────── */
function escH(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function fmtSpeed(b) { return b>1048576?(b/1048576).toFixed(1)+' MB/s':b>1024?(b/1024).toFixed(0)+' KB/s':b+' B/s'; }
function fmtEta(s) {
if (s < 0) return '';
if (s < 60) return s + 's';
if (s < 3600) return Math.floor(s/60) + 'm ' + (s%60) + 's';
return Math.floor(s/3600) + 'h ' + Math.floor((s%3600)/60) + 'm';
}
function renderDownloads() {
fetch(BASE + '/api/downloads', {cache: 'no-store'})
.then(function(r) { return r.json(); })
.then(function(dl) {
var el = document.getElementById('dl-table');
if (!dl.length) { el.innerHTML = '<p style="color:var(--dim)">No downloads yet.</p>'; return; }
var html = '<table><tr><th></th><th>Name</th><th>Status</th><th>Progress</th><th>Speed</th><th>ETA</th><th></th></tr>';
dl.slice().reverse().forEach(function(d) {
var pct = d.pct || 0, mb = (d.downloaded/1048576).toFixed(1);
var cls = d.status==='done' ? 'done' : d.status.startsWith('error') ? 'err' : d.status==='downloading' ? 'dl' : '';
var thumb = d.cover_url
? '<img src="' + escH(d.cover_url) + '" style="width:32px;height:48px;object-fit:cover;border-radius:3px" onerror="this.style.display=\'none\'">'
: '<span style="color:var(--dim);font-size:18px">&#9723;</span>';
html += '<tr><td>' + thumb + '</td><td>' + escH(d.name) + '</td><td class=' + cls + '>' + escH(d.status) + '</td><td>';
if (d.status === 'downloading')
html += '<div class=pbar><div class=pfill style="width:' + pct + '%"></div></div>&nbsp;' + pct + '% (' + mb + 'MB)';
else html += pct + '%';
html += '</td><td>' + (d.status==='downloading'&&d.speed_bps>0 ? fmtSpeed(d.speed_bps) : '—') + '</td>';
html += '<td>' + (d.status==='downloading'&&d.eta_s>=0 ? fmtEta(d.eta_s) : '—') + '</td><td>';
if (d.status==='downloading' || d.status==='queued')
html += '<button class="btn btn-s" onclick="cancelDl(\'' + d.id + '\')" style="padding:2px 8px">✕</button>';
html += '</td></tr>';
});
html += '</table>';
el.innerHTML = html;
}).catch(function() {});
}
function cancelDl(id) {
fetch(BASE + '/api/cancel', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({id:id})})
.then(function() { setTimeout(renderDownloads, 400); });
}
function clearDl() { fetch(BASE + '/api/clear', {method:'POST'}).then(function() { setTimeout(renderDownloads, 400); }); }
function clearCancelled() { fetch(BASE + '/api/clear-cancelled', {method:'POST'}).then(function() { setTimeout(renderDownloads, 400); }); }
function retryInterrupted() { fetch(BASE + '/api/retry-interrupted', {method:'POST'}).then(function() { setTimeout(renderDownloads, 400); }); }
function cleanPartials() { fetch(BASE + '/api/clean-partials', {method:'POST'}).then(function() { renderDownloads(); }); }
renderDownloads();
setInterval(renderDownloads, 3000);
+3
View File
@@ -0,0 +1,3 @@
</div>
</body>
</html>
+17
View File
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{TITLE}} — IPTV</title>
<link rel="stylesheet" href="/iptv.css">
</head>
<body>
<nav class="topnav">
<a href="/series" class="nav-item">Series</a>
<a href="/movies" class="nav-item">Movies</a>
<a href="/search" class="nav-item">Search</a>
<a href="/downloads" class="nav-item">Downloads</a>
</nav>
<div class="container">
<script src="/iptv.js"></script>
+41
View File
@@ -0,0 +1,41 @@
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;700&display=swap');
:root{--black:#000;--offblack:#0a0a0a;--terminal:#ccc;--dim:#555;--border:#171717;--strawberry:#e8547a;--green:#4ade80}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--black);color:var(--terminal);font-family:'JetBrains Mono',monospace;min-height:100vh;padding:24px 16px 48px;-webkit-font-smoothing:antialiased}
header{border-bottom:1px solid var(--border);padding-bottom:20px;margin-bottom:24px;display:flex;align-items:baseline;gap:16px}
header h1{font-size:22px;font-weight:700;letter-spacing:-.04em;color:#fff}
nav{display:flex;gap:6px;flex-wrap:wrap}
nav a{font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--dim);text-decoration:none;padding:4px 10px;border:1px solid var(--border);border-radius:6px;transition:border-color .15s,color .15s}
nav a:hover{border-color:var(--strawberry);color:var(--strawberry)}
.c{max-width:960px;margin:0 auto}
.lbl{font-size:9px;letter-spacing:.1em;text-transform:uppercase;color:var(--dim);margin-bottom:12px}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:10px;margin-bottom:28px}
.card{background:var(--offblack);border:1px solid var(--border);border-radius:12px;padding:14px 12px 10px;cursor:pointer;text-decoration:none;display:block;color:var(--terminal);transition:border-color .15s}
.card:hover{border-color:#2a2a2a}
.card:active{background:#0f0f0f}
.card-img{width:100%;aspect-ratio:2/3;background:var(--border);border-radius:6px;overflow:hidden;margin-bottom:8px}
.card-img img{width:100%;height:100%;object-fit:cover;display:block}
.card-name{font-size:12px;font-weight:700;color:#fff;letter-spacing:-.01em;margin-bottom:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.card-sub{font-size:10px;color:var(--dim);letter-spacing:.04em}
table{width:100%;border-collapse:collapse;font-size:12px}
th{text-align:left;padding:8px 10px;background:var(--offblack);border-bottom:1px solid var(--border);font-size:9px;letter-spacing:.08em;text-transform:uppercase;color:var(--dim);position:sticky;top:0}
td{padding:8px 10px;border-bottom:1px solid var(--border);vertical-align:middle}
tr:hover td{background:var(--offblack)}
.btn{display:inline-block;padding:4px 12px;border-radius:6px;border:1px solid var(--border);cursor:pointer;font-size:10px;font-family:inherit;letter-spacing:.06em;text-transform:uppercase;text-decoration:none;transition:border-color .15s,color .15s;background:transparent;color:var(--terminal)}
.btn:hover{border-color:var(--strawberry);color:var(--strawberry)}
.btn-g{border-color:var(--green);color:var(--green)}.btn-g:hover{border-color:#22d360;color:#22d360}
.btn-s{border-color:var(--strawberry);color:var(--strawberry)}.btn-r{border-color:#ef4444;color:#ef4444}.btn-r:hover{border-color:#f87171;color:#f87171}
input[type=checkbox]{cursor:pointer;accent-color:var(--strawberry)}
.search{width:100%;padding:8px 12px;background:var(--offblack);border:1px solid var(--border);color:var(--terminal);border-radius:8px;font-size:12px;font-family:inherit;margin-bottom:14px;outline:none;transition:border-color .15s}
.search:focus{border-color:#2a2a2a}
select{background:var(--offblack);border:1px solid var(--border);color:var(--terminal);border-radius:8px;font-size:12px;font-family:inherit;padding:8px 12px;outline:none;cursor:pointer}
.pbar{background:var(--border);border-radius:2px;height:2px;overflow:hidden;min-width:80px;display:inline-block;vertical-align:middle}
.pfill{background:var(--green);height:100%}
.done{color:var(--green)}.err{color:var(--strawberry)}.dl{color:#fbbf24}
.bc{font-size:10px;color:var(--dim);letter-spacing:.04em;margin-bottom:16px}
.bc a{color:var(--strawberry);text-decoration:none}
.sep{border:none;border-top:1px solid var(--border);margin:20px 0}
.sform{display:flex;gap:8px;margin-bottom:20px;flex-wrap:wrap}
.sform input{flex:1;min-width:160px;margin-bottom:0}
.sform select{margin-bottom:0}
@media(min-width:480px){.grid{grid-template-columns:repeat(auto-fill,minmax(160px,1fr))}}
+20
View File
@@ -0,0 +1,20 @@
/* ── Shared IPTV downloader JS ──────────────────────────────── */
var BASE = (function(){
var m = window.location.pathname.match(/^(\/[^/]+)/);
return m ? m[1] : '';
})();
function filter(el) {
document.querySelectorAll('.card').forEach(function(c) {
c.style.display = c.textContent.toLowerCase().includes(el.value.toLowerCase()) ? '' : 'none';
});
}
function dlMov(id, title, ext, icon) {
if (!confirm('Download: ' + title + '?')) return;
fetch(BASE + '/api/download_movie', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({id: id, title: title, ext: ext || 'mp4', cover_url: icon || ''})
}).then(function() { alert('Queued!'); location.href = BASE + '/downloads'; });
}
+65
View File
@@ -0,0 +1,65 @@
/* ── Series episode selector ──────────────────────────────────── */
/* TITLE, COVER, SEASONS injected by server as window vars before this script */
var tabs = document.getElementById('tabs');
var sdiv = document.getElementById('seasons');
Object.keys(SEASONS).sort(function(a,b){return +a-+b;}).forEach(function(s) {
var btn = document.createElement('button');
btn.className = 'btn b'; btn.textContent = 'S' + s.padStart(2,'0');
btn.style.marginRight = '6px';
btn.onclick = function() { showSeason(s); };
tabs.appendChild(btn);
var div = document.createElement('div'); div.id = 's-' + s; div.style.display = 'none';
var html = '<div style="margin:8px 0">'
+ '<button class="btn btn-s" onclick="selAll(\'' + s + '\',true)">All S' + s.padStart(2,'0') + '</button> '
+ '<button class="btn g" onclick="dlSeason(\'' + s + '\')" style="margin-left:6px">⬇ Season ' + s + '</button></div>';
html += '<table><tr><th><input type=checkbox onchange="selAll(\'' + s + '\',this.checked)"></th>'
+ '<th>Ep</th><th>Title</th><th>Duration</th></tr>';
SEASONS[s].forEach(function(e) {
var epn = e.episode_num || e.episodeNum || '?';
var dur = (e.info && e.info.duration) || '';
var ext = e.container_extension || 'mp4';
html += '<tr>'
+ '<td><input type=checkbox class="ep-cb s-cb-' + s + '" data-id="' + e.id + '" data-ep="' + epn + '" data-s="' + s + '" data-ext="' + ext + '"></td>'
+ '<td>E' + String(epn).padStart(2,'0') + '</td>'
+ '<td>' + (e.title||'') + '</td>'
+ '<td>' + dur + '</td></tr>';
});
html += '</table>';
div.innerHTML = html;
sdiv.appendChild(div);
});
var firstS = Object.keys(SEASONS).sort(function(a,b){return +a-+b;})[0];
if (firstS) showSeason(firstS);
function showSeason(s) {
document.querySelectorAll('[id^=s-]').forEach(function(d) { d.style.display = 'none'; });
var d = document.getElementById('s-' + s); if (d) d.style.display = '';
}
function selAll(s, v) {
var all = Array.from(document.querySelectorAll('.s-cb-' + s));
var checked = v === undefined ? !all.every(function(x){return x.checked;}) : v;
all.forEach(function(c) { c.checked = checked; });
}
function dlSeason(s) { selAll(s, true); dlSelected(); }
function dlSelected() {
var eps = Array.from(document.querySelectorAll('.ep-cb:checked')).map(function(c) {
return {id: c.dataset.id, ep: c.dataset.ep, s: c.dataset.s, ext: c.dataset.ext};
});
if (!eps.length) { alert('Select episodes first'); return; }
var bySeason = {};
eps.forEach(function(e) { (bySeason[e.s] = bySeason[e.s]||[]).push({id:e.id, episode_num:e.ep, ext:e.ext}); });
Promise.all(Object.keys(bySeason).map(function(s) {
return fetch(BASE + '/api/download', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({episodes: bySeason[s], series_title: TITLE, season: s, cover_url: COVER})
}).then(function(r) { return r.json(); });
})).then(function() { alert('Queued!'); location.href = BASE + '/downloads'; });
}