A self-hosted IPTV playlist manager for importing, editing, organizing, exporting, and sharing M3U/Xtream playlists. It includes a responsive Vue interface, XMLTV guide support, multi-user isolation, and a production Docker stack.
- Private backup storage with legacy-file migration and blocked public backup URLs.
- Version 2 snapshots with category visibility, stable IDs on same-playlist restore, source settings, filters and views.
- Original HTTP(S) live/VOD stream URLs preserved during import; real series episodes fetched on demand from the primary Xtream source.
- Shared refresh coordination, transient-error session preservation and single-use password reset enforcement.
- Destructive scheduled tasks require a successful backup; task execution and undo operations have concurrency protection.
- Cross-origin redirects strip sensitive headers, HTTPS downgrades are rejected, and numeric proxy settings are parsed correctly.
- Updated dependencies and CI checks for PostgreSQL integration tests and accidentally tracked runtime data.
Validation for this update: 631 tests passed across 49 suites, including two real PostgreSQL integration tests; all 33 migrations applied successfully. Frontend lint/build and translation parity passed. Both dependency audits reported zero known vulnerabilities at validation time. Physical-player compatibility and production load testing remain separate checks.
Every release is documented in CHANGELOG.md, including the audit finding each change closes.
- AI assistant that drives the whole application through chat with your own OpenAI-compatible provider
- Xtream Codes imports for Live TV, VOD, and Series
- Local or remote M3U import and escaped M3U export
- XMLTV sources, source-scoped channel matching, and timezone-aware guide views
- Category and channel sorting, bulk operations, logo uploads, search, and metadata
- Short-lived access tokens with rotating HttpOnly refresh sessions
- Turkish and English interface, light/dark/system themes, keyboard navigation, and mobile layouts
- PostgreSQL migrations, structured logs, health checks, CI, and hardened containers
Clean entry page with theme-aware hero, language switcher and direct GitHub link.
Dashboard showing all playlists with channel counts, stream-type badges (LIVE / VOD / SERIES), creation dates and quick actions for editing or deleting.
One-step connect flow. Auto-fills server URL, username and password by parsing a regular get.php?... M3U URL, or fill the fields manually. Pick which content types to pull.
Three-pane editor: stream-type sidebar, category list, channels table, and an edit panel for the selected channel (logo upload, EPG ID, stream URL, category).
The same editor specialized for movies — separate categories, channel-count badges per category, and a metadata "Fetch Info" action that hydrates titles from external sources.
Multi-day TV-guide grid. Add XMLTV sources, auto-match channels by tvg-id, then visually inspect the programme schedule per channel.
Reorder categories on the left, then drag channels on the right. Order is persisted instantly.
Profile summary, theme switcher (System / Dark / Light) and password change.
- Node.js 22 or newer
- PostgreSQL 13 or newer (PostgreSQL 16 is used by Docker and CI)
- npm 10 or newer
git clone https://github.com/fyildirim-debug/M3uEditor.git
cd M3uEditor
npm ci
cd frontend
npm ci
cd ..Copy .env.example to .env, then set the database and application values.
JWT_SECRET and CREDENTIAL_ENCRYPTION_KEY must be two independently generated
values of at least 32 characters. Production startup rejects the shipped
placeholders, short values, low-entropy values, and reusing one value for both:
echo "JWT_SECRET=$(openssl rand -hex 32)"
echo "CREDENTIAL_ENCRYPTION_KEY=$(openssl rand -hex 32)"Create the database and start the application:
npm run migrate
npm run devIn a second terminal:
cd frontend
npm run devThe frontend defaults to http://localhost:5173; the API and health endpoint default to http://localhost:3000 and http://localhost:3000/health.
First create a regular user account in the application, then grant admin rights to that registered email address:
npm run make-admin -- <email>On a Docker setup, run the same command inside the API container:
docker compose exec api npm run make-admin -- <email>Append --revoke to the command to remove the privilege.
Set ALLOW_REGISTRATION=false to stop new accounts from being created. The default is true for backward compatibility. So a fresh install never locks itself out, the first user can register even when the flag is off while the users table is empty; registrations after the first user are rejected. The unauthenticated GET /api/auth/registration-status endpoint returns only the current state as { "allowed": boolean }.
When SMTP is not configured, reset a registered user's password to a strong, randomly generated value:
npm run reset-password -- <email>Append --password <value> to set a specific password. All of the user's active sessions are revoked when the password is reset. On a Docker setup, run the command inside the API container:
docker compose exec api npm run reset-password -- <email>Create .env from .env.example. Generate the two secrets independently — do not reuse one value for both, and do not ship the placeholders:
echo "JWT_SECRET=$(openssl rand -hex 32)"
echo "CREDENTIAL_ENCRYPTION_KEY=$(openssl rand -hex 32)"DB_PASSWORD=a-strong-database-password
JWT_SECRET=<output of the first command>
CREDENTIAL_ENCRYPTION_KEY=<output of the second command>
APP_URL=https://m3u.example.com
CORS_ORIGIN=https://m3u.example.com
HTTP_PORT=8080Then run:
docker compose up -d --build
docker compose psThe Compose stack serves plain HTTP only. It has no TLS listener, certificate, or HTTP-to-HTTPS redirect. Exposing that port directly to a network you do not control lets anyone on the path read login credentials, bearer tokens, and provider stream URLs, or modify responses. Refresh cookies are marked Secure in production, so sessions will not survive a direct HTTP deployment either.
Terminate TLS in front of the stack and keep the container port private:
- Bind the published port to loopback (
HTTP_PORT=8080and a127.0.0.1:8080:80mapping), or attach the frontend to an internal Docker network reachable only by your proxy. - Put Caddy, Traefik, nginx, or a cloud load balancer in front, terminating HTTPS on 443 and redirecting 80 to 443.
- Forward
X-Forwarded-ForandX-Forwarded-Protofrom that proxy, and keepTRUST_PROXYset to the number of proxies in front of the API — rate limiting keys on the client IP derived from those headers. - Enable HSTS at the proxy only after HTTPS is verified end to end.
A LAN-only deployment behind a firewall is the one case where plain HTTP is defensible, and even then any client on that LAN can read the traffic.
The API container applies pending migrations before startup. PostgreSQL is not exposed to the host. Uploaded logos and database files use named volumes. The API runs as a non-root user with a read-only root filesystem; the frontend is served by Nginx with security and cache headers.
Private-network remote URLs are blocked by default to prevent SSRF. Only set ALLOW_PRIVATE_NETWORK_URLS=true when importing from a trusted service on your own network and after understanding the exposure.
Xtream imports use one bulk stream request per selected content type and download two content types concurrently. If a provider omits categories from a bulk response, only the missing categories are fetched with bounded concurrency. Channel writes use 1,000-row PostgreSQL batches.
The Docker defaults are sized for playlists with roughly 150,000 Live/VOD/Series entries:
MAX_XTREAM_BYTES=268435456
XTREAM_CATEGORY_CONCURRENCY=8
XTREAM_TYPE_CONCURRENCY=2
NODE_MAX_OLD_SPACE_MB=1536Lower the concurrency values if a provider rate-limits parallel requests. Increase MAX_XTREAM_BYTES and NODE_MAX_OLD_SPACE_MB together only when a single provider response exceeds the defaults and the host has sufficient memory.
A playlist can be exposed as an Xtream Codes output via POST /api/playlists/:id/xtream-output on the authenticated management API. The response contains the server address, a random username, and a random password to enter into the player. The password is stored AES-256-GCM encrypted and is always shown in plain text to its owner; the configuration — including the password — is returned by every GET. POST /api/playlists/:id/xtream-output/regenerate immediately invalidates the old password, and DELETE /api/playlists/:id/xtream-output turns player access off.
- Open the playlist's download/sharing dialog and enable Xtream output.
- For a player with Xtream login, enter the displayed server URL, username and password.
- For an integration that accepts an API URL, copy Xtream Codes API link from Sharing links. It includes the credentials and points to
player_api.php; append anactionparameter to request a catalogue. - For an M3U player, copy the short M3U link. Copy the XMLTV link separately if the player asks for a programme guide.
The API link is a JSON endpoint, not an M3U file. A generic playlist URL field may only accept M3U. Playback through Xtream requires a client/version that honours direct_source; compatibility is not guaranteed by the player's brand name. If the catalogue loads but playback fails because the player builds local stream paths, use the M3U link.
Set APP_URL to the externally reachable application origin. Supported root paths are /player_api.php, /xmltv.php, /get.php and /m3u.php.
All player_api.php requests require the output username and password. The following examples use placeholders, not real credentials:
https://m3u.example.com/player_api.php?username=OUTPUT_USER&password=OUTPUT_PASSWORD
https://m3u.example.com/player_api.php?username=OUTPUT_USER&password=OUTPUT_PASSWORD&action=get_live_streams
| Action | Result / optional filter |
|---|---|
| No action | Account and server information |
get_live_categories, get_vod_categories, get_series_categories |
Non-hidden categories containing the requested media type |
get_live_streams, get_vod_streams, get_series |
Catalogue; optional category_id (0 selects uncategorized entries) |
get_vod_info |
Movie details; requires vod_id |
get_series_info |
Series details; requires series_id |
get_short_epg |
Guide for stream_id; optional limit, default 4, maximum 1,000 |
get_simple_data_table |
Guide for stream_id, up to 1,000 entries |
Catalogue IDs are the output's numeric Xtream IDs, not the management API's UUIDs. Hidden categories and their channels are excluded, including direct detail lookups. Names, ordering and stream addresses come from the edited playlist. API requests read current data; players may need a manual catalogue refresh to display edits.
Live and VOD lists, movie details and synthetic series episodes return the stored stream_url as direct_source. The M3U endpoints emit that same stored address. The output=ts query parameter does not transcode or rewrite streams.
/m3u.php?id=<8 chars>&secret=<16 chars> is the same playlist as get.php, in an address short enough to type into a TV app by hand (74 characters instead of 130). It carries its own credential pair — an id looked up in an indexed column and a 96-bit secret stored as a SHA-256 hash, exactly like the player password — so it can be handed out without exposing the Xtream username and password. Regenerating the output rotates it too, which invalidates every previously issued short address. The configuration GET returns it as m3uShortUrl, and outputs created before this endpoint existed get one on their next read, without a credential rotation. Because series data is stored per series rather than per episode, the Xtream response exposes one playable synthetic season/episode per series.
Playback uses the original source URL. Live/VOD imports preserve HTTP(S) direct_source addresses when supplied. This application serves the catalogue and guide, not media bytes. For series associated with the playlist's primary Xtream source, get_series_info fetches actual seasons and episodes on demand and returns their direct URLs. Standalone series entries without a configured source retain the single-episode fallback. There are no local playback routes or transcoding.
New backups live in data/backups, outside the public logo directory. Legacy backup URLs are blocked; older files move to private storage when accessed through the backup service. Version 2 snapshots preserve category visibility and, when restoring to the original playlist, channel/category UUIDs and Xtream IDs. They also include source settings, additional sources, filter rules, views and EPG matching profiles. Restoring to a different playlist leaves automatic sync disabled. Compression is asynchronous and decompression is capped at 256 MiB. These snapshots are not a complete database disaster-recovery backup: shared credentials, EPG programme data and logo binaries are not restored.
Refresh requests use a shared client helper and Web Locks where supported; transient network/server errors preserve the local session. Destructive playlist-bound AI tasks stop if their pre-run backup fails. Failed tool steps produce a partial result, and PostgreSQL advisory locks prevent simultaneous execution of the same task.
CI runs opt-in PostgreSQL regression tests with RUN_DB_TESTS=1 and NODE_ENV=test. They check stable-ID restore, hidden categories, ownership and concurrent password-reset token consumption. Physical-player compatibility and production load still require environment-specific validation.
- Management endpoints require the playlist owner's authenticated session.
- Disabling output blocks subsequent API, M3U and XMLTV requests. Regenerating rotates the output password and short-link credentials.
- Revocation cannot invalidate upstream URLs already downloaded or stop a stream playing directly from the provider. Those require controls at the provider.
- Stream URLs may contain provider credentials. Output credentials protect catalogue access; they do not hide the URLs returned to recipients.
- Connection counts in the account response are compatibility metadata, not enforced playback limits. This application cannot count direct upstream connections.
- Output access does not inherit expiry/password settings from the separate shared-playlist feature.
Run npm run test:ci -- --testPathPatterns=xtream for the Xtream regression suite and npm run test:ci for the full backend suite. In frontend, run npm run check and node src/langs/check-parity.js.
Before rollout, verify with the actual target player: load the catalogue, play a live/VOD URL, inspect a series entry, load XMLTV, hide a category and refresh, then rotate and disable output to check that old catalogue links are rejected. Unit tests do not certify every player's direct_source behaviour.
Channel logos rarely arrive complete from a provider. The editor's channel panel carries a Find logo button that opens a searchable browser over tv-logo/tv-logos — 10.700+ ready-made channel logos across 48 countries and 16 other groups (VOD, sports, flags, regional bundles).
Type a channel name and the grid filters as you type; the country selector next to the box narrows the search to one country, and country names are shown in the interface language. Clicking a logo writes it to the channel immediately.
The file index is pulled from GitHub's tree endpoint once a day and kept in
memory, so searching never calls out; a failed refresh keeps serving the previous
index rather than breaking the feature. Anonymous GitHub allows 60 requests an
hour, which one daily fetch never approaches — set GITHUB_TOKEN only if you
share an IP with something else that hits the API. Images are served from
jsDelivr (TV_LOGOS_CDN_BASE), not from raw.githubusercontent, because the grid
loads hundreds of thumbnails at once; the URL stored on the channel is that same
CDN address, exactly like the external logo URLs that arrive with an import.
Matching is tolerant of how channels are actually named: quality and country tags
(FHD, 4K, TR:) are stripped, Turkish characters are folded, and scoring
prefers exact names over prefixes over partial word matches — TRT 1 FHD finds
trt-1-tr.png, TR: NTV FHD finds ntv-tr.png. The assistant can do the same
through search_logo_library, set_channel_logo_from_library and
apply_library_logos, the last of which fills in every channel that has no logo
and reports the ones it was not confident about instead of guessing.
Every authenticated page carries a chat launcher in the bottom-right corner, and the same settings live under Account → AI Assistant. The assistant is not a help bot: it operates the application through 175 server-side tools, so it can do anything the UI can do — and a good deal the UI cannot. It also accepts file attachments, streams its answer as it is produced, can pause for your approval before destructive work, hands finished output back as downloadable files, and can be given recurring jobs that run on the server without a browser.
Nothing is hard-coded to a vendor. In the assistant's settings panel you enter:
- Base URL — any OpenAI-compatible endpoint (
https://api.openai.com/v1, OpenRouter, Groq, Together, vLLM, LM Studio, Ollama's/v1, …). - API key — stored AES-256-GCM encrypted with
CREDENTIAL_ENCRYPTION_KEY, never returned to the browser afterwards (the API only reports whether a key exists). - Model — press Fetch models to call the provider's
GET /modelsand pick one from the list; providers without that endpoint accept a typed model id.
Temperature, the maximum number of tool steps per turn (1–25), an optional extra
system instruction, and a destructive-actions switch are configured in the
same panel. Requests to the provider go through the same SSRF-safe HTTP client
as every other outbound call, so a base URL pointing at a private address is
rejected unless ALLOW_PRIVATE_NETWORK_URLS=true — which is what a local Ollama
or LM Studio endpoint needs.
- Playlists — create, rename, duplicate, delete, clear, stats, channel counts, compare two playlists, move or copy channels and categories between playlists, store Xtream credentials, schedule auto-sync, backups (create/list/restore/delete/download).
- Categories — create in bulk, rename, bulk find-and-replace on names, prefix/suffix, normalise, hide/show, delete (with or without their channels), drop empty ones, merge, deduplicate, split by pattern, auto-categorise by keyword rules, sort by name/count/creation/explicit order, move to a position, per-category stats.
- Channels — search (exact and fuzzy), create one or many at once, edit, bulk-update, move between categories, find-and-replace rename with dry run, prefix/suffix/strip, case conversion, strip quality tags, rewrite stream URLs, change stream type, clear logos, reset to provider values, delete by filter, deduplicate, sort A–Z / by category / naturally / EPG-first / explicit order, sort inside every category, move to position or to the top, swap, per-channel and full-playlist health scans, dead-channel reports, repairing dead channels from a working Xtream source.
- EPG — add/refresh/delete XMLTV sources, add straight from the iptv-org library, refresh-interval scheduling, auto-match, per-channel matching, match suggestions, bulk tvg-id assignment, clear matches, copy matches between playlists, coverage reports, guide listings, now-playing, and pulling channel logos out of the guide.
- Import & sync — M3U by URL or pasted content, Xtream with preview and category selection, credential testing, sync one or all playlists, add stream types, provider sources, filter rules (create/update/test/move/delete).
- Export & sharing — M3U preview, export URLs, JSON export, share links with expiry and password, Xtream output enable/regenerate/disable, XMLTV coverage, view profiles.
- Attachments & output files — list, describe, page through and search an attached file, import an attached M3U straight into a playlist, write a report or converted list to a downloadable file, export a playlist to an M3U file.
- Scheduled tasks — create, list, update, delete and run recurring jobs.
- Account — own account summary, usage overview, storage report, recent activity, system status.
Some things the assistant needs are simply not in the database: a channel's
official name, its correct tvg-id, which country it belongs to, where its logo
lives. The stack therefore ships a SearXNG instance as an internal service —
no domain, not attached to Traefik, reachable only as http://searxng:8080 from
within the stack. Nothing is exposed to the internet and the user configures
nothing: whichever model you point the assistant at, web_search and
open_web_page are already in its catalogue.
Two details worth knowing. SearXNG's JSON output is off by default, so
searxng/settings.yml in this repository turns it on — without it the assistant
would get HTML it cannot read. And because the service resolves to a private
address, the SSRF guard would normally refuse it; the exception is granted to the
server-configured URL only (allowPrivateHost), never to anything the user
or the model supplies. open_web_page visits addresses that ultimately come from
search results, so it runs under the normal guard and cannot reach the internal
network.
If SEARXNG_URL is empty the two tools are not registered at all, so the model
is never offered a capability that would fail — it simply knows search is off.
Providers rotate stream addresses constantly. The playlist breaks, but the channels themselves — names, categories, logos, EPG matches — are still right. Until now the only options were to delete the dead channels or re-import the whole list, and both throw away the editing work.
repair_dead_channels takes a third path: it looks up each dead channel by name
in an Xtream catalogue and replaces only the stream URL. Say "scan this
playlist and repair whatever is broken from my Xtream source" and the assistant
runs the health scan, matches, and swaps.
- Credentials are optional. With no source given it uses the playlist's own
stored Xtream credentials, so there is no need to paste a password into the
chat. Pass
serverUrl/username/passwordonly when repairing from a different provider. - Only channels the scan marked dead are touched. Never-tested channels
(
last_check_ok IS NULL) are deliberately excluded — they may well be working, and rewriting a working address is a regression, not a repair. - Names are matched tolerantly:
TRT 1 FHD,TR: TRT1 HDandtrt-1all reduce to the same key, so provider naming differences do not block a match. - The candidate is tested before it is written. Replacing a broken address
with another broken address would make the playlist look repaired while hiding
the problem; failed candidates are reported instead (
verify: falseskips this when speed matters more). dryRun: truereports what would change without touching anything, and the tool is[YIKICI], so the destructive switch and the approval flow both apply.
Paired with a scheduled task — "every morning, check this source and repair what is broken" — this runs unattended, and because task runs take a backup first, a bad night is one click away from being undone.
The paperclip in the composer accepts .m3u, .m3u8, .txt, .xml, .xmltv,
.json and .csv up to 20 MB each (AI_ATTACHMENT_MAX_BYTES), with a 200 MB
per-account ceiling (AI_ATTACHMENT_QUOTA_BYTES). The format is detected from
the content, not the extension. The file is stored on disk under
AI_ATTACHMENT_DIR (by default a sibling of the logo directory, so it lives in
the same volume) while only metadata, a short preview and a content summary —
entry count for M3U, channel/programme counts for XMLTV, columns for CSV — go
into the database.
The file is never handed to the model in one piece. The message carries the
attachment id and its summary; from there the assistant pages through it with
read_attachment (line windows), narrows it with search_attachment, or imports
it wholesale with import_attachment. A 20 MB playlist is therefore workable
without ever filling the context window. XMLTV attachments can be read and
searched but not imported — EPG ingestion streams from a URL, so add those as an
EPG source instead.
Anything the assistant produces — a CSV report, a converted list, a filtered M3U
— comes back as a download card in the chat, served from
GET /api/ai/attachments/:id/download with the owner's session required.
There is no practical message length limit. Text beyond
AI_INLINE_MESSAGE_CHARS (12.000) is stored as an attachment automatically and
the model receives a preview plus the id, so pasting a whole playlist into the
box works the same way as attaching it. The hard ceiling is
AI_MAX_MESSAGE_CHARS (1.000.000).
POST /api/ai/chat/stream returns Server-Sent Events: text arrives token by
token and every tool step appears the moment it starts, so a long chain is
visible while it runs instead of after it finishes. Gateways that do not support
streaming are detected on the first response and the panel falls back to the
plain POST /api/ai/chat endpoint with no visible difference beyond the text
landing at once.
With Ask for my approval before destructive actions enabled, a [YIKICI]
call does not run: it stops in the chat as a confirmation card carrying the tool
name and an impact estimate, and the rest of that turn's calls wait in a queue so
the provider never sees a half-finished turn. Approving runs exactly the stored
call; declining closes it and the queued calls with a "not approved" result, and
the assistant is told so it can react.
Recurring work — "check this Xtream source every morning and add new channels
to the right categories with matching EPG" — becomes a scheduled task simply by
saying it: the assistant turns the sentence into a task, binds it to the playlist
you are working on, and tells you what it set up. Tasks live in ai_tasks, run on
the server via SchedulerService (minimum interval 15 minutes,
AI_MIN_TASK_INTERVAL_MINUTES), and each run starts a fresh conversation so
context does not accumulate. Destructive permission is per task and off by
default; a task cannot ask for approval, so an unattended run that hits one
stops and records why.
Runs with an available pre-run backup can be undone. A task bound to a
playlist takes a backup before it runs, then writes a row in ai_task_runs
holding the status, the assistant's summary, the tools it executed, and how the
channel and category counts changed. The assistant's task tab shows this history
per task; each entry carries an Undo button that restores that pre-run backup.
Two things worth knowing about undo. It restores the playlist to the moment before the run, so changes you made after the run are lost too — the confirmation says so, and the action is one-way. And it depends on the backup still existing: task backups are kept in their own retention bucket (the last 10, counted separately from your manual backups so nightly runs cannot push yours out), and a run whose backup has aged out shows as not undoable rather than failing when you press the button. A task with no playlist bound cannot be backed up at all and is marked accordingly from the start.
list_task_runs and undo_task_run expose the same thing to the assistant, so
"what did last night's task do?" and "undo it" work in chat as well.
Providers cap how many functions one request may carry (OpenAI's limit is 128),
so each turn ships the first 120 tools plus three meta-tools —
search_capabilities, describe_capability and invoke_capability. The
assistant searches the catalogue for anything not in its current list and invokes
it by name, so no capability is ever out of reach.
GET /api/ai/capabilities returns the whole catalogue with descriptions; the
settings panel renders it too. Assistant replies are rendered as Markdown —
bold, italic, code, lists, headings and links — with the text escaped
before any formatting is applied, so nothing the model emits can inject HTML.
- The assistant's authority equals its own user's — never more. Every tool
runs as the authenticated user through the normal service layer, so tenant
ownership, validation, and business rules are identical to the UI's. The
identity comes from the session alone:
userId,is_adminand similar keys are stripped from model-supplied arguments before a tool sees them, and a tool call without a session is refused. No admin endpoint is in the catalogue, so even an administrator's assistant is confined to that administrator's own playlists, channels and sources. - Tools that lose data are labelled
[YIKICI]for the model, marked in the chat trace, and gated behind the destructive-actions switch. Turning it off makes deletion, restore, overwrite, and share-revocation tools fail with a message telling the model to ask you first. - Selections are bounded (at most 5000 channels per bulk call) and tool output is truncated before it goes back to the model.
- Every executed tool is shown in the chat as a trace line, so a turn's real effects are visible rather than inferred from prose.
- Conversations are stored per user in
ai_conversations/ai_messagesand can be deleted;POST /api/ai/chatis rate-limited per user. The panel remembers the open conversation across reloads and lists past ones in its history tab — only the conversation id lives in the browser, the content stays on the server. - Attachments are stored under a generated id, never under the supplied filename, so a crafted name cannot escape the user's own directory. Every read, download and delete goes through an ownership check, and deleting a conversation removes its files from disk as well as its rows.
- File contents are third-party data. The system prompt states this explicitly and the attachment manifest repeats it per message, so instructions embedded in a playlist or guide are treated as text, not as commands.
- Scheduled tasks run with their owner's authority and their own destructive switch; they cannot create or trigger other tasks, which rules out runaway loops.
Endpoints: GET/PUT /api/ai/settings, POST /api/ai/models,
GET /api/ai/capabilities, POST /api/ai/chat,
GET/DELETE /api/ai/conversations[/:id].
npm run check
npm run test:ci
npm audit --audit-level=high
cd frontend
npm run check
npm audit --audit-level=highCI additionally starts PostgreSQL, applies every migration, verifies the Compose configuration, and builds the production frontend.
- Passwords use bcrypt with cost factor 12.
- Access tokens expire after 15 minutes by default and are rejected unless they carry a valid issuer, audience, expiry, and session id; every request revalidates the session against the database, so logout and password changes take effect immediately.
- Refresh tokens are random, hashed at rest, rotated on use, and delivered in
HttpOnly,SameSite=Strictcookies. Replaying an already-rotated token revokes the entire session family, so a stolen token cannot outlive its detection. - Login, registration, password reset, session refresh, and password-protected share downloads each have their own rate limit; the share limiter is keyed per token so one link cannot exhaust another's budget.
- Writable channel fields are length- and type-checked, and control characters are rejected so exported M3U output cannot be injected with extra lines.
- Saved Xtream passwords and EPG source URLs use AES-256-GCM authenticated encryption. Note that Xtream stream URLs embed the provider username and password by protocol design, so every imported channel row — and every exported or shared M3U — still contains those credentials in clear text. Encrypting the credential column does not protect them from someone who can read the database, a backup, or a shared playlist.
- Share and password-reset tokens are stored as hashes; protected shares use bcrypt passwords.
- Remote M3U, XMLTV, metadata, and stream checks resolve DNS before connection, reject private/reserved addresses, revalidate redirects, enforce timeouts, and cap response sizes.
- Tenant ownership is checked before playlist, category, channel, EPG, upload, export, and admin mutations.
- Uploaded logos are size-limited and validated by image signatures; SVG uploads are not accepted. The cacheable
/logosroute is intentionally unauthenticated, but local logo files are removed when their channel, playlist, or account is deleted, and when a local logo is replaced by an external URL. Runnode scripts/cleanup-logos.jsfor a dry-run orphan report or add--deleteto remove reported orphan files. - Production startup refuses to boot on missing, short, placeholder, low-entropy, or identical secrets.
- Transport security is not provided by this stack; see TLS is required and is not included.
Do not commit .env, database dumps, exported playlists, or provider credentials. Rotate both application secrets when an environment may have been exposed; existing encrypted credentials must be re-entered after rotating CREDENTIAL_ENCRYPTION_KEY.
src/
config/ Environment, database, JWT, and logger
controllers/ HTTP request handlers
middleware/ Authentication, admin, and errors
models/migrations/ PostgreSQL schema migrations
parsers/ M3U and XMLTV parsing/formatting
routes/ Express routers
services/ Authentication, import, EPG, and domain logic
utils/ Encryption, SSRF-safe fetch, and validation
frontend/src/ Vue application
screenshot/ UI screenshots used in this README
tests/ Jest unit and property tests
- AI assistant with full application access. A chat panel on every authenticated page drives 55 server-side tools covering playlists, categories, channels, EPG, imports, exports, filter rules, health scans, backups, and views — including playlist merging and pulling logos out of the EPG guide. Bring your own OpenAI-compatible provider: base URL and API key are yours, the model list is fetched from the provider, and the key is stored encrypted. Destructive tools are gated behind a switch and every executed tool is shown in the chat trace. See AI assistant.
- Category-selective sync with a report. Updating a playlist now asks which categories to pull — previous selections come pre-checked, provider-new categories are badged "New", and unselected categories are never added (a long-standing bug imported them anyway). A report lists added categories, new channel names, and updated/removed counts. Per-type selection is optional; untouched types are preserved.
- Xtream output password always visible. The output password is stored AES-256-GCM encrypted (new migration) and shown to its owner on every view, with one-click regeneration that instantly invalidates the old password. M3U download and Xtream credentials now live in a single "Download Channel List" modal.
- EPG scales to 55k+ channels. The guide loads channels in pages with infinite scroll instead of freezing the browser, program descriptions are fetched lazily only when a program is opened, and grid hour lines moved from 24 DOM nodes per channel to one CSS background.
- Modal and rendering fixes. Child component updates suppressed by a toast in the same render flush (modals stuck open), playlist deletion of 55k channels down from ~13 s to ~0.06 s with spinner feedback, EPG controls removed from movie/series editing, and the Xtream import wizard gained a Cancel button, a 150 s timeout, and backdrop-click protection.
See CHANGELOG.md for the full history.
MIT © Furkan Yıldırım. See LICENSE.







