commit f8af2245807de2ff86214b85a151143c33b5c4d7 Author: rmuxnet Date: Tue Jun 9 00:31:08 2026 +0200 initial: modular iptv-dl with runtime config from ~/.iptv-downloader/config.json diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cf8bf8a --- /dev/null +++ b/Makefile @@ -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 diff --git a/alioth-smoke b/alioth-smoke new file mode 100755 index 0000000..2bff62a --- /dev/null +++ b/alioth-smoke @@ -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 diff --git a/buf.c b/buf.c new file mode 100644 index 0000000..39deaac --- /dev/null +++ b/buf.c @@ -0,0 +1,28 @@ +#include "buf.h" +#include +#include +#include +#include + +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); +} diff --git a/buf.h b/buf.h new file mode 100644 index 0000000..8d2aaad --- /dev/null +++ b/buf.h @@ -0,0 +1,10 @@ +#pragma once +#include + +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))); diff --git a/buf.o b/buf.o new file mode 100644 index 0000000..b813772 Binary files /dev/null and b/buf.o differ diff --git a/config.c b/config.c new file mode 100644 index 0000000..0ed9fac --- /dev/null +++ b/config.c @@ -0,0 +1,175 @@ +#include "config.h" +#include +#include +#include +#include +#include +#include + +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); +} diff --git a/config.h b/config.h new file mode 100644 index 0000000..0eff778 --- /dev/null +++ b/config.h @@ -0,0 +1,63 @@ +#pragma once +#include + +/* ── 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); diff --git a/config.o b/config.o new file mode 100644 index 0000000..2d99f51 Binary files /dev/null and b/config.o differ diff --git a/discord.c b/discord.c new file mode 100644 index 0000000..ec40fc7 --- /dev/null +++ b/discord.c @@ -0,0 +1,25 @@ +#include "discord.h" +#include "config.h" +#include +#include +#include + +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); +} diff --git a/discord.h b/discord.h new file mode 100644 index 0000000..af83d11 --- /dev/null +++ b/discord.h @@ -0,0 +1,2 @@ +#pragma once +void discord_notify(const char *msg); diff --git a/discord.o b/discord.o new file mode 100644 index 0000000..7cbcc31 Binary files /dev/null and b/discord.o differ diff --git a/footer.html b/footer.html new file mode 100644 index 0000000..a1f0ee4 --- /dev/null +++ b/footer.html @@ -0,0 +1 @@ + diff --git a/handlers.c b/handlers.c new file mode 100644 index 0000000..587fa32 --- /dev/null +++ b/handlers.c @@ -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 +#include +#include +#include +#include +#include +#include + +/* ── 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, "

iptv downloader

" + "

" + "Browse series, " + "movies, or " + "search by name." + "

"); + 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, "

series categories

" + "" + "
"); + 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, "
", id?id:""); + html_esc(&b, name?name:"?"); buf_str(&b, "
"); + free(id); free(name); free(arr[i]); + } + buf_str(&b, "
"); 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, "

series / category

" + "

series — %d results

", n); + buf_str(&b, "
"); + 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, "", sid?sid:""); + if (cover && cover[0]) { + buf_str(&b,"
"); + } + buf_str(&b,"
"); html_esc(&b, name?name:"?"); buf_str(&b,"
"); + free(sid); free(name); free(cover); free(arr[i]); + } + buf_str(&b, "
"); 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, "

series / "); html_esc(&b, title); + buf_str(&b, "

"); html_esc(&b, title); buf_str(&b, "

"); + buf_str(&b, "
"); + buf_str(&b, "
" + "" + "
"); + buf_str(&b, "
"); + + /* Inject TITLE, COVER, SEASONS as window vars before the external script */ + Buf inl; buf_init(&inl); + buf_str(&inl, ""); + 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, "

movie categories

" + "" + "
"); + 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, "
", id?id:""); + html_esc(&b, name?name:"?"); buf_str(&b, "
"); + free(id); free(name); free(arr[i]); + } + buf_str(&b, "
"); 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, "

movies / category

" + "

movies — %d results

", n); + buf_str(&b, "
"); + 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, ""); + if (icon && icon[0]) { + buf_str(&b,"
"); + } + buf_str(&b, "
"); html_esc(&b, name?name:"?"); buf_str(&b, "
"); + free(vid); free(name); free(ext); free(icon); free(arr[i]); + } + buf_str(&b, "
"); 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, "

search

"); + buf_str(&b, "" + "", + is_series?" selected":"", !is_series?" selected":""); + buf_str(&b, "
"); + 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, "
"); + 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, "", sid?sid:""); + if (cover && cover[0]) { buf_str(&b,"
"); } + buf_str(&b,"
"); html_esc(&b,name); buf_str(&b,"
"); + 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,""); + if (icon&&icon[0]){buf_str(&b,"
");} + buf_str(&b,"
"); html_esc(&b,name); buf_str(&b,"
"); + free(vid); free(ext); free(icon); + } + free(name); free(arr[i]); count++; + } + buf_str(&b, "
"); free(arr); free(json); + char cnt[64]; snprintf(cnt,sizeof(cnt),"

%d results

",count); + Buf out; buf_init(&out); + const char *gp = strstr(b.data,"
downloads

" + "
" + "" + "" + "" + "" + "
" + "

loading...

"); + 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}"); +} diff --git a/handlers.h b/handlers.h new file mode 100644 index 0000000..bde760f --- /dev/null +++ b/handlers.h @@ -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); diff --git a/handlers.o b/handlers.o new file mode 100644 index 0000000..a154abd Binary files /dev/null and b/handlers.o differ diff --git a/header.html b/header.html new file mode 100644 index 0000000..73f9c62 --- /dev/null +++ b/header.html @@ -0,0 +1,15 @@ + + +{{TITLE}} — iptv + + + + + +

iptv

+
diff --git a/http.c b/http.c new file mode 100644 index 0000000..8a317cd --- /dev/null +++ b/http.c @@ -0,0 +1,144 @@ +#include "http.h" +#include "config.h" /* g_cfg */ +#include +#include +#include +#include +#include + +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); + 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)); +} diff --git a/http.h b/http.h new file mode 100644 index 0000000..4181fc8 --- /dev/null +++ b/http.h @@ -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); diff --git a/http.o b/http.o new file mode 100644 index 0000000..7d6ebbb Binary files /dev/null and b/http.o differ diff --git a/iptv-dl b/iptv-dl new file mode 100755 index 0000000..a9955e6 Binary files /dev/null and b/iptv-dl differ diff --git a/iptv.css b/iptv.css new file mode 100644 index 0000000..7ef47be --- /dev/null +++ b/iptv.css @@ -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))}} diff --git a/iptv_api.c b/iptv_api.c new file mode 100644 index 0000000..494bc34 --- /dev/null +++ b/iptv_api.c @@ -0,0 +1,26 @@ +#include "iptv_api.h" +#include "config.h" +#include "buf.h" +#include +#include + +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 */ +} diff --git a/iptv_api.h b/iptv_api.h new file mode 100644 index 0000000..2c2cb0e --- /dev/null +++ b/iptv_api.h @@ -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); diff --git a/iptv_api.o b/iptv_api.o new file mode 100644 index 0000000..4392104 Binary files /dev/null and b/iptv_api.o differ diff --git a/jellyfin.c b/jellyfin.c new file mode 100644 index 0000000..d777d58 --- /dev/null +++ b/jellyfin.c @@ -0,0 +1,74 @@ +#include "jellyfin.h" +#include "config.h" +#include +#include +#include +#include +#include +#include +#include + +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); +} diff --git a/jellyfin.h b/jellyfin.h new file mode 100644 index 0000000..9437c99 --- /dev/null +++ b/jellyfin.h @@ -0,0 +1,3 @@ +#pragma once +void trigger_jellyfin_scan(void); +void update_show_manifest(const char *season_dir, const char *title_hint); diff --git a/jellyfin.o b/jellyfin.o new file mode 100644 index 0000000..025925f Binary files /dev/null and b/jellyfin.o differ diff --git a/json.c b/json.c new file mode 100644 index 0000000..1d02602 --- /dev/null +++ b/json.c @@ -0,0 +1,116 @@ +#include "json.h" +#include +#include +#include +#include + +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, "<"); + else if (*s == '>') buf_str(b, ">"); + else if (*s == '&') buf_str(b, "&"); + else if (*s == '"') buf_str(b, """); + 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; +} diff --git a/json.h b/json.h new file mode 100644 index 0000000..4da0a44 --- /dev/null +++ b/json.h @@ -0,0 +1,19 @@ +#pragma once +#include +#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); diff --git a/json.o b/json.o new file mode 100644 index 0000000..ca8e137 Binary files /dev/null and b/json.o differ diff --git a/main.c b/main.c new file mode 100644 index 0000000..ba7faf0 --- /dev/null +++ b/main.c @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include + +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); + } +} diff --git a/main.o b/main.o new file mode 100644 index 0000000..1439e11 Binary files /dev/null and b/main.o differ diff --git a/notify.c b/notify.c new file mode 100644 index 0000000..bc10145 --- /dev/null +++ b/notify.c @@ -0,0 +1,97 @@ +#include "notify.h" +#include "config.h" +#include "json.h" +#include +#include +#include +#include + +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(¬if_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(¬if_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(¬if_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(¬if_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(¬if_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(¬if_mutex); + if (found) notify_save(); + return found; +} diff --git a/notify.h b/notify.h new file mode 100644 index 0000000..3d8d693 --- /dev/null +++ b/notify.h @@ -0,0 +1,19 @@ +#pragma once +#include + +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); diff --git a/notify.o b/notify.o new file mode 100644 index 0000000..b48e512 Binary files /dev/null and b/notify.o differ diff --git a/queue.c b/queue.c new file mode 100644 index 0000000..3fd045a --- /dev/null +++ b/queue.c @@ -0,0 +1,301 @@ +#include "queue.h" +#include "config.h" +#include "json.h" +#include "discord.h" +#include "notify.h" +#include "jellyfin.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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); +} diff --git a/queue.h b/queue.h new file mode 100644 index 0000000..5f7841b --- /dev/null +++ b/queue.h @@ -0,0 +1,56 @@ +#pragma once +#include +#include + +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); diff --git a/queue.o b/queue.o new file mode 100644 index 0000000..5c750d9 Binary files /dev/null and b/queue.o differ diff --git a/runit.run b/runit.run new file mode 100755 index 0000000..37cda0b --- /dev/null +++ b/runit.run @@ -0,0 +1,2 @@ +#!/bin/sh +exec /usr/local/bin/iptv-dl 2>&1 diff --git a/server.c b/server.c new file mode 100644 index 0000000..fd6bcc2 --- /dev/null +++ b/server.c @@ -0,0 +1,44 @@ +#include "server.h" +#include "http.h" +#include "handlers.h" +#include +#include +#include + +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; +} diff --git a/server.h b/server.h new file mode 100644 index 0000000..87fccc2 --- /dev/null +++ b/server.h @@ -0,0 +1,2 @@ +#pragma once +void *handle_conn(void *arg); diff --git a/server.o b/server.o new file mode 100644 index 0000000..d53674e Binary files /dev/null and b/server.o differ diff --git a/static/downloads.js b/static/downloads.js new file mode 100644 index 0000000..ab4169f --- /dev/null +++ b/static/downloads.js @@ -0,0 +1,49 @@ +/* ── Downloads page ─────────────────────────────────────────── */ +function escH(s) { return String(s).replace(/&/g,'&').replace(//g,'>'); } +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 = '

No downloads yet.

'; return; } + var html = ''; + 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 + ? '' + : ''; + html += ''; + html += ''; + }); + html += '
NameStatusProgressSpeedETA
' + thumb + '' + escH(d.name) + '' + escH(d.status) + ''; + if (d.status === 'downloading') + html += '
 ' + pct + '% (' + mb + 'MB)'; + else html += pct + '%'; + html += '
' + (d.status==='downloading'&&d.speed_bps>0 ? fmtSpeed(d.speed_bps) : '—') + '' + (d.status==='downloading'&&d.eta_s>=0 ? fmtEta(d.eta_s) : '—') + ''; + if (d.status==='downloading' || d.status==='queued') + html += ''; + html += '
'; + 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); diff --git a/static/footer.html b/static/footer.html new file mode 100644 index 0000000..9943ff0 --- /dev/null +++ b/static/footer.html @@ -0,0 +1,3 @@ +
+ + diff --git a/static/header.html b/static/header.html new file mode 100644 index 0000000..c7a300b --- /dev/null +++ b/static/header.html @@ -0,0 +1,17 @@ + + + + + +{{TITLE}} — IPTV + + + + +
+ diff --git a/static/iptv.css b/static/iptv.css new file mode 100644 index 0000000..7ef47be --- /dev/null +++ b/static/iptv.css @@ -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))}} diff --git a/static/iptv.js b/static/iptv.js new file mode 100644 index 0000000..8c31207 --- /dev/null +++ b/static/iptv.js @@ -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'; }); +} diff --git a/static/series_show.js b/static/series_show.js new file mode 100644 index 0000000..6dad3e1 --- /dev/null +++ b/static/series_show.js @@ -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 = '
' + + ' ' + + '
'; + html += '' + + ''; + 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 += '' + + '' + + '' + + '' + + ''; + }); + html += '
EpTitleDuration
E' + String(epn).padStart(2,'0') + '' + (e.title||'') + '' + dur + '
'; + 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'; }); +}