Compare commits

..
12 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
ubuntuandClaude Sonnet 5 e5df068778 Show channel category in search results
Each channel already carries a Topic field, but search results only
showed the name and logo. Render it as a small badge in both the
list and grid views when present.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 01:33:34 +00:00
ubuntuandClaude Sonnet 5 4e596114c1 Rank channel search results by relevance, run search off the event loop
list_channel previously matched every playlist with one unranked
".*keyword.*" regex, so a specific channel name could be buried pages
behind unrelated names that merely contained the keyword mid-word.
Split matches into two tiers (word-boundary match first, then
anywhere-else) using a cheap count+skip/limit per tier so pagination
still avoids pulling every match into memory.

Also found while testing against a 1.19M-channel playlist: searching
scans an unindexed regex and can take several seconds, and it was
running synchronously inside the async handler, freezing every other
request/socket for that whole time. Moved it to run_in_executor;
verified a concurrent request now returns in ~2ms while a 3.6s search
is in flight.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 01:33:27 +00:00
ubuntuandClaude Sonnet 5 685bbb1683 Improve channel search UX: Enter-to-search, loading/empty states
The search box only worked via mouse click on the Search button (no
keypress handler), and a search returning zero matches or failing
silently left the previous results or an empty panel with no
feedback. Add Enter-key submission and show a spinner while
searching, "No channels found" on empty results, and an error
message on request failure. Also fixes a dead '#list_channels'
selector (the actual container is '#listChannels') that silently
did nothing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 23:43:15 +00:00
ubuntuandClaude Sonnet 5 2eaa0fee64 Remove dead code and tighten error handling
- Drop analyse_m3u_file2: an unrouted, unused ~90%-duplicate of
  analyse_m3u_file (a bug fixed in one would never apply to the other).
- Drop the in-memory `devices` list from polling/list_devices: it was
  only ever written to, never read (list_devices already returns from
  devices_db, the real source of truth).
- Remove the resulting unused NUM_MAX_CHANNEL variable and duplicate
  `from pymongo import MongoClient` import.
- Narrow a bare `except:` to `except KeyError:` so it can't also
  swallow SystemExit/KeyboardInterrupt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 23:36:07 +00:00
ubuntuandClaude Sonnet 5 3eff9519db Stop Xtream imports from freezing the server while fetching from the provider
run_xtream_import made 6 sequential synchronous requests.get() calls
(60s timeout each) directly inside an asyncio task, so the whole
event loop — and every other request/socket the server was handling
— stalled for the full duration of each Xtream import, same failure
mode as the logo-download freeze fixed earlier. Run them through
run_in_executor instead so the loop stays free.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 23:35:57 +00:00
ubuntuandClaude Sonnet 5 0fa98c7283 Read the client IP from X-Real-IP behind the nginx reverse proxy
Since TLS termination moved to nginx, aiohttp's request.remote is
nginx's own address on the docker network (172.21.0.1), a private IP
that resolve_ip_country() correctly refuses to geolocate. Nginx
already forwards the real client address via X-Real-IP
(iptv.mrk.ovh.conf); read that first so device IP/country tracking
works again.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 23:29:10 +00:00
ubuntuandClaude Sonnet 5 6881839e31 Fix crashing /init_db and remove dead proxy_image handler
/init_db dropped the channels/playlists collections then crashed on
an undefined `res` variable, so the "Reset DB" button in the UI
always surfaced a 500 even though the wipe had already happened.
proxy_image used aiohttp.ClientSession without importing aiohttp and
was never routed or called from the frontend, so it's just removed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 23:29:04 +00:00
5 changed files with 367 additions and 229 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:
+238 -206
View File
@@ -27,16 +27,12 @@ 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
from pymongo import MongoClient
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s", format="%(asctime)s %(levelname)s %(message)s",
@@ -71,10 +67,16 @@ 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
devices = []
active_xtream_imports = {} active_xtream_imports = {}
sio_session_ips = {} sio_session_ips = {}
ip_country_cache = {} ip_country_cache = {}
@@ -109,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):
@@ -185,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)
@@ -440,13 +510,11 @@ async def init_db(request):
db_iptv.drop_collection("channels") db_iptv.drop_collection("channels")
db_iptv.drop_collection("playlists") db_iptv.drop_collection("playlists")
return web.Response(text=str(res),content_type='text/html') return web.json_response({'status': 'ok'})
async def analyse_m3u_file(request): async def analyse_m3u_file(request):
NUM_MAX_CHANNEL = 300000
data = await request.post() data = await request.post()
id_playlist = data['id_playlist'] id_playlist = data['id_playlist']
title_playlist = data['title'] title_playlist = data['title']
@@ -458,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)
@@ -476,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)
@@ -483,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 = '--------'
@@ -492,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:
@@ -544,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})
@@ -554,120 +636,6 @@ async def analyse_m3u_file(request):
return web.json_response(result) return web.json_response(result)
async def analyse_m3u_file2(request):
NUM_MAX_CHANNEL = 300000
data = await request.post()
id_playlist = data['id_playlist']
title_playlist = data['title']
url_playlist = data['url']
date_end_playlist = data['date_end']
filename = data['filename']
sidClient = data['sidClient']
playlist_comment = data.get('comment', '').strip()
new_path_filename = "./" + UPLOAD_PLAYLIST_DIR + '/' + id_playlist + '.m3u'
old_path_filename = "./" + UPLOAD_PLAYLIST_DIR + '/' +filename
os.rename(old_path_filename,new_path_filename)
collection_channels = get_playlist_collection(id_playlist)
collection_channels.drop()
new_playlist = build_playlist_document(
id_playlist,
title_playlist,
date_end_playlist,
'',
{
'playlist_type': 'm3u',
'url_playlist': url_playlist,
'comment': playlist_comment,
'xtream_server': '',
'xtream_username': '',
'xtream_password': '',
}
)
save_playlist_document(new_playlist)
# Process the m3u file
TypeLangChannel = ['EN','FR','US','CA', 'UK' ]
new_channels =[]
f = open(new_path_filename, "r", encoding="utf8")
Lines = f.readlines()
print('ouverture fichier', flush=True)
channel = {}
separator = '--------'
topic = ''
num_channel = 0
total_lines = len(Lines)
number_line = 0
for line in Lines:
number_line += 1
if separator in line:
topic = line[10:]
topic = topic.replace(separator,'')
if len(channel)>0 and line[0:4]=="http":
channel["url"] = line.strip('\n')
if len(LanguageChannel) > 0:
new_channels.append(channel)
print(num_channel, flush=True)
if num_channel % 1000 == 0:
bulk_insert_channels(collection_channels, new_channels, num_channel)
new_channels = []
print('%d / %d' % (number_line, total_lines), flush=True)
if line[0:4] == "#EXT":
channel = {}
# DEFINE CHANNEL NAME
name_channel = line[line.rfind(',')+1:].strip('\n')
id_channel = str(uuid.uuid4())
LanguageChannel =''
for langChannel in TypeLangChannel:
if langChannel in line:
LanguageChannel = langChannel
# deternine url logo
url_logo = findInfo('tvg-logo', line)
#if len(url_logo) == 0:
# cleaned_channel_name = clean_name_channel(name_channel)
# logo_data = logos.find({ 'logo_name': { '$regex': cleaned_channel_name, '$options': "i" } })
# for l in logo_data:
# url_logo = l['logo_url']
# break
channel_exist = False
if len(name_channel)>0:
channel = {"id_playlist":id_playlist, "id_channel":id_channel, "Name":name_channel, "Language":LanguageChannel, "Topic":topic, "urlLogo" : url_logo}
num_channel += 1
channel_exist = True
if channel_exist == False:
name_channel = line[line.rfind('|')+2:].strip('\n')
if len(name_channel) > 0:
channel = {"id_playlist":id_playlist, "id_channel":id_channel, "Name":name_channel, "Language":LanguageChannel, "Topic":topic, "urlLogo" : url_logo}
bulk_insert_channels(collection_channels, new_channels, num_channel)
print("Save final channels ", num_channel, flush=True)
number_channels = collection_channels.count_documents({"id_playlist":id_playlist})
playlists.update_many({'id_playlist':id_playlist}, { "$set": { "num_channels": number_channels } } )
result = {'num_channel': number_channels}
return web.json_response(result)
async def import_xtream_codes(request): async def import_xtream_codes(request):
data = await request.post() data = await request.post()
id_playlist = data.get('id_playlist', '').strip() id_playlist = data.get('id_playlist', '').strip()
@@ -679,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)
@@ -721,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)
@@ -741,19 +714,20 @@ async def run_xtream_import(id_playlist, import_key, collection_channels,
try: try:
await emit_analysis_progress({'percentage': 0.02}, sidClient) await emit_analysis_progress({'percentage': 0.02}, sidClient)
live_categories = parse_xtream_category_map( loop = asyncio.get_running_loop()
fetch_xtream_json(xtream_server, xtream_username, xtream_password, 'get_live_categories')
)
vod_categories = parse_xtream_category_map(
fetch_xtream_json(xtream_server, xtream_username, xtream_password, 'get_vod_categories')
)
series_categories = parse_xtream_category_map(
fetch_xtream_json(xtream_server, xtream_username, xtream_password, 'get_series_categories')
)
live_streams = fetch_xtream_json(xtream_server, xtream_username, xtream_password, 'get_live_streams') async def fetch(action, **extra_params):
vod_streams = fetch_xtream_json(xtream_server, xtream_username, xtream_password, 'get_vod_streams') return await loop.run_in_executor(
series_list = fetch_xtream_json(xtream_server, xtream_username, xtream_password, 'get_series') None, lambda: fetch_xtream_json(xtream_server, xtream_username, xtream_password, action, **extra_params)
)
live_categories = parse_xtream_category_map(await fetch('get_live_categories'))
vod_categories = parse_xtream_category_map(await fetch('get_vod_categories'))
series_categories = parse_xtream_category_map(await fetch('get_series_categories'))
live_streams = await fetch('get_live_streams')
vod_streams = await fetch('get_vod_streams')
series_list = await fetch('get_series')
total_steps = max(1, len(live_streams) + len(vod_streams) + len(series_list)) total_steps = max(1, len(live_streams) + len(vod_streams) + len(series_list))
processed_steps = 0 processed_steps = 0
@@ -841,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)
@@ -988,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({})
@@ -1002,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):
@@ -1012,31 +1014,44 @@ async def get_source(request):
# List Channels # List Channels
async def list_channel(request): def _search_channels_sync(channel_to_search, current_page):
MAX_RECORDS = 30 MAX_RECORDS = 30
data = await request.post()
channel_to_search = unquote(data['keyword']) escaped_keyword = re.escape(channel_to_search)
current_page = int(data['num_page']) # Channel names are typically prefixed with a country/format tag (e.g.
# "AR - BEIN SPORTS 1 FHD"), so match at word boundaries rather than the
# start of the string to rank those above unrelated names that merely
# contain the keyword mid-word.
prefix_pattern = re.compile(r'\b' + escaped_keyword, re.IGNORECASE)
contains_pattern = re.compile(escaped_keyword, re.IGNORECASE)
logger.info("Search keyword='%s' page=%s", channel_to_search, current_page)
regex_pattern = ".*" + re.escape(channel_to_search) + ".*"
# First pass: compute totals without pulling all docs into memory
total_records = 0
active_playlists = list(playlists.find({"activation": 1})) active_playlists = list(playlists.find({"activation": 1}))
per_playlist_counts = []
# Two relevance tiers, most relevant first: names starting with the keyword,
# then everything else that merely contains it. Counted per tier/playlist so
# pagination stays a cheap count+skip/limit instead of pulling every match
# into memory to sort it.
tiers = []
for playlist in active_playlists: for playlist in active_playlists:
collection_channels = db_iptv['channels_' + playlist['id_playlist']] collection_channels = db_iptv['channels_' + playlist['id_playlist']]
count = collection_channels.count_documents({ prefix_filter = {'id_playlist': playlist['id_playlist'], 'Name': prefix_pattern}
count = collection_channels.count_documents(prefix_filter)
if count:
tiers.append((playlist, prefix_filter, count))
for playlist in active_playlists:
collection_channels = db_iptv['channels_' + playlist['id_playlist']]
contains_only_filter = {
'id_playlist': playlist['id_playlist'], 'id_playlist': playlist['id_playlist'],
'Name': {'$regex': regex_pattern, '$options': "i"} '$and': [{'Name': contains_pattern}, {'Name': {'$not': prefix_pattern}}],
}) }
per_playlist_counts.append((playlist, count)) count = collection_channels.count_documents(contains_only_filter)
total_records += count if count:
tiers.append((playlist, contains_only_filter, count))
total_records = sum(count for _, _, count in tiers)
if total_records == 0: if total_records == 0:
return web.json_response({'num_total_page': 0, 'current_page': 1, 'data': []}) return {'num_total_page': 0, 'current_page': 1, 'data': []}
num_total_page = max(1, (total_records + MAX_RECORDS - 1) // MAX_RECORDS) num_total_page = max(1, (total_records + MAX_RECORDS - 1) // MAX_RECORDS)
current_page = max(1, min(current_page, num_total_page)) current_page = max(1, min(current_page, num_total_page))
@@ -1046,7 +1061,7 @@ async def list_channel(request):
# Second pass: fetch only the page slice # Second pass: fetch only the page slice
serialized_data = [] serialized_data = []
offset = 0 offset = 0
for playlist, count in per_playlist_counts: for playlist, mongo_filter, count in tiers:
if remaining == 0: if remaining == 0:
break break
if offset + count <= begin: if offset + count <= begin:
@@ -1056,16 +1071,30 @@ async def list_channel(request):
local_skip = max(0, begin - offset) local_skip = max(0, begin - offset)
fetch_limit = min(remaining, count - local_skip) fetch_limit = min(remaining, count - local_skip)
collection_channels = db_iptv['channels_' + playlist['id_playlist']] collection_channels = db_iptv['channels_' + playlist['id_playlist']]
cursor = collection_channels.find({ cursor = collection_channels.find(mongo_filter).skip(local_skip).limit(fetch_limit)
'id_playlist': playlist['id_playlist'],
'Name': {'$regex': regex_pattern, '$options': "i"}
}).skip(local_skip).limit(fetch_limit)
serialized_data.extend(serialize_doc(doc) for doc in cursor) serialized_data.extend(serialize_doc(doc) for doc in cursor)
remaining = MAX_RECORDS - len(serialized_data) remaining = MAX_RECORDS - len(serialized_data)
offset += count offset += count
return web.json_response({'num_total_page': num_total_page, 'current_page': current_page, 'data': serialized_data}) return {'num_total_page': num_total_page, 'current_page': current_page, 'data': serialized_data}
async def list_channel(request):
data = await request.post()
channel_to_search = unquote(data['keyword'])
current_page = int(data['num_page'])
logger.info("Search keyword='%s' page=%s", channel_to_search, current_page)
# Searching can mean scanning a multi-hundred-thousand-channel collection
# with an unindexed regex (several seconds); run it off the event loop so
# it doesn't stall every other request/socket while it works.
result = await asyncio.get_running_loop().run_in_executor(
None, _search_channels_sync, channel_to_search, current_page
)
return web.json_response(result)
@@ -1138,19 +1167,6 @@ async def get_logo(request):
return web.Response(body=image_data, content_type="image/jpeg") return web.Response(body=image_data, content_type="image/jpeg")
async def proxy_image(request):
url = request.query.get("url")
if not url:
return web.Response(status=400, text="Missing URL")
async with aiohttp.ClientSession() as session:
async with session.get(url) as resp:
if resp.status == 200:
headers = {"Content-Type": resp.headers["Content-Type"]}
return web.Response(body=await resp.read(), headers=headers)
else:
return web.Response(status=resp.status)
async def list_logo(request): async def list_logo(request):
result = logos.find({}).limit(1000) result = logos.find({}).limit(1000)
search_string = "harry potter" search_string = "harry potter"
@@ -1164,8 +1180,6 @@ async def list_logo(request):
async def list_devices(request): async def list_devices(request):
json_devices = json.dumps(devices)
#return web.json_response(json_devices)
return web.json_response(dumps(devices_db.find({}).limit(50))) return web.json_response(dumps(devices_db.find({}).limit(50)))
@@ -1416,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)
@@ -1475,8 +1490,12 @@ def messageReceived():
def connect(sid, environ): def connect(sid, environ):
# engineio's aiohttp integration hardcodes REMOTE_ADDR to 127.0.0.1; # engineio's aiohttp integration hardcodes REMOTE_ADDR to 127.0.0.1;
# the real peer address is only available via the underlying aiohttp request. # the real peer address is only available via the underlying aiohttp request.
# Behind the nginx reverse proxy, that peer is nginx itself, so prefer the
# client IP nginx forwards via X-Real-IP (set in iptv.mrk.ovh.conf).
aiohttp_request = environ.get('aiohttp.request') aiohttp_request = environ.get('aiohttp.request')
ip_address = aiohttp_request.remote if aiohttp_request else '' ip_address = ''
if aiohttp_request:
ip_address = aiohttp_request.headers.get('X-Real-IP') or aiohttp_request.remote or ''
sio_session_ips[sid] = ip_address sio_session_ips[sid] = ip_address
print("connect ", sid, ip_address, flush=True) print("connect ", sid, ip_address, flush=True)
@@ -1486,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')
@@ -1507,12 +1548,14 @@ 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)
@sio.event @sio.event
async def polling(sid, data): async def polling(sid, data):
global devices
device_name = data['name'] device_name = data['name']
IdDevice = data['IdDevice'] IdDevice = data['IdDevice']
mediaRunning = data['mediaRunning'] mediaRunning = data['mediaRunning']
@@ -1524,7 +1567,7 @@ async def polling(sid, data):
print('data_running :', data_running, flush=True) print('data_running :', data_running, flush=True)
try: try:
vpn_status = data['vpn_status'] vpn_status = data['vpn_status']
except: except KeyError:
vpn_status = 'unknown' vpn_status = 'unknown'
ip_address = sio_session_ips.get(sid, '') ip_address = sio_session_ips.get(sid, '')
@@ -1542,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,
@@ -1573,24 +1623,6 @@ async def polling(sid, data):
else: else:
recents.insert_one(new_record) recents.insert_one(new_record)
test_exist_device = False
for device in devices:
if device['IdDevice'] == IdDevice:
test_exist_device = True
device['last_datetime'] = device_last_datetime
device['name'] = device_name
device['sid'] = sid
device['mediaRunning'] = mediaRunning
try:
device['vpn_status'] = vpn_status
except:
device['vpn_status'] = 'unknown'
if not test_exist_device:
new_device = { 'IdDevice': IdDevice, 'name' : device_name, 'last_datetime' : device_last_datetime, 'sid':sid ,'mediaRunning' : mediaRunning, 'vpn_status':vpn_status}
devices.append(new_device)
# Main # Main
if __name__ == '__main__': if __name__ == '__main__':
+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
+117 -16
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)
@@ -1881,6 +1924,13 @@
get_search_channel(1) get_search_channel(1)
}) })
$('#input_search').on('keypress', function(e){
if (e.which === 13) {
e.preventDefault();
get_search_channel(1);
}
})
$('#btn_initdb').click(function(){ $('#btn_initdb').click(function(){
$.get('init_db').done(function(data){ $.get('init_db').done(function(data){
window.location.href = "/" window.location.href = "/"
@@ -1978,6 +2028,8 @@
var name_channel = element['Name'] var name_channel = element['Name']
var url_channel = element['url'] var url_channel = element['url']
var id_playlist = element['id_playlist'] var id_playlist = element['id_playlist']
var topic_channel = element['Topic'] || ''
var topicBadge = topic_channel ? `<span class="badge bg-secondary channel-topic-badge">${topic_channel}</span>` : ''
var url_channel_macos = url_channel.replace("http://", 'rtmp://') var url_channel_macos = url_channel.replace("http://", 'rtmp://')
var oid = '' var oid = ''
@@ -2003,8 +2055,9 @@
data-id_playlist="${id_playlist}"> data-id_playlist="${id_playlist}">
<img src="logos/${id_channel}.jpg" class="channel-row-logo" <img src="logos/${id_channel}.jpg" class="channel-row-logo"
onerror="this.onerror=null; this.src='get_logo?id_channel=${id_channel}&id_playlist=${id_playlist}';"> onerror="this.onerror=null; this.src='get_logo?id_channel=${id_channel}&id_playlist=${id_playlist}';">
<span class="channel-row-name text-truncate">${name_channel}</span> <span class="channel-row-name text-truncate">${name_channel}</span>
${topicBadge}
${colorCircle} ${colorCircle}
</div> </div>
@@ -2020,11 +2073,11 @@
onerror="this.onerror=null; this.src='get_logo?id_channel=${id_channel}&id_playlist=${id_playlist}';" alt="${name_channel}"> onerror="this.onerror=null; this.src='get_logo?id_channel=${id_channel}&id_playlist=${id_playlist}';" alt="${name_channel}">
</div> </div>
<p class="btn_play_media card-text mb-3" <p class="btn_play_media card-text mb-3"
data-url_channel="${url_channel}" data-url_channel="${url_channel}"
data-name_channel="${name_channel}" data-name_channel="${name_channel}"
data-id_channel="${id_channel}" data-id_channel="${id_channel}"
data-id_playlist="${id_playlist}">${name_channel}</p> data-id_playlist="${id_playlist}">${name_channel}</p>
<div class="d-flex justify-content-center align-items-center gap-2">${colorCircle}</div> <div class="d-flex justify-content-center align-items-center gap-2">${colorCircle}${topicBadge}</div>
<div class="d-flex justify-content-center gap-2 mt-3"> <div class="d-flex justify-content-center gap-2 mt-3">
<button data-display_url="${url_channel}" class="btn_display_url btn btn-sm btn-outline-info">url</button> <button data-display_url="${url_channel}" class="btn_display_url btn btn-sm btn-outline-info">url</button>
<a class="btn btn-sm btn-primary" href="vlc://${url_channel_macos}">vlc ios</a> <a class="btn btn-sm btn-primary" href="vlc://${url_channel_macos}">vlc ios</a>
@@ -2065,17 +2118,26 @@
function get_search_channel(num_page) { function get_search_channel(num_page) {
var keyword = $('#input_search').val() var keyword = $('#input_search').val()
if (keyword.length>0) { if (keyword.length>0) {
$('#pagination-container').empty();
$('#listChannels').show().html('<div class="text-center text-muted py-4"><i class="fas fa-spinner fa-spin"></i> Searching...</div>');
$.post('list_channel', {keyword:encodeURIComponent(keyword), num_page:num_page}).done(function(payload){ $.post('list_channel', {keyword:encodeURIComponent(keyword), num_page:num_page}).done(function(payload){
$('#list_channels').html('')
var html =''
num_total_page = payload.num_total_page num_total_page = payload.num_total_page
current_page = payload.current_page current_page = payload.current_page
result_search = payload.data result_search = payload.data
console.log(num_total_page, current_page) console.log(num_total_page, current_page)
renderPagination(num_total_page, current_page);
if (!result_search || result_search.length === 0) {
$('#pagination-container').empty();
$('#listChannels').html('<div class="text-center text-muted py-4">No channels found.</div>');
return;
}
renderPagination(num_total_page, current_page);
displayResult() displayResult()
lazyLoad(); lazyLoad();
}).fail(function(){
$('#pagination-container').empty();
$('#listChannels').html('<div class="text-center text-danger py-4">Search failed. Please try again.</div>');
}) })
} }
} }
@@ -2403,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>-->\
@@ -2413,7 +2476,7 @@
var dataJSON = JSON.parse(data) var dataJSON = JSON.parse(data)
list_playlist = dataJSON list_playlist = dataJSON
for (var i=0;i<dataJSON.length;i++) { for (var i=0;i<dataJSON.length;i++) {
var id_playlist = dataJSON[i].id_playlist var id_playlist = dataJSON[i].id_playlist
@@ -2432,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>\
@@ -2812,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();
@@ -2871,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);
@@ -2903,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,
@@ -2967,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,
@@ -2974,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
@@ -3139,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;
} }