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>
This commit is contained in:
ubuntu
2026-10-16 02:02:10 +00:00
co-authored by Claude Opus 5.5
parent 16e979fe77
commit 4fa53e7a64
2 changed files with 189 additions and 3 deletions
+124 -2
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
@@ -192,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)
@@ -464,6 +527,7 @@ async def analyse_m3u_file(request):
try: try:
max_connections = parse_max_connections(data.get('max_connections'))
id_playlist = validate_id_playlist(id_playlist) id_playlist = validate_id_playlist(id_playlist)
if not UPLOADED_PLAYLIST_RE.match(filename or ''): if not UPLOADED_PLAYLIST_RE.match(filename or ''):
raise ValueError('invalid filename') raise ValueError('invalid filename')
@@ -489,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)
@@ -578,6 +643,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)
@@ -620,6 +689,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)
@@ -897,6 +967,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({})
@@ -911,7 +996,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):
@@ -1337,6 +1426,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)
@@ -1411,9 +1501,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')
@@ -1432,6 +1544,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)
@@ -1466,6 +1581,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,
+65 -1
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">
@@ -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>
@@ -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'])
@@ -1762,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');
@@ -1790,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);
} }
@@ -2439,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>-->\
@@ -2468,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>\
@@ -2848,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();
@@ -2907,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);
@@ -2939,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,
@@ -3011,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