Compare commits

...
3 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
4 changed files with 205 additions and 10 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:
+132 -6
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)
@@ -496,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 = '--------'
@@ -505,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:
@@ -557,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})
@@ -578,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)
@@ -620,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)
@@ -897,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({})
@@ -911,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):
@@ -1337,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)
@@ -1411,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')
@@ -1432,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)
@@ -1466,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,
+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