| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
| Name | Name | Last commit date | ||
|---|---|---|---|---|
Πρώτα τα δύο A records (πίνακας παρακάτω): ο Caddy ζητάει certificate στο πρώτο boot και χωρίς DNS το ACME challenge αποτυγχάνει.
Αντικατάστησε το server.example.com με το hostname του server σου:
apt update
apt install git -y
git clone https://github.com/pointergr/node-media-server.git
cd node-media-server
git checkout master
cd apps/stream
./install server.example.com # ή: ./install server.example.com --docker
# ...ή όλα μαζί, μαζί με τη δήλωση στο κεντρικό panel:
./install server.example.com --docker \
--panel https://panel.example.com/api --key pk_<provisioning key> \
--cf-token <cloudflare token> --cf-zone <zone id>| Παράμετρος | Τι κάνει |
|---|---|
| <hostname> | υποχρεωτικό, πρώτο όρισμα — το δημόσιο hostname (χωρίς το rtmp.) |
| --docker | Docker + compose αντί για caddy/volta/pm2 στο μηχάνημα |
| --panel <url> | το API του κεντρικού panel, δηλαδή https://panel.example.com/api |
| --key pk_… | provisioning API key του panel (apikey.js) — μόνο μαζί με το --panel |
| --cf-token <token> | Cloudflare API token με Zone · DNS · Edit στο zone — φτιάχνει τα δύο A records |
| --cf-zone <id> | το Zone ID του domain (sidebar του Cloudflare dashboard) — μόνο μαζί με το --cf-token |
Με τη σειρά, το script: ζώνη ώρας Europe/Athens και apt upgrade → ufw (22, 80, 443, 1935) → DNS records, αν δόθηκαν τα δύο cf flags → clone, αν δεν τρέχει ήδη μέσα στο repo, και config.json από το example → Docker ή caddy + volta/node 24 + pm2 → generate-passwords → δήλωση στο panel, αν δόθηκαν τα δύο flags → σηκώνει τον server.
Χωρίς --docker στήνεται στο μηχάνημα (caddy + volta/node 24 + pm2). Με --docker εγκαθιστά τον Docker και σηκώνει το compose· τα κοινά (ζώνη ώρας, ufw, clone, config.json, κωδικοί) είναι τα ίδια και στα δύο. Η μόνη διαφορά στο firewall είναι το 8000: ανοίγει μόνο χωρίς Docker, γιατί στο compose ο admin δεν δημοσιεύει θύρα. Και τα δύο επιβιώνουν reboot — pm2 startup/pm2 save στο ένα, restart policy του compose στο άλλο.
Κράτα ό,τι τυπώνει το generate-passwords: admin password (basic auth του /admin API) και το έτοιμο Stream Key του OBS. Τυπώνονται μία φορά — δεύτερη κλήση της ίδιας εντολής τα ξαναδείχνει, αλλά νέοι κωδικοί θέλουν ρητό force.
Δεύτερο τρέξιμο με --panel/--key σκάει: το Server.host είναι unique, οπότε το POST /servers απορρίπτεται και το script σταματάει εκεί. Αν ξαναστήνεις μηχάνημα με το ίδιο hostname, σβήσε πρώτα την παλιά εγγραφή από το /admin/servers (409 αν την χρησιμοποιούν πλάνα ή συνδρομές — τότε πάρε το token της και βάλ' το με το χέρι).
Μένουν στο χέρι σου: τα DNS records (πριν), ο έλεγχος με npm run test-stream -- rtmp.server.example.com (τρέχει μέχρι να το κόψεις με Ctrl-C, γι' αυτό δεν μπαίνει στο script) και, όπου χρειάζονται, R2 και encoder/ABR.
Το script είναι ήδη non-interactive και τρέχει ως root, οπότε μπαίνει αυτούσιο σε runcmd — όλο το στήσιμο γίνεται στο πρώτο boot του VM:
#cloud-config
package_update: true
packages: [git]
runcmd:
- export HOME=/root
- git clone -b master https://github.com/pointergr/node-media-server.git /opt/node-media-server
- cd /opt/node-media-server/apps/stream && ./install stream.example.com --dockerΤο compose σηκώνει και τον Caddy, οπότε δεν χρειάζεται τίποτα στο host πέρα από Docker και τα δύο DNS records. Χειροκίνητα, αν δεν χρησιμοποιήσεις το ./install --docker:
cd apps/stream
cp .env.example .env # βάλε μέσα το DOMAIN σου
cp config.example.json config.json # χωρίς αυτό το bind mount φτιάχνει φάκελο
docker compose up -d --build
docker compose logs -fΜετά από reboot σηκώνονται μόνα τους: τα services έχουν restart: unless-stopped. Προϋπόθεση είναι να ξεκινάει ο ίδιος ο Docker στο boot — μια φορά, στον server (το ./install --docker το κάνει ήδη):
sudo systemctl enable --now docker(Το unless-stopped δεν ξαναπιάνει container που το σταμάτησες ρητά με docker compose stop πριν το reboot — αυτό είναι το ζητούμενο, docker compose up -d το ξαναφέρνει.)
Καμία χειροκίνητη αλλαγή στο config.json: το compose δίνει ADMIN_HOST και ADMIN_DB ως environment variables.
Το config.json δεν είναι στο git (μόνο το config.example.json): κρατάει τους κωδικούς και το jwt secret του κάθε server, οπότε το git pull δεν το αγγίζει ποτέ.
| Volume | Γιατί |
|---|---|
| ./config.json | ο server γράφει μέσα το jwt secret στο πρώτο boot — χωρίς mount χάνεται σε κάθε recreate και ακυρώνονται όλα τα tokens |
| media | τα HLS segments· χωρίς R2 σερβίρονται από εδώ. Named volume, όχι bind mount: ο φάκελος media/ δίπλα στο compose μένει πάντα άδειος — τα αρχεία τα βλέπεις με docker compose exec stream ls -R /app/media |
| data | το stats.db με τα στατιστικά 30 ημερών, το passwords.json και το clients.json |
| caddy-data | τα certificates· χωρίς αυτό κάθε recreate ζητάει νέο cert και η Let's Encrypt κόβει στο rate limit |
Δημοσιεύεται μόνο το 1935 (RTMP) πέρα από τα 80/443 του Caddy. Τα 8000/8001 μένουν στο compose network: τα βλέπει μόνο ο Caddy. Στο ufw αρκούν 22, 80, 443, 1935.
docker compose exec stream npm run generate-passwords stream.example.com
docker compose restart streamΤυπώνει admin password, το έτοιμο Stream Key του OBS (stream?key=...) και τις υπόλοιπες ρυθμίσεις, γράφει τα credentials στο mounted config.json — γι' αυτό χρειάζεται restart, διαβάζεται μόνο στο boot — και φτιάχνει τον πελάτη default στο data/clients.json αν δεν υπάρχει ήδη (δες Πελάτες).
Μέχρι να τρέξει αυτό, ο server δουλεύει με τους κωδικούς-παραδείγματα του config.example.json.
Η ίδια εντολή ξανά τους ξανατυπώνει αντί να φτιάξει καινούργιους, επειδή το passwords.json ζει στο data volume και επιβιώνει το recreate. Για νέους κωδικούς θέλει ρητό force:
docker compose exec stream npm run generate-passwords stream.example.com forceΆνοιξε έναν server.
Πρέπει να έχεις ένα πραγματικό URL που να δείχνει στον server (A Record). Παρακάτω θα χρησιμοποιήσουμε το υποθετικό stream.example.com.
Χρειάζονται δύο A Records στην ίδια IP, με διαφορετικό ρόλο το καθένα:
| Record | Ρόλος | Cloudflare |
|---|---|---|
| rtmp.stream.example.com | εκπομπή — εδώ στέλνει το OBS (RTMP, 1935) | DNS only (γκρι) |
| stream.example.com | playlist, FLV, admin | proxied (πορτοκαλί) |
Το rtmp. πρέπει να μείνει DNS only: το Cloudflare proxy περνάει μόνο HTTP(S) ports, οπότε με πορτοκαλί σύννεφο το publish στο 1935 δεν φτάνει ποτέ στον server.
Με R2 προστίθεται και τρίτο hostname, media.stream.example.com, για τα .ts — αυτό όμως δεν το φτιάχνεις εσύ, το βάζει μόνο του το R2 ως CNAME όταν κάνεις Connect Domain.
Αν λείπει το rtmp. record, ο Caddy δεν μπορεί να βγάλει certificate γι' αυτό και γεμίζει τα logs με ACME errors.
Και τα δύο τα φτιάχνει μόνο του το install με --cf-token <token> --cf-zone <id> (token με Zone · DNS · Edit, zone id από το sidebar του dashboard). Την IP τη ρωτάει από το api.ipify.org — ο ίδιος ο server βλέπει μόνο τη διεύθυνση της κάρτας του. Record που υπάρχει ήδη δεν πειράζεται (ώστε το install να ξανατρέχει και να μη σβήνει ρύθμιση που έγινε με το χέρι)· οτιδήποτε άλλο απορρίψει το Cloudflare σταματάει το install, γιατί χωρίς DNS ο Caddy δεν βγάζει certificate και ο server θα «εγκαθίστατο επιτυχώς» χωρίς να σηκώνει ούτε εκπομπή ούτε θεατή. Το token δεν γράφεται πουθενά — ζει όσο τρέχει το curl, όπως και το provisioning key.
apt update
apt install caddy git ffmpeg ufwufw allow 22
ufw allow 80
ufw allow 443
ufw allow 8000
ufw allow 1935
ufw default deny incoming
ufw default allow outgoing
ufw enableΈνα μόνο Caddyfile, αυτό του repo — το ίδιο χρησιμοποιεί και το Docker setup. Βάλε το hostname σου στη θέση του placeholder:
sed "s/{\\$DOMAIN}/stream.example.com/g" Caddyfile > /etc/caddy/Caddyfile
systemctl restart caddyΤο STREAM_HOST μένει στο default (localhost) γιατί εδώ ο Caddy και ο server είναι στο ίδιο μηχάνημα. Δεν μπαίνει basic auth στο Caddyfile: το κάνει ο ίδιος ο admin server με τον κωδικό του config.json (δες Στατιστικά).
journalctl -u caddy -f # access logs, certificates, ACME, configΟ Caddy γράφει στο stderr, οπότε τα πάντα είναι στο journal — δεν θέλει logrotate.
curl https://get.volta.sh | bash
source ~/.bashrc
volta install node@24
volta install npm@bundledgit clone git@github.com:pointergr/node-media-server.git
cd node-media-server/apps/stream
cp config.example.json config.json
npm installnpm install pm2 -g
source ~/.bashrcnpm run generate-passwords stream.example.compm2 start app.js --name streampm2 restart streampm2 logs streamΤο config.json δεν είναι στο git, οπότε οι κωδικοί μένουν ως έχουν:
cd node-media-server
git pull
cd apps/stream
npm install # μόνο αν άλλαξαν dependencies
pm2 restart streamcd node-media-server
git pull
cd apps/stream
docker compose up -d --build
docker compose logs -f stream # Ctrl-C· τα logs συνεχίζουν χωρίς αυτόΧωρίς npm install: το COPY package.json + npm install είναι μέσα στο Dockerfile και το --build τα ξανατρέχει. Χωρίς --build το compose ξανασηκώνει το παλιό image και δεν αλλάζει τίποτα — είναι το πιο συνηθισμένο «έκανα deploy και δεν άλλαξε τίποτα».
Κόβει την εκπομπή. Το --build κάνει recreate το container, οπότε πέφτει το RTMP: το OBS ξανασυνδέεται μόνο του σε λίγα δευτερόλεπτα, οι θεατές τρώνε ένα κενό στο HLS και το media volume κρατάει τα segments της προηγούμενης εκπομπής μέχρι να τα σβήσει ο νέος ffmpeg. Κάνε deploy εκτός εκπομπής.
Τι επιβιώνει: το config.json (bind mount, εκτός git — το git pull δεν το αγγίζει) και τα named volumes data (στατιστικά, κωδικοί), caddy-data (certificates), media.
Αν άλλαξε μόνο το Caddyfile, δεν χρειάζεται build — είναι bind mount:
docker compose restart caddyΈλεγχος μετά το deploy:
docker compose exec stream npm test
docker compose exec stream npm run test-stream -- rtmp.stream.example.comΣε server που εγκαταστάθηκε πριν βγει το config.example.json, το config.json είναι ακόμα tracked με τους πραγματικούς κωδικούς μέσα και το git pull θα κολλήσει. Μία φορά:
cp config.json /root/config.json.bak
git checkout -- config.json # πετάει τους κωδικούς από το working tree
git pull # τώρα το config.json φεύγει από το tracking
cp /root/config.json.bak config.json
pm2 restart streamΤο node-media-server v4 δεν κάνει transcoding — το HLS το βγάζει το app.js σπρώχνοντας ένα ffmpeg -c copy (remux, όχι transcode) ανά stream όταν ξεκινάει το publish, και το σταματάει όταν κλείνει. Τα segments γράφονται στο ./media/<app>/<name>/ και σερβίρονται από τον static server του v4:
https://stream.example.com/live/stream/index.m3u8
Θέλει keyframe interval ≤ 2s στον encoder (OBS: Output → Keyframe Interval = 2), αλλιώς τα segments βγαίνουν μεγαλύτερα από το hls_time και αυξάνει το latency. Το path του ffmpeg ρυθμίζεται στο config.json (hls.ffmpeg). Λάθη του ffmpeg φαίνονται στο pm2 logs stream.
npm run test-stream # bare metal
docker compose exec stream npm run test-stream # DockerΕκπέμπει τις χρωματικές μπάρες του ffmpeg (testsrc) με συνθετικό ήχο, παίρνοντας μόνο του το κλειδί του /live/stream από το clients.json. Αποδεικνύει σε ένα βήμα ότι δουλεύουν RTMP, publish auth, ffmpeg και HLS — χωρίς να ανοίξεις OBS. Ctrl-C για τερματισμό.
Με το δημόσιο hostname περνάει από έξω, οπότε ελέγχει και Caddy, DNS και στατιστικά:
docker compose exec stream npm run test-stream -- rtmp.stream.example.comΗ διαφορά μετράει: ό,τι έρχεται από 127.0.0.1 το stats.js το θεωρεί δικό μας (όπως τον ffmpeg του HLS) και δεν το δείχνει στο admin. Από εξωτερικό IP εμφανίζεται κανονικά, με codec, ανάλυση και θεατές.
Ο encoder χρησιμοποιεί -g 60, δηλαδή keyframe κάθε 2s στα 30fps — ακριβώς το Keyframe Interval = 2 που θέλει και το OBS.
Από προεπιλογή η εκπομπή βγαίνει σε μία ποιότητα, αυτή που στέλνει ο πελάτης (remux, μηδενικό κόστος). Ένα πλάνο μπορεί όμως να πουλάει και χαμηλότερα σκαλοπάτια: το πεδίο Αναλύσεις στο /admin/plans (Plan.ladder, csv από ύψη — 1080, 720, 480, 360, 240) ταξιδεύει με το sync ως ladder στο clients.json και ο app.js χτίζει τα args του ffmpeg αναλόγως (ladder.js).
Τότε το index.m3u8 γίνεται master playlist και δίπλα του γράφονται v720.m3u8, v480.m3u8 κ.ο.κ. — το URL αναπαραγωγής δεν αλλάζει, ο player διαλέγει μόνος του σκαλοπάτι ανάλογα με τη γραμμή του θεατή.
Η αλλαγή του ladder ενός πλάνου ισχύει από την επόμενη εκπομπή κάθε stream — δεν διακόπτει όσες τρέχουν. (Η ανάκληση πελάτη παραμένει άμεση, ≤10s: άλλο πράγμα.)
Δες PLAN-transcoding.md για το σκεπτικό.
Το config.json → hls.encoder λέει με τι κωδικοποιεί αυτό το μηχάνημα:
| τιμή | codec | πότε |
|---|---|---|
| x264 (προεπιλογή, ή απόν) | libx264 -preset veryfast | παντού· καμία απαίτηση |
| qsv | h264_qsv | Intel iGPU/Arc, με /dev/dri περασμένο στο container |
| nvenc | h264_nvenc | NVIDIA, με nvidia-container-toolkit και GPU στο compose |
Το πλάνο δεν το ξέρει και δεν το αλλάζει: ο πελάτης αγοράζει σκαλοπάτια, ο server τα βγάζει με ό,τι έχει. Ένα μηχάνημα με GPU μπαίνει ως κανονικός server στο panel και τα πλάνα με ladder δείχνουν εκεί (Plan.serverId) — καμία νέα έννοια, μεγαλύτερο hls.maxRenditions.
Επιταχύνονται μόνο τα σκαλοπάτια που κωδικοποιούνται. Η κορυφή είναι copy ούτως ή άλλως, και το scaling μένει στη CPU και στις τρεις περιπτώσεις (φθηνό· ακριβό είναι το encode).
Το ffmpeg του image (Debian 5.1) έχει ήδη χτισμένους τους h264_nvenc, h264_qsv και h264_vaapi — δεν χρειάζεται δικό μας build. Λείπει μόνο ο runtime driver της κάρτας και το device μέσα στο container.
Ο encoder δοκιμάζεται στο boot με ένα καρέ. Αν δεν δουλεύει (λάθος τιμή, GPU που δεν υπάρχει, driver ή codec που λείπει από το ffmpeg) γράφεται μια γραμμή στο log και ο server συνεχίζει με x264: πιο ακριβά, αλλά οι εκπομπές βγαίνουν. Σε server χωρίς τη ρύθμιση δεν τρέχει καν η δοκιμή — τίποτα δεν αλλάζει, ούτε ένα ffmpeg arg.
HLS encoder: h264_nvenc # δουλεύει config.hls.encoder "nvenc": ο h264_nvenc δεν δουλεύει εδώ (exit 255) — συνεχίζω με x264
Το hls.encoder διαβάζεται μόνο στο boot: μετά την αλλαγή θέλει docker compose restart stream (ή pm2 restart stream).
Στο host: ο driver της NVIDIA και το nvidia-container-toolkit. Στο μηχάνημα με την κάρτα — και μόνο εκεί — ένα apps/stream/docker-compose.override.yml (είναι στο .gitignore και το φορτώνει μόνο του το compose):
services:
stream:
environment:
# Χωρίς αυτό ο toolkit περνάει μόνο compute/utility και ο nvenc σκάει σε
# μηχάνημα που κατά τα άλλα «βλέπει» την κάρτα.
NVIDIA_DRIVER_CAPABILITIES: video,utility
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu, video]Στο κοινό docker-compose.yml δεν μπαίνει ποτέ: κάθε docker compose up σε απλό VPS θα έσκαγε στο could not select device driver "nvidia".
Οι consumer κάρτες (GeForce) έχουν όριο ταυτόχρονων NVENC sessions στον driver — το όριο μετράει encoded renditions, όχι εκπομπές, οπότε ένα ladder των δύο σκαλοπατιών το πιάνει στις μισές εκπομπές. Οι Quadro/datacenter δεν το έχουν.
Στο override, το device του host και ο iHD driver ως build arg:
services:
stream:
build:
args:
# Το node:24-slim έχει μόνο το `main` του Debian: το
# intel-media-va-driver-non-free δεν υπάρχει εκεί — αν το θέλεις
# (HEVC/VP9 encode), πρόσθεσε πρώτα το non-free component.
# Για Gen8-11 iGPU: libmfx1 αντί για libmfx-gen1.2.
EXTRA_PKGS: intel-media-va-driver libmfx-gen1.2
devices:
- /dev/dri:/dev/driΤο EXTRA_PKGS είναι build arg του Dockerfile ώστε το μηχάνημα με το iGPU να μην κρατάει τοπική αλλαγή σε αρχείο του git — αλλιώς κάθε git pull της ενημέρωσης θα ζητούσε merge. Μετά την αλλαγή θέλει docker compose up -d --build.
Δεν υποστηρίζεται, σκόπιμα. Ο VA-API είναι ο μόνος δρόμος για κάρτες AMD, αλλά σε αντίθεση με τους άλλους δύο απαιτεί τα frames να ανέβουν ρητά στην GPU (-vaapi_device + format=nv12,hwupload μέσα στο filter graph) — δηλαδή διαφορετικό filter_complex ανά encoder, εκεί που σήμερα αλλάζουν τρεις λέξεις. Επειδή ο VA-API δουλεύει και σε Intel, ένα μηχάνημα Intel το εξυπηρετεί το qsv χωρίς τίποτα από αυτά.
Μπαίνει όταν υπάρξει μηχάνημα με AMD κάρτα και ζήτηση να το γεμίσει — τότε το ENCODERS του ladder.js αποκτά και προαιρετικό filter/global args ανά encoder. Μέχρι τότε: vaapi στο config.json είναι άγνωστη τιμή, δηλαδή x264.
docker compose exec stream ffmpeg -hide_banner -encoders | grep -E "nvenc|qsv" # χτισμένο στο ffmpeg;
docker compose exec stream ffmpeg -hide_banner -f lavfi -i testsrc=d=0.1 \
-c:v h264_nvenc -b:v 1000k -f null - # δουλεύει εδώ;
docker compose logs stream | grep -i encoder # τι διάλεξε στο bootΤο πρώτο δεν αρκεί μόνο του — πάντα περνάει: το ffmpeg του Debian δείχνει τον h264_nvenc και σε μηχάνημα χωρίς κάρτα, γιατί τη libnvidia-encode την ψάχνει στο runtime. Γι' αυτό ο server κάνει το δεύτερο, στο boot.
Η Cloudflare περιορίζει το σερβίρισμα βίντεο μέσω του CDN σε non-Enterprise plan — δηλαδή ακριβώς το κασάρισμα των .ts, που όμως είναι όλο το bandwidth. Το R2 δεν είναι CDN αλλά storage με μηδενικό egress· το να φεύγει βίντεο από R2 custom domain είναι η προβλεπόμενη χρήση του.
Το setup με R2 είναι ρητά εντός των όρων — μην το ξαναψάχνεις. Ο παλιός όρος 2.8 («Limitation on Serving Non-HTML Content») καταργήθηκε τον Μάιο 2023 ακριβώς επειδή το R2 τον έκανε αντιφατικό. Η ανακοίνωση το λέει ονομαστικά:
customers can serve video and other large files using the CDN so long as that content is hosted by a Cloudflare service like Stream, Images, or R2.
Και το κατοπτρικό, που είναι το μόνο που πρέπει να προσέχεις:
Video and large files hosted outside of Cloudflare will still be restricted on our CDN.
Δηλαδή η χωρίς R2 παραλλαγή — .ts στον δικό μας δίσκο, cached από το πορτοκαλί σύννεφο (ο κανόνας #1 παρακάτω) — είναι αυτή που πατάει στα όρια, όχι η R2. Ο ίδιος κανόνας επιβιώνει σήμερα στα Service-Specific Terms, διατυπωμένος ανάποδα: «specific Paid Services (e.g., the Developer Platform, Images, and Stream) that you must use in order to serve video […] via the CDN» — και το R2 είναι το Developer Platform.
Το index.m3u8 που μένει στο origin δεν αλλάζει τίποτα: κείμενο μερικών εκατοντάδων bytes, ούτε video ούτε large file.
Γι' αυτό σπάμε το HLS στα δύο:
| Πού | Γιατί | |
|---|---|---|
| index.m3u8 | origin, όπως και πριν | ψίχουλα bytes, ήδη bypass cache — δεν είναι «large file» |
| *.ts | R2 + custom domain | εδώ είναι το 100% του bandwidth |
Το playlist πρέπει να μείνει στο origin: πάνω στα requests του μετράμε τους θεατές (δες Στατιστικά). Αν πήγαινε κι αυτό στο R2, τα στατιστικά θα έδειχναν μόνιμα μηδέν.
Ρύθμιση στο Cloudflare:
Και στο config.json:
"hls": {
"ffmpeg": "/usr/bin/ffmpeg",
"r2": {
"endpoint": "https://<account-id>.r2.cloudflarestorage.com",
"bucket": "stream",
"accessKeyId": "<από το API token>",
"secretAccessKey": "<από το API token>",
"publicUrl": "https://media.stream.example.com"
}
}Άδειο accessKeyId σημαίνει R2 off: τα segments σερβίρονται από τον ίδιο τον server, όπως πριν.
Πώς δουλεύει (r2.js): ο ffmpeg γράφει playlists και segments σε υποφάκελο ff/, με απόλυτα URLs του R2 (-hls_base_url). Το r2.js ανεβάζει τα segments και δημοσιεύει τα playlists ένα επίπεδο πάνω, εκεί που τα ζητάνε οι θεατές. Ό,τι ανέβηκε δείχνει στο R2 — ό,τι δεν πρόλαβε δείχνει στον ίδιο τον server (ff/<segment>.ts, το σερβίρει ο static server όπως χωρίς R2). Το playlist προχωράει έτσι πάντα στην ώρα του: το R2 είναι βελτιστοποίηση της κίνησης, ποτέ εξάρτηση της αναπαραγωγής.
Εκεί είναι όλη η διαφορά ανάμεσα σε «το R2 αργεί σήμερα» και «η εκπομπή δεν παίζει». Όσο το playlist περίμενε να ανέβουν όλα τα segments, ένας γύρος που ξεπερνούσε τα 2s άφηνε ένα segment πίσω· ο επόμενος έβρισκε δύο και τα ξανάστελνε παράλληλα στην ίδια γραμμή, οπότε αργούσε κι άλλο. Από εκεί και πέρα δεν ξαναπρολάβαινε ποτέ: το playlist πάγωνε για 12s (όσο το παράθυρο) και μετά πηδούσε μπροστά με κενό.
Η προθεσμία κάθε PUT είναι ένα segment και το aws4fetch περιορίζεται σε 2 retries: σε live, ένα segment που άργησε παραπάνω δεν το θέλει πια κανένας player, ενώ οι default 10 retries με exponential backoff θα κρατούσαν την αλυσίδα ~50 δευτερόλεπτα — και χωρίς προθεσμία μια κολλημένη σύνδεση για λεπτά, χωρίς ούτε ένα log. Μία προσπάθεια ανά segment και τέλος: από τη στιγμή που ο θεατής το παίρνει από εμάς, ένα δεύτερο PUT στέλνει bytes που κανείς δεν πρόκειται να ζητήσει από το R2 — και τα στέλνει ακριβώς όταν το uplink χρειάζεται για να σερβίρει.
Στα logs (pm2 logs stream, docker compose logs stream) γράφεται η μετάβαση, δύο γραμμές ανά επεισόδιο και όχι μία ανά segment:
R2 /live/stream: δεν προλαβαίνει — τα segments φεύγουν από το origin (…) R2 /live/stream: ξαναπρολαβαίνει, τα segments ξαναπάνε στο R2
Ανάμεσα στις δύο γραμμές το bandwidth το πληρώνει ο server. Το ίδιο επεισόδιο φαίνεται και στο /admin του κεντρικού panel χωρίς να σκύψεις σε logs: badge ΕΚΤΟΣ R2 δίπλα στην έξοδο του stream όσο τρέχει, και το πόσα segments κόστισε αφού κλείσει. Έλεγχος: node test-r2.js.
Ο Caddyfile βάζει ήδη τα σωστά Cache-Control. Στο Cloudflare (Caching → Cache Rules) χρειάζονται τρεις κανόνες με αμοιβαία αποκλειόμενα expressions, ώστε να μην παίζει ρόλο η σειρά τους:
| # | Expression | Ρύθμιση |
|---|---|---|
| 1 | ends_with(http.request.uri.path, ".ts") | Eligible for cache · Edge TTL: use origin cache-control |
| 2 | ends_with(http.request.uri.path, ".m3u8") | Bypass cache |
| 3 | ends_with(http.request.uri.path, ".flv") or starts_with(http.request.uri.path, "/api/") or starts_with(http.request.uri.path, "/admin") | Bypass cache |
Γιατί:
Εκτός των rules:
Ο κανόνας #1 κασάρει στο CDN βίντεο που φιλοξενείται εκτός Cloudflare — αυτό ακριβώς που περιορίζουν οι όροι σε non-Enterprise plan. Η καθαρή λύση είναι το R2, που είναι ρητά εντός των όρων και κάνει τον κανόνα περιττό.
Ο έλεγχος της εκπομπής είναι δικός μας, όχι του nms (auth.publish: false). Κάθε path ανήκει σε έναν πελάτη και θέλει το δικό του τυχαίο κλειδί — όλα δηλωμένα στο data/clients.json:
{
"pelatis-a": {
"limit": 200,
"paths": { "/live/kamera1": "KEY1", "/live/kamera2": "KEY2" }
}
}Στο OBS το κλειδί μπαίνει στο Stream Key: kamera1?key=KEY1. Το nms κόβει το query πριν φτιάξει το path, οπότε φάκελος HLS, στατιστικά και urls αναπαραγωγής δεν αλλάζουν.
Με το κεντρικό panel το limit δεν το γράφεις στο χέρι. Ο κατάλογος έχει πλάνα (δύο νούμερα το καθένα: μέγιστοι θεατές, μέγιστα streams) και ο πελάτης αγοράζει όσα θέλει — το ίδιο πλάνο ακόμα και πολλές φορές. Κάθε αγορά είναι μια συνδρομή και στέκεται μόνη της:
| Συνδρομή | Server | Θεατές | Streams |
|---|---|---|---|
| basic | stream1 | 50 | 1 |
| basic | stream2 | 50 | 1 |
| extra | stream2 | 100 | 2 |
Με συμπληρωμένο panel.url στο config.json, ο server κάνει ανά 10s POST <url>/servers/<host>/sync με το snapshot (τι παίζει, πόσοι θεατές) και γράφει την απάντηση στο data/clients.json. Pull αντί για push: ο server συγχρονίζεται μόνος του μετά από restart ή deploy, ενώ ένα push προς server εκτός λειτουργίας θα χανόταν και το panel θα έπρεπε να κρατάει ουρά. Αν το panel είναι κάτω, μένει σε ισχύ το τελευταίο clients.json — panel κάτω δεν σημαίνει εκπομπές κάτω. Κενό url = απενεργοποιημένο, το clients.json το γράφει το χέρι.
apps/api (NestJS) + apps/panel (Nuxt SPA με Nuxt UI, στατικά αρχεία) — μία εφαρμογή διαχείρισης για όλους τους stream servers, ένα μόνο deployment (docker compose up -d --build μέσα στο apps/api σηκώνει Nest + Caddy με τα στατικά του panel). Το Caddy κάνει /api/* → Nest και ό,τι άλλο → file_server με SPA fallback στο index.html (client-side routing). Φωτεινό/σκοτεινό θέμα με διακόπτη στην κεφαλίδα — τον ακολουθούν και τα γραφήματα.
Deployment του κεντρικού host:
cd apps/api
cp .env.example .env # DOMAIN=panel.example.com, JWT_SECRET
docker compose up -d --build
docker compose exec api node dist/src/seed.js # πρώτος admin χρήστης
docker compose exec api node dist/src/apikey.js "<υπηρεσία>" # μόνο αν κάνει provisioning εξωτερική υπηρεσίαΤο API key είναι για μηχανές: Authorization: Bearer pk_… στα ίδια admin endpoints, χωρίς login και χωρίς λήξη, με ανάκληση σε μία εντολή (apikey.js revoke) ή από την οθόνη /admin/apikeys του panel — η γραμμή εντολών μένει γιατί δουλεύει και με χαμένο κωδικό admin. Πλήρης αναφορά του API — σώματα, απαντήσεις, σφάλματα, ροή provisioning — στο apps/api/API.md.
Το σχήμα της βάσης εφαρμόζεται μόνο του σε κάθε boot (prisma db push, καμία migrations directory). Μία αλλαγή που σβήνει στήλη είναι η μόνη εξαίρεση: το db push αρνείται χωρίς TTY και το container δεν σηκώνεται, οπότε θέλει μία φορά, χειροκίνητα:
docker compose build api
docker compose run --rm api sh -c \
"node /app/node_modules/prisma/build/index.js db push --accept-data-loss --skip-generate --schema=prisma/schema.prisma"
docker compose up -d --build api caddyΠλήρες συμβόλαιο των endpoints στο apps/api/README.md. Εδώ μόνο τα βήματα άκρη σε άκρη:
Με --panel <url> --key <pk_...> τα βήματα 2-3 τα κάνει το ίδιο το ./install: δηλώνει τον server με το provisioning API key (POST /servers) και γράφει το panel: {...} με το token της απάντησης, πριν σηκωθεί ο server. Χειροκίνητα:
"panel": { "url": "https://panel.example.com/api", "token": "<το token>", "host": "<το host>" }Ο stream server δεν σερβίρει πια δική του οθόνη διαχείρισης — αυτή μετακόμισε στο κεντρικό panel (apps/api). Το /admin εδώ μένει «χαζό» JSON API πίσω από basic auth (χρήστης admin, ο κωδικός από το generate-passwords), που το panel καταναλώνει:
Το basic auth το κάνει ο ίδιος ο admin server, με τον κωδικό του config.json. Δεν μπαίνει hash στο Caddyfile: ένα δεύτερο αντίγραφο του κωδικού σε άλλο αρχείο σημαίνει σίγουρο 401 την πρώτη φορά που θα αλλάξουν οι κωδικοί και δεν θα ενημερωθεί.
Το POST /admin/api/restart δεν ξανασηκώνει τον server μόνο του: σκοτώνει πρώτα τα ffmpeg jobs του HLS (αλλιώς μένουν ορφανά και κλειδώνουν το streamPath για το επόμενο process) και μετά τερματίζει με process.exit(0)· τον ξανασηκώνει ο supervisor — pm2 σε bare metal, restart: unless-stopped σε Docker. Απαντάει 202 πρώτα και exit μετά: αν ο server τερματίσει πριν απαντήσει, ο caller βλέπει connection reset αντί για επιτυχία και δεν μπορεί να το ξεχωρίσει από πραγματική αποτυχία.
Πώς δουλεύει ο collector:
Το stats.db είναι στο .gitignore. Έλεγχος του collector: node test-stats.js.
Το v4 άλλαξε το API: /api/v1/{health,info,streams,sessions,stats} με JWT αντί για basic auth. Το login είναι challenge-response σε δύο βήματα (δεν ταξιδεύει ποτέ ο κωδικός):
CHALLENGE=$(curl -s -X POST https://stream.example.com/api/v1/login \
-H "Content-Type: application/json" -d '{"username":"admin"}' | jq -r .data.challenge)
RESPONSE=$(echo -n "$CHALLENGE" | openssl dgst -sha256 -hmac "<admin password>" | awk '{print $NF}')
TOKEN=$(curl -s -X POST https://stream.example.com/api/v1/login \
-H "Content-Type: application/json" \
-d "{\"username\":\"admin\",\"challenge\":\"$CHALLENGE\",\"response\":\"$RESPONSE\"}" | jq -r .data.token)
curl -H "Authorization: Bearer $TOKEN" https://stream.example.com/api/v1/streams| Back | FazBrowse Home | New Git URL |