fix: inject <base href> from base_path config; fix relative asset/nav links; add README
This commit is contained in:
@@ -0,0 +1,100 @@
|
|||||||
|
# iptv-downloader
|
||||||
|
|
||||||
|
A lightweight HTTP server for browsing and downloading content from an Xtream Codes IPTV provider. Written in C, zero runtime dependencies beyond libcurl and pthreads.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Browse series and movies by category
|
||||||
|
- Full-text search across the entire catalogue
|
||||||
|
- Sequential download queue with progress tracking, speed and ETA
|
||||||
|
- Resume interrupted downloads; skip already-downloaded files
|
||||||
|
- Discord webhook notifications on completion or failure
|
||||||
|
- Jellyfin library refresh triggered after each successful download
|
||||||
|
- Notification history with dismiss support
|
||||||
|
- Runtime configuration — no recompile needed to change provider or paths
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- GCC, make
|
||||||
|
- libcurl (development headers)
|
||||||
|
- Linux (uses `SO_BINDTODEVICE` for VPN binding)
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make
|
||||||
|
make test # optional — runs unit tests
|
||||||
|
```
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo make install
|
||||||
|
# installs binary to /usr/local/bin/iptv-dl
|
||||||
|
# installs static files to /usr/local/share/iptv-dl/{css,js,html}/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Create a config file at one of the following locations (first found wins):
|
||||||
|
|
||||||
|
1. Path passed via `--config PATH`
|
||||||
|
2. `$IPTV_DL_CONFIG` environment variable
|
||||||
|
3. `~/.iptv-downloader/config.json`
|
||||||
|
4. `/etc/iptv-downloader/config.json`
|
||||||
|
5. Built-in defaults (runs without any config file)
|
||||||
|
|
||||||
|
Example config:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"port": 8787,
|
||||||
|
"bind_iface": "wg0-mullvad",
|
||||||
|
"iptv_api": "http://your.provider/player_api.php",
|
||||||
|
"iptv_user": "username",
|
||||||
|
"iptv_pass": "password",
|
||||||
|
"stream_base": "http://your.provider:80",
|
||||||
|
"dl_dir_tv": "/mnt/media/TV",
|
||||||
|
"dl_dir_mov": "/mnt/media/Movies",
|
||||||
|
"discord_webhook": "",
|
||||||
|
"jellyfin_url": "",
|
||||||
|
"jellyfin_token": "",
|
||||||
|
"template_dir": "/usr/local/share/iptv-dl",
|
||||||
|
"data_dir": "/var/lib/iptv-dl",
|
||||||
|
"max_recv_speed": 20971520
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Print the active config:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
iptv-dl --dump-config
|
||||||
|
```
|
||||||
|
|
||||||
|
## Source layout
|
||||||
|
|
||||||
|
```
|
||||||
|
config/ Runtime configuration (load/save/defaults)
|
||||||
|
util/ String buffer, JSON parser, URL/HTML/JS escaping
|
||||||
|
http/ HTTP request parsing, response helpers, route handlers
|
||||||
|
notifs/ Notification ring buffer with persistence
|
||||||
|
queue/ Download queue, worker thread, history, Xtream Codes API client
|
||||||
|
integrations/ Discord webhook, Jellyfin library refresh
|
||||||
|
static/css/ Stylesheet
|
||||||
|
static/js/ Shared JS (BASE, filter, dlMov), downloads page, series selector
|
||||||
|
static/html/ Page header and footer templates
|
||||||
|
tests/ Unit tests
|
||||||
|
main.c Server entry point (~80 lines)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```sh
|
||||||
|
iptv-dl [--config PATH] [--dump-config]
|
||||||
|
```
|
||||||
|
|
||||||
|
The web UI is served at `http://localhost:8787/`. If running behind a reverse proxy at a sub-path (e.g. `/iptv/`), all asset and navigation links are relative and work correctly.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -19,6 +19,7 @@ static void set_defaults(void) {
|
|||||||
strncpy(g_cfg.dl_dir_mov, "/mnt/media/Movies", sizeof(g_cfg.dl_dir_mov)-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.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);
|
strncpy(g_cfg.data_dir, "/var/lib/iptv-dl", sizeof(g_cfg.data_dir)-1);
|
||||||
|
strncpy(g_cfg.base_path, "", sizeof(g_cfg.base_path)-1);
|
||||||
g_cfg.max_recv_speed = 20L * 1024 * 1024; /* 20 MiB/s */
|
g_cfg.max_recv_speed = 20L * 1024 * 1024; /* 20 MiB/s */
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +80,7 @@ static int try_load(const char *path) {
|
|||||||
cfg_read_str(buf, "jellyfin_token", g_cfg.jellyfin_token, sizeof(g_cfg.jellyfin_token));
|
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, "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));
|
cfg_read_str(buf, "data_dir", g_cfg.data_dir, sizeof(g_cfg.data_dir));
|
||||||
|
cfg_read_str(buf, "base_path", g_cfg.base_path, sizeof(g_cfg.base_path));
|
||||||
g_cfg.port = cfg_read_int(buf, "port", g_cfg.port);
|
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);
|
g_cfg.max_recv_speed = cfg_read_long(buf, "max_recv_speed", g_cfg.max_recv_speed);
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ typedef struct {
|
|||||||
/* Paths */
|
/* Paths */
|
||||||
char template_dir[512]; /* dir containing header.html, footer.html, iptv.css, *.js */
|
char template_dir[512]; /* dir containing header.html, footer.html, iptv.css, *.js */
|
||||||
char data_dir[512]; /* dir for history.json, notifications.json */
|
char data_dir[512]; /* dir for history.json, notifications.json */
|
||||||
|
char base_path[128]; /* URL prefix when behind reverse proxy, e.g. "/iptv" (no trailing slash) */
|
||||||
|
|
||||||
/* Limits */
|
/* Limits */
|
||||||
long max_recv_speed; /* bytes/sec, 0 = unlimited */
|
long max_recv_speed; /* bytes/sec, 0 = unlimited */
|
||||||
|
|||||||
+2
-2
@@ -117,7 +117,7 @@ void handle_series_show(int fd, const char *qs) {
|
|||||||
buf_free(&inl);
|
buf_free(&inl);
|
||||||
|
|
||||||
free(sid); free(title_raw); free(cover_raw); free(json);
|
free(sid); free(title_raw); free(cover_raw); free(json);
|
||||||
send_page(fd, "Series", &b, "/series_show.js");
|
send_page(fd, "Series", &b, "series_show.js");
|
||||||
buf_free(&b);
|
buf_free(&b);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,7 +226,7 @@ void handle_downloads(int fd) {
|
|||||||
"<button class='btn btn-r' onclick=cleanPartials()>clean partial files</button>"
|
"<button class='btn btn-r' onclick=cleanPartials()>clean partial files</button>"
|
||||||
"</div>"
|
"</div>"
|
||||||
"<div id=dl-table><p style='color:var(--dim)'>loading...</p></div>");
|
"<div id=dl-table><p style='color:var(--dim)'>loading...</p></div>");
|
||||||
send_page(fd, "Downloads", &b, "/downloads.js");
|
send_page(fd, "Downloads", &b, "downloads.js");
|
||||||
buf_free(&b);
|
buf_free(&b);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
-1
@@ -77,8 +77,19 @@ int parse_request(int fd, Req *req) {
|
|||||||
void send_page(int fd, const char *title, Buf *body, const char *script_src) {
|
void send_page(int fd, const char *title, Buf *body, const char *script_src) {
|
||||||
Buf page; buf_init(&page);
|
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");
|
buf_str(&page, "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nConnection: close\r\n\r\n");
|
||||||
|
/* inject <base href> so relative assets resolve correctly at any sub-path */
|
||||||
const char *ph = strstr(g_header, "{{TITLE}}");
|
const char *ph = strstr(g_header, "{{TITLE}}");
|
||||||
if (ph) {
|
const char *head_end = strstr(g_header, "</head>");
|
||||||
|
if (ph && head_end && head_end > ph) {
|
||||||
|
/* write up to </head>, inject base tag, then rest of header */
|
||||||
|
buf_append(&page, g_header, ph - g_header);
|
||||||
|
buf_str(&page, title);
|
||||||
|
const char *after_title = ph + 9;
|
||||||
|
buf_append(&page, after_title, head_end - after_title);
|
||||||
|
if (g_cfg.base_path[0])
|
||||||
|
buf_fmt(&page, "<base href=\"%s/\">", g_cfg.base_path);
|
||||||
|
buf_str(&page, head_end);
|
||||||
|
} else if (ph) {
|
||||||
buf_append(&page, g_header, ph - g_header);
|
buf_append(&page, g_header, ph - g_header);
|
||||||
buf_str(&page, title);
|
buf_str(&page, title);
|
||||||
buf_str(&page, ph + 9);
|
buf_str(&page, ph + 9);
|
||||||
|
|||||||
@@ -4,14 +4,14 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>{{TITLE}} — IPTV</title>
|
<title>{{TITLE}} — IPTV</title>
|
||||||
<link rel="stylesheet" href="/iptv.css">
|
<link rel="stylesheet" href="iptv.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="topnav">
|
<nav class="topnav">
|
||||||
<a href="/series" class="nav-item">Series</a>
|
<a href="series" class="nav-item">Series</a>
|
||||||
<a href="/movies" class="nav-item">Movies</a>
|
<a href="movies" class="nav-item">Movies</a>
|
||||||
<a href="/search" class="nav-item">Search</a>
|
<a href="search" class="nav-item">Search</a>
|
||||||
<a href="/downloads" class="nav-item">Downloads</a>
|
<a href="downloads" class="nav-item">Downloads</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<script src="/iptv.js"></script>
|
<script src="iptv.js"></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user