Compare commits

...
5 Commits
Author SHA1 Message Date
ubuntuandClaude Opus 5.5 0d6f8d95c3 Keep MongoDB off the Internet and its password out of git
MongoDB was published on 0.0.0.0:27017 with the default "example" root
password. It now listens on 127.0.0.1 only (the apps use the Docker
network), and the root password comes from MONGO_PASSWORD in a
git-ignored .env file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:12:18 +00:00
ubuntuandClaude Opus 5.5 ad82a96440 Stream M3U playlists line by line during analysis
readlines() loaded whole playlists into memory; a 336 MB file needs
roughly 500-600 MB of RAM on a VPS with ~800 MB available.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:03:12 +00:00
ubuntuandClaude Opus 5.5 4fa53e7a64 Add per-playlist max connections and viewing quota alerts
Each playlist gets a max_connections setting (default 1), editable in the
playlist modal without re-importing. Active viewings are tracked in the
shared db_iptv.viewings collection (also used by serverIPTV_2): devices
are counted from 'on'/'off' commands and their 10 s polling. When
starting a channel pushes a playlist above its limit, the web UI shows a
quota alert. The playlist list shows active/max connections.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 02:02:10 +00:00
ubuntuandClaude Opus 5.5 16e979fe77 Fix arbitrary file write in /upload and /analyse_m3u_file
/upload stored files under the client-supplied filename (allowing .php
webshells and ../ traversal) and analyse_m3u_file renamed files from
unvalidated filename/id_playlist. Uploads are now always stored as
upload_<uuid>.m3u, id_playlist must be a UUID, and paths go through
safe_join.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:54:14 +00:00
ubuntuandClaude Sonnet 5 03ec3f0c68 Speed up device snapshot cadence for a near-live thumbnail
Lower the default and minimum snapshot interval (10s/2s -> 2s/1s) and
refresh the selected device's thumbnail on its own 1s timer instead of
waiting for the 5s device-list poll, so the preview updates fast
enough to feel close to live video without building an actual
streaming pipeline.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 01:51:13 +00:00
5 changed files with 270 additions and 28 deletions
+3
View File
@@ -15,3 +15,6 @@ static/device_snapshots/
# Config locale spécifique à cette machine # Config locale spécifique à cette machine
.claude/ .claude/
# Mot de passe MongoDB (variables de docker-compose)
.env
+5 -3
View File
@@ -5,10 +5,12 @@ services:
image: mongo:latest image: mongo:latest
container_name: mongodb container_name: mongodb
ports: ports:
- "27017:27017" # Seulement depuis la machine elle-même : les applications passent par le réseau Docker
- "127.0.0.1:27017:27017"
environment: environment:
MONGO_INITDB_ROOT_USERNAME: root MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: example # Mot de passe dans .env (MONGO_PASSWORD), hors du dépôt git
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD:?MONGO_PASSWORD manquant dans .env}
volumes: volumes:
- mongodb_data:/data/db - mongodb_data:/data/db
networks: networks:
@@ -20,7 +22,7 @@ services:
depends_on: depends_on:
- mongodb - mongodb
environment: environment:
- MONGO_URI=mongodb://root:example@mongodb:27017/?authSource=admin - MONGO_URI=mongodb://root:${MONGO_PASSWORD:?MONGO_PASSWORD manquant dans .env}@mongodb:27017/?authSource=admin
networks: networks:
- app-network - app-network
volumes: volumes:
+169 -14
View File
@@ -27,7 +27,7 @@ import io, re
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
import pathlib import pathlib
from datetime import datetime from datetime import datetime, timedelta
from bson import ObjectId from bson import ObjectId
import urllib.request import urllib.request
import ipaddress import ipaddress
@@ -67,6 +67,13 @@ playlists = db_iptv['playlists']
logos = db_iptv['logos'] logos = db_iptv['logos']
recents = db_iptv['recents'] recents = db_iptv['recents']
settings_collection = db_iptv['settings'] settings_collection = db_iptv['settings']
# Visionnages en cours, partagés avec serverIPTV_2 (même collection, même format) :
# _id = "<source>:<clé>", un document par connexion ouverte chez le fournisseur.
viewings = db_iptv['viewings']
viewings.create_index("expires_at", expireAfterSeconds=0)
viewings.create_index("id_playlist")
VIEWING_ACTIVE_SECONDS = 35 # un appareil signale sa lecture toutes les 10 s
DEFAULT_MAX_CONNECTIONS = 1
devices_db = db_iptv['devices'] devices_db = db_iptv['devices']
MAX_STREAMING_ERRORS_PER_DEVICE = 5 MAX_STREAMING_ERRORS_PER_DEVICE = 5
@@ -104,6 +111,18 @@ def safe_join(base_dir, user_filename):
return candidate return candidate
UPLOADED_PLAYLIST_RE = re.compile(r'^upload_[0-9a-f]{32}\.m3u$')
def validate_id_playlist(value):
"""Playlist ids are UUIDs; anything else is rejected (they end up in file paths
and Mongo collection names)."""
try:
return str(uuid.UUID(str(value)))
except (ValueError, TypeError, AttributeError):
raise ValueError('invalid id_playlist')
def findInfo(tag, s): def findInfo(tag, s):
end = '"' end = '"'
if (s.find(tag)>-1): if (s.find(tag)>-1):
@@ -180,6 +199,62 @@ def save_playlist_document(playlist_doc):
) )
def playlist_max_connections(playlist_doc):
try:
return max(1, int((playlist_doc or {}).get('max_connections') or DEFAULT_MAX_CONNECTIONS))
except (TypeError, ValueError):
return DEFAULT_MAX_CONNECTIONS
def parse_max_connections(value):
"""Valeur saisie dans la configuration de playlist (None si absente)."""
if value in (None, ''):
return None
number = int(value)
if not 1 <= number <= 100:
raise ValueError('max_connections must be between 1 and 100')
return number
def active_viewings_count(id_playlist, exclude_id=None):
since = datetime.utcnow() - timedelta(seconds=VIEWING_ACTIVE_SECONDS)
query = {'id_playlist': id_playlist, 'last_seen': {'$gte': since}}
if exclude_id:
query['_id'] = {'$ne': exclude_id}
return viewings.count_documents(query)
def touch_viewing(viewing_id, id_playlist, id_channel=None, name_channel=None, extra=None):
now = datetime.utcnow()
fields = {'id_playlist': id_playlist, 'last_seen': now,
'expires_at': now + timedelta(seconds=VIEWING_ACTIVE_SECONDS * 4)}
if id_channel is not None:
fields['id_channel'] = id_channel
if name_channel is not None:
fields['name_channel'] = name_channel
if extra:
fields.update(extra)
source, _, key = viewing_id.partition(':')
viewings.update_one({'_id': viewing_id},
{'$set': fields, '$setOnInsert': {'source': source, 'key': key, 'started_at': now}},
upsert=True)
def start_viewing(viewing_id, id_playlist, id_channel=None, name_channel=None, extra=None):
"""Enregistre un nouveau visionnage et renvoie l'état du quota de la playlist."""
others = active_viewings_count(id_playlist, exclude_id=viewing_id)
touch_viewing(viewing_id, id_playlist, id_channel, name_channel, extra)
playlist_doc = playlists.find_one({'id_playlist': id_playlist}) or {}
maximum = playlist_max_connections(playlist_doc)
active = others + 1
return {'id_playlist': id_playlist, 'title_playlist': playlist_doc.get('title_playlist', ''),
'active': active, 'max': maximum, 'exceeded': active > maximum}
def device_viewing_id(id_device):
return 'serverIPTV:' + str(id_device)
def delete_playlist_resources(id_playlist): def delete_playlist_resources(id_playlist):
db_iptv.drop_collection("channels_" + id_playlist) db_iptv.drop_collection("channels_" + id_playlist)
@@ -451,8 +526,17 @@ async def analyse_m3u_file(request):
playlist_comment = data.get('comment', '').strip() playlist_comment = data.get('comment', '').strip()
new_path_filename = "./" + UPLOAD_PLAYLIST_DIR + '/' + id_playlist + '.m3u' try:
old_path_filename = "./" + UPLOAD_PLAYLIST_DIR + '/' +filename max_connections = parse_max_connections(data.get('max_connections'))
id_playlist = validate_id_playlist(id_playlist)
if not UPLOADED_PLAYLIST_RE.match(filename or ''):
raise ValueError('invalid filename')
new_path_filename = safe_join(UPLOAD_PLAYLIST_DIR, id_playlist + '.m3u')
old_path_filename = safe_join(UPLOAD_PLAYLIST_DIR, filename)
except ValueError as exc:
return web.json_response({'error': str(exc)}, status=400)
if not os.path.isfile(old_path_filename):
return web.json_response({'error': 'uploaded file not found'}, status=400)
os.rename(old_path_filename,new_path_filename) os.rename(old_path_filename,new_path_filename)
collection_channels = get_playlist_collection(id_playlist) collection_channels = get_playlist_collection(id_playlist)
@@ -469,6 +553,7 @@ async def analyse_m3u_file(request):
'xtream_server': '', 'xtream_server': '',
'xtream_username': '', 'xtream_username': '',
'xtream_password': '', 'xtream_password': '',
**({'max_connections': max_connections} if max_connections else {}),
} }
) )
save_playlist_document(new_playlist) save_playlist_document(new_playlist)
@@ -476,8 +561,12 @@ async def analyse_m3u_file(request):
# Process the m3u file # Process the m3u file
new_channels =[] new_channels =[]
f = open(new_path_filename, "r", encoding="utf8") # Lecture ligne à ligne : une playlist de plusieurs centaines de Mo chargée d'un
Lines = f.readlines() # bloc (readlines) ne tient pas dans la mémoire du VPS.
with open(new_path_filename, "r", encoding="utf8", errors="replace") as count_file:
total_lines = max(1, sum(1 for _ in count_file))
f = open(new_path_filename, "r", encoding="utf8", errors="replace")
Lines = f
print('ouverture fichier', flush=True) print('ouverture fichier', flush=True)
channel = {} channel = {}
separator = '--------' separator = '--------'
@@ -485,7 +574,6 @@ async def analyse_m3u_file(request):
num_channel = 0 num_channel = 0
TypeLangChannel = ['EN','FR','US','CA', 'UK' ] TypeLangChannel = ['EN','FR','US','CA', 'UK' ]
total_lines = len(Lines)
number_line = 0 number_line = 0
for line in Lines: for line in Lines:
@@ -537,7 +625,8 @@ async def analyse_m3u_file(request):
channel_exist = True channel_exist = True
num_channel += 1 num_channel += 1
f.close()
bulk_insert_channels(collection_channels, new_channels, num_channel) bulk_insert_channels(collection_channels, new_channels, num_channel)
print("Save final channels ", num_channel, flush=True) print("Save final channels ", num_channel, flush=True)
number_channels = collection_channels.count_documents({"id_playlist":id_playlist}) number_channels = collection_channels.count_documents({"id_playlist":id_playlist})
@@ -558,6 +647,10 @@ async def import_xtream_codes(request):
xtream_server = normalize_xtream_server(data.get('xtream_server', '')) xtream_server = normalize_xtream_server(data.get('xtream_server', ''))
xtream_username = data.get('xtream_username', '').strip() xtream_username = data.get('xtream_username', '').strip()
xtream_password = data.get('xtream_password', '').strip() xtream_password = data.get('xtream_password', '').strip()
try:
max_connections = parse_max_connections(data.get('max_connections'))
except ValueError as exc:
return web.json_response({'error': str(exc)}, status=400)
if not xtream_server or not xtream_username or not xtream_password: if not xtream_server or not xtream_username or not xtream_password:
return web.json_response({'error': 'Xtream server, username and password are required.'}, status=400) return web.json_response({'error': 'Xtream server, username and password are required.'}, status=400)
@@ -600,6 +693,7 @@ async def import_xtream_codes(request):
'xtream_server': xtream_server, 'xtream_server': xtream_server,
'xtream_username': xtream_username, 'xtream_username': xtream_username,
'xtream_password': xtream_password, 'xtream_password': xtream_password,
**({'max_connections': max_connections} if max_connections else {}),
} }
) )
save_playlist_document(playlist_doc) save_playlist_document(playlist_doc)
@@ -721,27 +815,36 @@ async def handle_upload(request):
# Handle both form field and file in one pass # Handle both form field and file in one pass
async for part in reader: async for part in reader:
if part.name == "id_playlist_to_action": if part.name == "id_playlist_to_action":
id_playlist_data = await part.text() id_playlist_data = (await part.text()).strip()
if len(id_playlist_data) == 0: if len(id_playlist_data) == 0:
print('No playlist to update', flush=True) print('No playlist to update', flush=True)
id_playlist = str(uuid.uuid4()) id_playlist = str(uuid.uuid4())
else: else:
id_playlist = id_playlist_data try:
id_playlist = validate_id_playlist(id_playlist_data)
except ValueError:
if filepath and os.path.exists(filepath):
os.remove(filepath)
return web.Response(text="Invalid id_playlist.", status=400)
print('playlist to update :', id_playlist, flush=True) print('playlist to update :', id_playlist, flush=True)
elif part.name == "file": elif part.name == "file":
filename = part.filename if not part.filename:
if not filename:
return web.Response(text="No file provided.", status=400) return web.Response(text="No file provided.", status=400)
filepath = os.path.join(UPLOAD_PLAYLIST_DIR, filename) # Never trust the client-supplied filename: always store under a
# server-generated .m3u name inside the playlists directory.
filename = f"upload_{uuid.uuid4().hex}.m3u"
filepath = safe_join(UPLOAD_PLAYLIST_DIR, filename)
with open(filepath, "wb") as f: with open(filepath, "wb") as f:
while chunk := await part.read_chunk(): while chunk := await part.read_chunk():
f.write(chunk) f.write(chunk)
if filepath: if filepath:
return web.json_response({'status': 'ok', 'id_playlist': id_playlist}) if id_playlist is None:
id_playlist = str(uuid.uuid4())
return web.json_response({'status': 'ok', 'id_playlist': id_playlist, 'filename': filename})
return web.Response(text="No file field found in the request.", status=400) return web.Response(text="No file field found in the request.", status=400)
@@ -868,6 +971,21 @@ async def update_playlist_comment(request):
return web.json_response({'status': 'ok', 'id_playlist': id_playlist, 'comment': comment}) return web.json_response({'status': 'ok', 'id_playlist': id_playlist, 'comment': comment})
async def update_playlist_max_connections(request):
data = await request.post()
id_playlist = (data.get('id_playlist') or '').strip()
try:
max_connections = parse_max_connections(data.get('max_connections'))
except ValueError as exc:
return web.json_response({'error': str(exc)}, status=400)
if not id_playlist or not max_connections:
return web.json_response({'error': 'id_playlist and max_connections are required'}, status=400)
result = playlists.update_one({'id_playlist': id_playlist}, {'$set': {'max_connections': max_connections}})
if result.matched_count == 0:
return web.json_response({'error': 'playlist not found'}, status=404)
return web.json_response({'status': 'ok', 'id_playlist': id_playlist, 'max_connections': max_connections})
# Get List Playlists # Get List Playlists
async def get_list_playlists(request): async def get_list_playlists(request):
active_count = playlists.count_documents({}) active_count = playlists.count_documents({})
@@ -882,7 +1000,11 @@ async def get_list_playlists(request):
if active_count == 0: if active_count == 0:
return web.json_response({'status':'no data'}) return web.json_response({'status':'no data'})
else: else:
return web.json_response(dumps(playlists.find())) items = list(playlists.find())
for p in items:
p['max_connections'] = playlist_max_connections(p)
p['active_connections'] = active_viewings_count(p.get('id_playlist'))
return web.json_response(dumps(items))
async def get_source(request): async def get_source(request):
@@ -1308,6 +1430,7 @@ app.router.add_post('/upload_snapshot', upload_snapshot)
app.router.add_post('/analyse_m3u_file', analyse_m3u_file) app.router.add_post('/analyse_m3u_file', analyse_m3u_file)
app.router.add_post('/import_xtream_codes', import_xtream_codes) app.router.add_post('/import_xtream_codes', import_xtream_codes)
app.router.add_post('/update_playlist_comment', update_playlist_comment) app.router.add_post('/update_playlist_comment', update_playlist_comment)
app.router.add_post('/update_playlist_max_connections', update_playlist_max_connections)
app.router.add_get('/delete_playlist', delete_playlist) app.router.add_get('/delete_playlist', delete_playlist)
app.router.add_get('/cleanup_xtream_duplicates', cleanup_xtream_duplicates) app.router.add_get('/cleanup_xtream_duplicates', cleanup_xtream_duplicates)
app.router.add_post('/list_channel', list_channel) app.router.add_post('/list_channel', list_channel)
@@ -1382,9 +1505,31 @@ async def communication(fromSid, data):
print("search request from ", fromSid, flush=True) print("search request from ", fromSid, flush=True)
toSidDevice = data['toSidDevice'] toSidDevice = data['toSidDevice']
print("send signal to ", toSidDevice, flush=True) print("send signal to ", toSidDevice, flush=True)
type_command = data.get('typeCommand')
if type_command in ('on', 'off'):
try:
await track_device_command(fromSid, toSidDevice, type_command, data.get('payload'))
except Exception as exc: # le suivi des quotas ne doit jamais bloquer la commande
logger.warning("Viewing tracking failed: %s", exc)
await sio.emit('communication', data, room=toSidDevice ,callback=messageReceived) await sio.emit('communication', data, room=toSidDevice ,callback=messageReceived)
async def track_device_command(fromSid, toSidDevice, type_command, payload):
device = devices_db.find_one({'sid': toSidDevice}) or {}
viewing_id = device_viewing_id(device.get('id_device') or toSidDevice)
if type_command == 'off':
viewings.delete_one({'_id': viewing_id})
return
if not isinstance(payload, dict) or not payload.get('id_playlist'):
return
quota = start_viewing(viewing_id, payload['id_playlist'], payload.get('id_channel'),
payload.get('name'), {'device_name': device.get('name')})
logger.info("Viewing %s on playlist %s: %s/%s", viewing_id, quota['title_playlist'],
quota['active'], quota['max'])
if quota['exceeded']:
await sio.emit('communication', {'typeCommand': 'quota_alert', 'payload': quota}, room=fromSid)
@sio.event @sio.event
async def streaming_error(sid, data): async def streaming_error(sid, data):
IdDevice = data.get('IdDevice') IdDevice = data.get('IdDevice')
@@ -1403,6 +1548,9 @@ async def streaming_error(sid, data):
@sio.event @sio.event
def disconnect(sid): def disconnect(sid):
sio_session_ips.pop(sid, None) sio_session_ips.pop(sid, None)
device = devices_db.find_one({'sid': sid}, {'id_device': 1})
if device:
viewings.delete_one({'_id': device_viewing_id(device['id_device'])})
print('disconnect ', sid, flush=True) print('disconnect ', sid, flush=True)
@@ -1437,6 +1585,13 @@ async def polling(sid, data):
new_device = { 'id_device': IdDevice, 'name' : device_name, 'last_datetime' : device_last_datetime, 'sid':sid ,'mediaRunning' : mediaRunning, 'vpn_status':vpn_status, 'data_running':data_running, 'auto_snapshot_enabled': auto_snapshot_enabled, 'snapshot_interval_seconds': snapshot_interval_seconds, 'ip_address': ip_address, 'ip_country': ip_country} new_device = { 'id_device': IdDevice, 'name' : device_name, 'last_datetime' : device_last_datetime, 'sid':sid ,'mediaRunning' : mediaRunning, 'vpn_status':vpn_status, 'data_running':data_running, 'auto_snapshot_enabled': auto_snapshot_enabled, 'snapshot_interval_seconds': snapshot_interval_seconds, 'ip_address': ip_address, 'ip_country': ip_country}
devices_db.insert_one(new_device) devices_db.insert_one(new_device)
if data_running and data_running.get('id_playlist'):
touch_viewing(device_viewing_id(IdDevice), data_running['id_playlist'],
data_running.get('id_channel'), data_running.get('name_channel'),
{'device_name': device_name})
else:
viewings.delete_one({'_id': device_viewing_id(IdDevice)})
if data_running: if data_running:
new_record = { new_record = {
"id_device": IdDevice, "id_device": IdDevice,
+4 -4
View File
@@ -1114,8 +1114,8 @@ def communication(data):
except (TypeError, ValueError): except (TypeError, ValueError):
sendMessage(toSidDevice, fromSidDevice, 'warning', 'Invalid snapshot interval', 0) sendMessage(toSidDevice, fromSidDevice, 'warning', 'Invalid snapshot interval', 0)
else: else:
if interval_seconds < 2: if interval_seconds < 1:
sendMessage(toSidDevice, fromSidDevice, 'warning', 'Snapshot interval must be at least 2 seconds', 0) sendMessage(toSidDevice, fromSidDevice, 'warning', 'Snapshot interval must be at least 1 second', 0)
else: else:
SNAPSHOT_INTERVAL_SECONDS = interval_seconds SNAPSHOT_INTERVAL_SECONDS = interval_seconds
sendMessage( sendMessage(
@@ -1302,7 +1302,7 @@ def initIPTV():
# ---------------------------------------------------------------- # ----------------------------------------------------------------
# ---------------------------------------------------------------- # ----------------------------------------------------------------
num_version='5.0.6' num_version='5.0.7'
# ---------------------------------------------------------------- # ----------------------------------------------------------------
# ---------------------------------------------------------------- # ----------------------------------------------------------------
@@ -1328,7 +1328,7 @@ mediaRunning = ''
status_vpn = 'disconnected' status_vpn = 'disconnected'
data_running = {} data_running = {}
auto_snapshot_enabled = True auto_snapshot_enabled = True
SNAPSHOT_INTERVAL_SECONDS = 10 SNAPSHOT_INTERVAL_SECONDS = 2
POLLING_INTERVAL_SECONDS = 10 POLLING_INTERVAL_SECONDS = 10
SOCKET_CONNECT_TIMEOUT = 10 SOCKET_CONNECT_TIMEOUT = 10
SOCKET_RETRY_MIN_INTERVAL = 3 SOCKET_RETRY_MIN_INTERVAL = 3
+89 -7
View File
@@ -852,6 +852,7 @@
</style> </style>
<body> <body>
<div id="quota_alert" class="alert alert-danger shadow position-fixed top-0 start-50 translate-middle-x mt-3" role="alert" style="z-index:2000;display:none;max-width:90vw"></div>
<!-- ID CLIENT SOCKET --> <!-- ID CLIENT SOCKET -->
<input id="sidClient" val="" type="hidden"> <input id="sidClient" val="" type="hidden">
@@ -1220,7 +1221,7 @@
<div class="modal-body"> <div class="modal-body">
<div id="snapshotIntervalSettingsAlert"></div> <div id="snapshotIntervalSettingsAlert"></div>
<label for="snapshot_interval_input" class="form-label">Seconds between 2 snapshots</label> <label for="snapshot_interval_input" class="form-label">Seconds between 2 snapshots</label>
<input type="number" min="2" step="1" class="form-control" id="snapshot_interval_input" placeholder="10"> <input type="number" min="1" step="1" class="form-control" id="snapshot_interval_input" placeholder="2">
<div class="form-text">This value is applied to the currently selected device.</div> <div class="form-text">This value is applied to the currently selected device.</div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
@@ -1387,6 +1388,16 @@
<div class="m-2 valid-feedback">Date ok</div> <div class="m-2 valid-feedback">Date ok</div>
</div> </div>
<!-- Max simultaneous connections -->
<div class="input-group p-2">
<div class="input-group-prepend">
<span class="input-group-text">Max connections</span>
</div>
<input id="playlist_max_connections" type="number" min="1" max="100" step="1" value="1" class="form-control" style="max-width:110px" aria-label="Maximum simultaneous connections">
<button type="button" class="btn btn-outline-secondary btn-sm" id="btn_update_playlist_max_connections" disabled>Update</button>
<div class="m-2 invalid-feedback">Between 1 and 100</div>
</div>
<div class="p-2"> <div class="p-2">
<div class="d-flex align-items-center justify-content-between gap-2 mb-2"> <div class="d-flex align-items-center justify-content-between gap-2 mb-2">
<label class="form-label mb-0" for="playlist_comment">Comment</label> <label class="form-label mb-0" for="playlist_comment">Comment</label>
@@ -1528,7 +1539,7 @@
var PROGRESS_STALE_MS = 15000 var PROGRESS_STALE_MS = 15000
var playlistImportProgress = 0 var playlistImportProgress = 0
var playlistImportInFlight = false var playlistImportInFlight = false
var selectedDeviceSnapshotIntervalSeconds = 10 var selectedDeviceSnapshotIntervalSeconds = 2
var currentSelectedDeviceData = null var currentSelectedDeviceData = null
var socket = io('https://iptv.mrk.ovh', { var socket = io('https://iptv.mrk.ovh', {
@@ -1545,6 +1556,17 @@
socket.on('communication', function(data) { socket.on('communication', function(data) {
console.log('communication', data) console.log('communication', data)
// QUOTA DE CONNEXIONS DÉPASSÉ
if (data['typeCommand'] == 'quota_alert') {
var q = data['payload'] || {}
$('#quota_alert').html('<i class="fas fa-exclamation-triangle me-2"></i><b>Quota maximum de visionnage dépassé</b> sur la playlist « '
+ $('<span>').text(q.title_playlist || '').html() + ' » : ' + q.active + ' connexions actives pour ' + q.max + ' autorisée(s).')
$('#quota_alert').stop(true, true).fadeIn('fast')
clearTimeout(window.quotaAlertTimer)
window.quotaAlertTimer = setTimeout(() => { $('#quota_alert').fadeOut('slow') }, 10000)
update_list_playlist()
}
// RECEPTION RETURN WARNING // RECEPTION RETURN WARNING
if (data['typeCommand'] == 'warning') { if (data['typeCommand'] == 'warning') {
$('#warning').html(data['payload']) $('#warning').html(data['payload'])
@@ -1710,6 +1732,22 @@
$placeholder.hide(); $placeholder.hide();
} }
// Reloads the currently selected device's snapshot image on its own fast
// timer, independent of the 5s full device-list poll, so a low snapshot
// interval (see Settings > Snapshot interval) actually feels near-live.
function refreshSelectedDeviceSnapshotFast() {
var device = currentSelectedDeviceData;
if (!device || !device.snapshot_url) {
return;
}
var dataRunning = device.data_running || {};
var hasChannel = Object.keys(dataRunning).length > 0 && dataRunning.name_channel;
if (!hasChannel) {
return;
}
$('#selectedDeviceSnapshot').attr('src', device.snapshot_url + '?t=' + Date.now());
}
function updateAutoSnapshotToggle(device) { function updateAutoSnapshotToggle(device) {
var $toggle = $('#autoSnapshotToggle'); var $toggle = $('#autoSnapshotToggle');
if (!device || !device.id_device) { if (!device || !device.id_device) {
@@ -1723,12 +1761,12 @@
function updateSnapshotIntervalState(device) { function updateSnapshotIntervalState(device) {
if (!device || !device.id_device) { if (!device || !device.id_device) {
selectedDeviceSnapshotIntervalSeconds = 10; selectedDeviceSnapshotIntervalSeconds = 2;
return; return;
} }
var interval = Number(device.snapshot_interval_seconds); var interval = Number(device.snapshot_interval_seconds);
if (!Number.isFinite(interval) || interval < 2) { if (!Number.isFinite(interval) || interval < 1) {
interval = selectedDeviceSnapshotIntervalSeconds; interval = selectedDeviceSnapshotIntervalSeconds;
} }
selectedDeviceSnapshotIntervalSeconds = interval; selectedDeviceSnapshotIntervalSeconds = interval;
@@ -1746,7 +1784,9 @@
playlistImportInFlight = false; playlistImportInFlight = false;
$('#upload-button').prop('disabled', false).text('Save'); $('#upload-button').prop('disabled', false).text('Save');
$('#btn_update_playlist_comment').prop('disabled', true); $('#btn_update_playlist_comment').prop('disabled', true);
$('#playlist_title, #playlist_url, #playlist_end_date, #xtream_server, #xtream_username, #xtream_password, #playlist_comment').removeClass('is-valid is-invalid'); $('#btn_update_playlist_max_connections').prop('disabled', true);
$('#playlist_max_connections').val(1);
$('#playlist_title, #playlist_max_connections, #playlist_url, #playlist_end_date, #xtream_server, #xtream_username, #xtream_password, #playlist_comment').removeClass('is-valid is-invalid');
$('#color-selector').removeClass('border border-danger rounded p-1'); $('#color-selector').removeClass('border border-danger rounded p-1');
$('input[name="playlist_color"]').prop('checked', false); $('input[name="playlist_color"]').prop('checked', false);
$('#playlist_type').val('m3u'); $('#playlist_type').val('m3u');
@@ -1774,6 +1814,8 @@
$('#xtream_username').val(playlist.xtream_username || ''); $('#xtream_username').val(playlist.xtream_username || '');
$('#xtream_password').val(playlist.xtream_password || ''); $('#xtream_password').val(playlist.xtream_password || '');
$('#btn_update_playlist_comment').prop('disabled', !(playlist.id_playlist || '').length); $('#btn_update_playlist_comment').prop('disabled', !(playlist.id_playlist || '').length);
$('#playlist_max_connections').val(playlist.max_connections || 1);
$('#btn_update_playlist_max_connections').prop('disabled', !(playlist.id_playlist || '').length);
if (playlist.selectedColor) { if (playlist.selectedColor) {
$(`input[name="playlist_color"][value="${playlist.selectedColor}"]`).prop('checked', true); $(`input[name="playlist_color"][value="${playlist.selectedColor}"]`).prop('checked', true);
} }
@@ -1789,6 +1831,7 @@
$(window).on('scroll resize', lazyLoad); $(window).on('scroll resize', lazyLoad);
updateListDevices() updateListDevices()
setInterval(updateListDevices, 5000) setInterval(updateListDevices, 5000)
setInterval(refreshSelectedDeviceSnapshotFast, 1000)
update_list_playlist() update_list_playlist()
display_most_frequent() display_most_frequent()
setInterval(display_most_frequent, 15000) setInterval(display_most_frequent, 15000)
@@ -2422,6 +2465,7 @@
<th scope="col">Title</th>\ <th scope="col">Title</th>\
<th scope="col">End Date</th>\ <th scope="col">End Date</th>\
<th scope="col">Num channels</th>\ <th scope="col">Num channels</th>\
<th scope="col">Connections</th>\
<th scope="col"></th>\ <th scope="col"></th>\
<th scope="col"></th>\ <th scope="col"></th>\
<!--<th scope="col">Sid</th>-->\ <!--<th scope="col">Sid</th>-->\
@@ -2451,6 +2495,7 @@
</td> </td>
<td>${dataJSON[i].date_end_playlist}</td>\ <td>${dataJSON[i].date_end_playlist}</td>\
<td>${dataJSON[i].num_channels}</td>\ <td>${dataJSON[i].num_channels}</td>\
<td class="${(dataJSON[i].active_connections || 0) > (dataJSON[i].max_connections || 1) ? 'text-danger fw-bold' : ''}" title="Active / maximum simultaneous connections">${dataJSON[i].active_connections || 0} / ${dataJSON[i].max_connections || 1}</td>\
<!--<td style="color:darkblue" id="zone_edit_playlist_${id_playlist}"><div class="d-flex flex-row"><div><i class="fas fa-edit"></i></div><div class="d-none d-lg-block" style="margin-left: 5px;">Edit</div></div></td>-->\ <!--<td style="color:darkblue" id="zone_edit_playlist_${id_playlist}"><div class="d-flex flex-row"><div><i class="fas fa-edit"></i></div><div class="d-none d-lg-block" style="margin-left: 5px;">Edit</div></div></td>-->\
<td style="color:red" data-param1="id_playlist" id="zone_delete_playlist_${i}"><div class="d-flex flex-row"><div><i class="fas fa-trash-alt"></i></div><div class="d-none d-lg-block" style="margin-left: 5px;">Delete</div></div></td>\ <td style="color:red" data-param1="id_playlist" id="zone_delete_playlist_${i}"><div class="d-flex flex-row"><div><i class="fas fa-trash-alt"></i></div><div class="d-none d-lg-block" style="margin-left: 5px;">Delete</div></div></td>\
<td style="color:green" data-param1="id_playlist" id="zone_edit_playlist_${i}"><div class="d-flex flex-row"><div><i class="fas fa-edit"></i></div><div class="d-none d-lg-block" style="margin-left: 5px;">Edit</div></div></td>\ <td style="color:green" data-param1="id_playlist" id="zone_edit_playlist_${i}"><div class="d-flex flex-row"><div><i class="fas fa-edit"></i></div><div class="d-none d-lg-block" style="margin-left: 5px;">Edit</div></div></td>\
@@ -2831,6 +2876,36 @@
}) })
}) })
function readMaxConnections() {
var value = parseInt($('#playlist_max_connections').val(), 10);
var valid = Number.isInteger(value) && value >= 1 && value <= 100;
$('#playlist_max_connections').toggleClass('is-invalid', !valid);
return valid ? value : null;
}
$('#btn_update_playlist_max_connections').click(function(){
var idPlaylist = $('#id_playlist_to_action').val().trim();
var maxConnections = readMaxConnections();
if (!idPlaylist || !maxConnections) {
return;
}
$('#btn_update_playlist_max_connections').prop('disabled', true).text('Updating...');
$.post('update_playlist_max_connections', {
id_playlist: idPlaylist,
max_connections: maxConnections
}).done(function(data){
$('#playlist_max_connections').removeClass('is-invalid').addClass('is-valid');
$('#add_playlist_alert').html('<div class="alert alert-success" role="alert">Max connections updated.</div>');
update_list_playlist();
}).fail(function(xhr){
var message = (xhr.responseJSON && xhr.responseJSON.error) || 'Unable to update max connections.';
$('#playlist_max_connections').removeClass('is-valid').addClass('is-invalid');
$('#add_playlist_alert').html('<div class="alert alert-danger" role="alert">' + message + '</div>');
}).always(function(){
$('#btn_update_playlist_max_connections').prop('disabled', false).text('Update');
});
})
$('#btn_update_playlist_comment').click(function(){ $('#btn_update_playlist_comment').click(function(){
var idPlaylist = $('#id_playlist_to_action').val().trim(); var idPlaylist = $('#id_playlist_to_action').val().trim();
var playlistComment = $('#playlist_comment').val().trim(); var playlistComment = $('#playlist_comment').val().trim();
@@ -2890,6 +2965,10 @@
var validationTitle = title.length > 0; var validationTitle = title.length > 0;
var validationDate = !!endDate; var validationDate = !!endDate;
var validationColor = typeof selectedColor !== 'undefined'; var validationColor = typeof selectedColor !== 'undefined';
var maxConnections = readMaxConnections();
if (!maxConnections) {
return;
}
setFieldState('#playlist_title', validationTitle); setFieldState('#playlist_title', validationTitle);
setFieldState('#playlist_end_date', validationDate); setFieldState('#playlist_end_date', validationDate);
@@ -2922,6 +3001,7 @@
title: title, title: title,
date_end: endDate, date_end: endDate,
comment: playlistComment, comment: playlistComment,
max_connections: maxConnections,
sidClient: $('#sidClient').val(), sidClient: $('#sidClient').val(),
selectedColor: selectedColor, selectedColor: selectedColor,
xtream_server: xtreamServer, xtream_server: xtreamServer,
@@ -2986,6 +3066,7 @@
$('#add_playlist_alert').html('<div class="alert alert-success" role="alert">Upload successful!</div>'); $('#add_playlist_alert').html('<div class="alert alert-success" role="alert">Upload successful!</div>');
var response = JSON.parse(xhr.response); var response = JSON.parse(xhr.response);
var id_playlist = response['id_playlist']; var id_playlist = response['id_playlist'];
$("#fileName").val(response['filename']);
if (response.status == 'ok') { if (response.status == 'ok') {
$.post('analyse_m3u_file', { $.post('analyse_m3u_file', {
'id_playlist': id_playlist, 'id_playlist': id_playlist,
@@ -2993,6 +3074,7 @@
'url': playlistUrl, 'url': playlistUrl,
'date_end': endDate, 'date_end': endDate,
'comment': playlistComment, 'comment': playlistComment,
'max_connections': maxConnections,
'sidClient': $('#sidClient').val(), 'sidClient': $('#sidClient').val(),
'filename': $("#fileName").val(), 'filename': $("#fileName").val(),
'selectedColor': selectedColor 'selectedColor': selectedColor
@@ -3158,9 +3240,9 @@
} }
var intervalValue = Number($('#snapshot_interval_input').val()); var intervalValue = Number($('#snapshot_interval_input').val());
if (!Number.isInteger(intervalValue) || intervalValue < 2) { if (!Number.isInteger(intervalValue) || intervalValue < 1) {
$('#snapshot_interval_input').addClass('is-invalid'); $('#snapshot_interval_input').addClass('is-invalid');
$('#snapshotIntervalSettingsAlert').html('<div class="alert alert-danger" role="alert">Enter an integer value greater than or equal to 2.</div>'); $('#snapshotIntervalSettingsAlert').html('<div class="alert alert-danger" role="alert">Enter an integer value greater than or equal to 1.</div>');
return; return;
} }