initial: modular iptv-dl with runtime config from ~/.iptv-downloader/config.json
This commit is contained in:
@@ -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
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)));
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
</div></body></html>
|
||||
+454
@@ -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
@@ -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
Binary file not shown.
+15
@@ -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>
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
@@ -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 */
|
||||
}
|
||||
@@ -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
Binary file not shown.
+74
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
#pragma once
|
||||
void trigger_jellyfin_scan(void);
|
||||
void update_show_manifest(const char *season_dir, const char *title_hint);
|
||||
BIN
Binary file not shown.
@@ -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, "<");
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(¬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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/* ── Downloads page ─────────────────────────────────────────── */
|
||||
function escH(s) { return String(s).replace(/&/g,'&').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 = '<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">◻</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> ' + 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);
|
||||
@@ -0,0 +1,3 @@
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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>
|
||||
@@ -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))}}
|
||||
@@ -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'; });
|
||||
}
|
||||
@@ -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'; });
|
||||
}
|
||||
Reference in New Issue
Block a user