commit a4b23c5f5d32ab3a1d22f65197a46acc05d0c31a Author: ubuntu Date: Mon Aug 3 13:14:43 2026 +0000 Commit initial du projet serverIPTV diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5379e3f --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +.DS_Store +__pycache__/ +*.pyc +.venv/ +venv/ + +# Contenu téléchargé / généré, pas du code source +playlists/ +playlists_download/ +logos/ +letsencrypt/ +epg/epg.xml +static/config_init.db +static/device_snapshots/ + +# Config locale spécifique à cette machine +.claude/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e54818e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +FROM python +RUN apt-get update -y +ENV LANG C.UTF-8 +ENV LC_ALL C.UTF-8 + +RUN apt-get -y install python3-pip +RUN apt-get install --reinstall ca-certificates + +RUN pip3 install aiohttp +RUN pip3 install aiohttp-session +RUN pip3 install aiomcache +RUN pip3 install python-socketio +RUN pip3 install pyOpenSSL +RUN pip3 install tornado +RUN pip3 install aiohttp-cors +RUN pip3 install requests +RUN pip3 install pillow +RUN pip3 install pymongo +RUN pip3 install aiofiles +RUN pip3 install wikipedia-api +RUN pip3 install beautifulsoup4 + +EXPOSE 443 + +WORKDIR "/home" + +CMD ["python3", "server.py"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c137195 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,37 @@ +version: "3.8" + +services: + mongodb: + image: mongo:latest + container_name: mongodb + ports: + - "27017:27017" + environment: + MONGO_INITDB_ROOT_USERNAME: root + MONGO_INITDB_ROOT_PASSWORD: example + volumes: + - mongodb_data:/data/db + networks: + - app-network + + python-app: + build: . + container_name: serverIPTV-container + depends_on: + - mongodb + environment: + - MONGO_URI=mongodb://root:example@mongodb:27017/?authSource=admin + networks: + - app-network + volumes: + - /etc/letsencrypt/:/home/letsencrypt/:rw + - /home/ubuntu/serverIPTV/:/home/:rw + ports: + - "5023:5023" + - "443:443" + +networks: + app-network: + +volumes: + mongodb_data: diff --git a/download_logo/download_logo.py b/download_logo/download_logo.py new file mode 100644 index 0000000..6e1d37f --- /dev/null +++ b/download_logo/download_logo.py @@ -0,0 +1,60 @@ + + +# Generic functions +def findInfo(tag, s): + end = '"' + if (s.find(tag)>-1): + t = s[s.find(tag)+len(tag)+2:s.rfind(end)] + return t[0:t.find(end)] + else: + return "" + + + + +def download_logo(): + + path_filename = 'source.m3u' + + # Process the m3u file + f = open(path_filename, "r", encoding="utf8") + Lines = f.readlines() + print('ouverture fichier', flush=True) + channel = {} + separator = '--------' + topic = '' + num_channel = 0 + for line in Lines: + #print(line,flush=True) + + if separator in line: + topic = line[10:] + topic = topic.replace(separator,'') + + if len(channel)>0 and line[0:4]=="http": + + print ('download ' + channel['urlLogo']) + #new_channels.append(channel) + #print(channel, flush=True) + + if line[0:4] == "#EXT": + channel = {} + + # DEFINE CHANNEL NAME + name_channel = line[line.rfind(',')+1:].strip('\n') + + channel_exist = False + if len(name_channel)>0: + channel = {"Name":name_channel, "Topic":topic, "urlLogo" : findInfo('tvg-logo', line)} + num_channel += 1 + channel_exist = True + + if channel_exist == False: + name_channel = line[line.rfind('|')+2:].strip('\n') + if len(name_channel) > 0: + channel = {"Name":name_channel, "Topic":topic, "urlLogo" : findInfo('tvg-logo', line)} + + + result = {'num_channel': num_channel} + + return result \ No newline at end of file diff --git a/epg/download_epg.py b/epg/download_epg.py new file mode 100644 index 0000000..704f3b1 --- /dev/null +++ b/epg/download_epg.py @@ -0,0 +1,21 @@ +import requests + +url = "http://xfvjwqjn.duperab.xyz/xmltv.php?username=VQNRZG7N&password=W3NFBDXY" + +headers = { + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", + "Accept-Language": "en-US,en;q=0.9,fr;q=0.8,fr-FR;q=0.7", + "Cache-Control": "max-age=0", + "Connection": "keep-alive", + "Upgrade-Insecure-Requests": "1", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36 Edg/135.0.0.0" +} + +response = requests.get(url, headers=headers, verify=False) + +if response.status_code == 200 and response.content: + with open("epg.xml", "wb") as f: + f.write(response.content) + print("Download successful: epg.xml") +else: + print(f"Failed to download. Status code: {response.status_code}, Content length: {len(response.content)}") diff --git a/server.py b/server.py new file mode 100644 index 0000000..813adab --- /dev/null +++ b/server.py @@ -0,0 +1,1488 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# pylint: disable=W0613, C0116 +# type: ignore[union-attr] + +from pymongo import MongoClient +from aiohttp import web +import socketio +import os, glob +import time +import json +import ssl +import sys +import uuid +import logging +from aiohttp_session import setup, get_session, new_session +from aiohttp_session.cookie_storage import EncryptedCookieStorage +from tornado.template import Loader +from bson.json_util import dumps +from urllib.parse import unquote +import aiohttp_cors +import aiofiles +import urllib.parse +import requests +from PIL import Image +import io, re +import xml.etree.ElementTree as ET +from bs4 import BeautifulSoup +import pathlib +from datetime import datetime +from bson import ObjectId +import urllib.request + + + +from pymongo import MongoClient + + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s", + handlers=[logging.StreamHandler(sys.stdout)], +) +logger = logging.getLogger(__name__) + +# Connexion Database +MONGO_URI = os.getenv("MONGO_URI", "mongodb://root:example@mongodb:27017/?authSource=admin") + + +def connect_mongo(): + for attempt in range(1, 6): + try: + client = MongoClient(MONGO_URI, serverSelectionTimeoutMS=5000, connectTimeoutMS=5000) + client.admin.command("ping") + logger.info("Connected to MongoDB") + return client + except Exception as exc: # retry if Mongo is not ready yet + logger.warning("Mongo connection failed (attempt %s/5): %s", attempt, exc) + time.sleep(5) + raise RuntimeError("MongoDB is unreachable after retries") + + +client = connect_mongo() + +# Connect to the database and collection +db_iptv = client["db_iptv"] +channels = db_iptv['channels'] +channels.create_index("id_playlist") +playlists = db_iptv['playlists'] +logos = db_iptv['logos'] +recents = db_iptv['recents'] +settings_collection = db_iptv['settings'] + +devices_db = db_iptv['devices'] +devices = [] +active_xtream_imports = {} + +# Configure Socket IO +sio = socketio.AsyncServer(logger=True, engineio_logger=True, cors_allowed_origins='*') +app = web.Application() +sio.attach(app) + +# Variables +path = os.path.dirname(os.path.realpath(__file__))+'/' +ssl_cert_path = '/home/letsencrypt/live/iptv.mrk.ovh/cert.pem' +ssl_key_path = '/home/letsencrypt/live/iptv.mrk.ovh/privkey.pem' +UPLOAD_PLAYLIST_DIR = "playlists" +DOWNLOAD_EPG_DIR = "epg" +LOGO_DIR = "/home/logos" +M3U_LOGO_SOURCE = '/home/playlists_download/tv_channels_2a7e1ba958_plus.m3u' +URL_SERVER = 'https://iptv.mrk.ovh/' +DEVICE_SNAPSHOT_DIR = pathlib.Path(__file__).parent / 'static' / 'device_snapshots' +DEVICE_SNAPSHOT_DIR.mkdir(parents=True, exist_ok=True) + +# Set up Tornado template loader +loader = Loader('') + +# SSL context setup +ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) +ssl_context.load_cert_chain(certfile=ssl_cert_path, keyfile=ssl_key_path) + + +# Generic functions +def findInfo(tag, s): + end = '"' + if (s.find(tag)>-1): + t = s[s.find(tag)+len(tag)+2:s.rfind(end)] + return t[0:t.find(end)] + else: + return "" + +# Helper function to convert MongoDB documents to JSON-serializable format +def serialize_doc(doc): + doc["_id"] = str(doc["_id"]) # Convert ObjectId to string + return doc + +def clean_name_channel(name_channel): + new_name_channel = re.sub(r'\b\w{2}\b', '', name_channel) + new_name_channel = re.sub(r'\s+', ' ', new_name_channel).strip() + new_name_channel = new_name_channel.replace('- ','') + new_name_channel = new_name_channel.replace('| ','') + new_name_channel = new_name_channel.replace(' UHD','') + new_name_channel = new_name_channel.replace(' FHD','') + new_name_channel = new_name_channel.replace(' -','') + new_name_channel = new_name_channel.replace(' /','') + new_name_channel = new_name_channel.replace('','') + pattern = r".{2}\|" + new_name_channel = re.sub(pattern, "", new_name_channel) + new_name_channel = re.sub(r"[\(\{\[].*?[\)\}\]]", "", new_name_channel) + new_name_channel = new_name_channel.strip() + return new_name_channel + + +def get_playlist_collection(id_playlist): + return db_iptv['channels_' + id_playlist] + + +async def emit_analysis_progress(payload, sid_client=None): + if not sid_client: + return + data = {'typeCommand': 'progress_analysis', 'payload': payload} + await sio.emit('communication', data, room=sid_client, callback=messageReceived) + + +def get_existing_playlist(id_playlist): + return playlists.find_one({"id_playlist": id_playlist}) or {} + + +def find_existing_xtream_playlist(server_url, username, password): + return playlists.find_one({ + "playlist_type": "xtream_codes", + "xtream_server": normalize_xtream_server(server_url), + "xtream_username": (username or '').strip(), + "xtream_password": (password or '').strip(), + }) or {} + + +def build_playlist_document(id_playlist, title_playlist, date_end_playlist, selected_color, extra_fields=None): + existing_playlist = get_existing_playlist(id_playlist) + playlist_doc = { + 'id_playlist': id_playlist, + 'title_playlist': title_playlist, + 'date_end_playlist': date_end_playlist, + 'selectedColor': selected_color, + 'activation': existing_playlist.get('activation', 1), + } + if extra_fields: + playlist_doc.update(extra_fields) + return playlist_doc + + +def save_playlist_document(playlist_doc): + playlists.update_one( + {"id_playlist": playlist_doc["id_playlist"]}, + {"$set": playlist_doc}, + upsert=True + ) + + +def delete_playlist_resources(id_playlist): + db_iptv.drop_collection("channels_" + id_playlist) + + result = playlists.delete_one({"id_playlist": id_playlist}) + + file_path = os.path.join(UPLOAD_PLAYLIST_DIR, id_playlist + '.m3u') + if os.path.exists(file_path): + os.remove(file_path) + print(f"File {file_path} deleted.") + + return result.deleted_count + + +def bulk_insert_channels(collection_channels, new_channels, num_channel): + if not new_channels: + return + collection_channels.insert_many(new_channels) + print("Save channels ", num_channel, flush=True) + + +def normalize_xtream_server(server_url): + server_url = (server_url or '').strip() + if not server_url: + return '' + if not re.match(r'^https?://', server_url, re.IGNORECASE): + server_url = 'http://' + server_url + return server_url.rstrip('/') + + +def build_xtream_playlist_url(server_url, username, password): + base = normalize_xtream_server(server_url) + if not base or not username or not password: + return '' + return f"{base}/get.php?username={username}&password={password}&type=m3u_plus&output=ts" + + +def build_xtream_api_url(server_url, username, password, action, **extra_params): + base = normalize_xtream_server(server_url) + query = { + 'username': username, + 'password': password, + 'action': action, + } + query.update(extra_params) + return f"{base}/player_api.php?{urllib.parse.urlencode(query)}" + + +def fetch_xtream_json(server_url, username, password, action, **extra_params): + url = build_xtream_api_url(server_url, username, password, action, **extra_params) + response = requests.get(url, timeout=60) + response.raise_for_status() + return response.json() + + +def parse_xtream_category_map(items, id_key='category_id', name_key='category_name'): + category_map = {} + for item in items or []: + category_id = str(item.get(id_key, '')).strip() + if category_id: + category_map[category_id] = item.get(name_key, '') or '' + return category_map + + +def make_xtream_live_channel(id_playlist, item, category_map, username, password, server_url): + stream_id = item.get('stream_id') + if not stream_id: + return None + container_extension = item.get('container_extension') or 'ts' + return { + "id_playlist": id_playlist, + "id_channel": str(uuid.uuid4()), + "Name": item.get('name') or f"Live {stream_id}", + "Language": item.get('stream_type') or 'live', + "Topic": category_map.get(str(item.get('category_id', '')), ''), + "urlLogo": item.get('stream_icon', '') or '', + "url": f"{normalize_xtream_server(server_url)}/live/{username}/{password}/{stream_id}.{container_extension}", + "media_type": "live", + "stream_type": item.get('stream_type', 'live'), + "stream_id": stream_id, + "category_id": item.get('category_id'), + "epg_channel_id": item.get('epg_channel_id', ''), + } + + +def make_xtream_vod_channel(id_playlist, item, category_map, username, password, server_url): + stream_id = item.get('stream_id') + if not stream_id: + return None + container_extension = item.get('container_extension') or 'mp4' + return { + "id_playlist": id_playlist, + "id_channel": str(uuid.uuid4()), + "Name": item.get('name') or f"Movie {stream_id}", + "Language": item.get('stream_type') or 'movie', + "Topic": category_map.get(str(item.get('category_id', '')), ''), + "urlLogo": item.get('stream_icon', '') or item.get('cover', '') or '', + "url": f"{normalize_xtream_server(server_url)}/movie/{username}/{password}/{stream_id}.{container_extension}", + "media_type": "movie", + "stream_type": item.get('stream_type', 'movie'), + "stream_id": stream_id, + "category_id": item.get('category_id'), + "rating": item.get('rating', ''), + } + + +def flatten_xtream_episodes(episodes_payload): + episodes = [] + if isinstance(episodes_payload, dict): + for season_episodes in episodes_payload.values(): + if isinstance(season_episodes, list): + episodes.extend(season_episodes) + elif isinstance(episodes_payload, list): + episodes.extend(episodes_payload) + return episodes + + +def make_xtream_series_channels(id_playlist, series_item, series_info, category_map, username, password, server_url): + topic = category_map.get(str(series_item.get('category_id', '')), '') + series_name = series_item.get('name') or f"Series {series_item.get('series_id')}" + series_logo = series_item.get('cover') or series_item.get('cover_big') or series_item.get('stream_icon') or '' + channels_to_insert = [] + + for episode in flatten_xtream_episodes(series_info.get('episodes')): + episode_id = episode.get('id') or episode.get('episode_id') + if not episode_id: + continue + container_extension = episode.get('container_extension') or 'mp4' + episode_title = episode.get('title') or episode.get('name') or f"Episode {episode_id}" + episode_num = episode.get('episode_num') + season_num = episode.get('season') + episode_label = episode_title + try: + if season_num is not None and episode_num is not None: + episode_label = f"{series_name} - S{int(season_num):02d}E{int(episode_num):02d} - {episode_title}" + else: + episode_label = f"{series_name} - {episode_title}" + except (TypeError, ValueError): + episode_label = f"{series_name} - {episode_title}" + + channels_to_insert.append({ + "id_playlist": id_playlist, + "id_channel": str(uuid.uuid4()), + "Name": episode_label, + "Language": "series", + "Topic": topic, + "urlLogo": episode.get('info', {}).get('movie_image', '') or series_logo, + "url": f"{normalize_xtream_server(server_url)}/series/{username}/{password}/{episode_id}.{container_extension}", + "media_type": "series", + "stream_type": "series", + "series_id": series_item.get('series_id'), + "episode_id": episode_id, + "category_id": series_item.get('category_id'), + "series_name": series_name, + "season": season_num, + "episode_num": episode_num, + }) + + if channels_to_insert: + return channels_to_insert + + return [{ + "id_playlist": id_playlist, + "id_channel": str(uuid.uuid4()), + "Name": series_name, + "Language": "series", + "Topic": topic, + "urlLogo": series_logo, + "url": "", + "media_type": "series", + "stream_type": "series", + "series_id": series_item.get('series_id'), + "category_id": series_item.get('category_id'), + "series_name": series_name, + }] + + +def save_image_from_url(url, save_path): + try: + # Send a GET request to the URL + response = requests.get(url) + response.raise_for_status() # Raise an error for unsuccessful status codes + + # Write the content of the response to a file + with open(save_path, 'wb') as file: + file.write(response.content) + print(f"Image saved to {save_path}") + except requests.exceptions.RequestException as e: + print(f"Error downloading the image: {e}") + + +def load_epg_root(): + """ + Load EPG XML either from the URL stored in settings or from a local fallback file. + Returns an ElementTree root or None if nothing can be loaded. + """ + xml_content = None + + # Try remote URL first (if configured) + doc = settings_collection.find_one({'key': 'epg_url'}) + epg_url = doc.get('value') if doc else None + + if epg_url: + try: + resp = requests.get(epg_url, timeout=10) + if resp.status_code == 200 and resp.content: + xml_content = resp.content + except requests.RequestException as exc: + logger.warning("Unable to fetch remote EPG (%s): %s", epg_url, exc) + + # Fallback to local file + if xml_content is None: + local_path = os.path.join(DOWNLOAD_EPG_DIR, 'epg.xml') + if os.path.exists(local_path): + with open(local_path, 'rb') as f: + xml_content = f.read() + + if not xml_content: + return None + + try: + return ET.fromstring(xml_content) + except ET.ParseError as exc: + logger.error("Failed to parse EPG XML: %s", exc) + return None + + +# AIOHTTP functions + +# INDEX +async def index(request): + template = loader.load('static/index.html') + return web.Response(text=template.generate().decode('utf-8'), content_type='text/html') + +# Access to playlist file from the web +async def playlist_download(request): + data = dict(request.query) + file_path = '/home/playlists_download/'+data['filename'] + if os.path.exists(file_path): + return web.FileResponse(file_path) + else: + raise web.HTTPNotFound(text='File not found') + +async def init_db(request): + + db_iptv.drop_collection("channels") + db_iptv.drop_collection("playlists") + + return web.Response(text=str(res),content_type='text/html') + + + +async def analyse_m3u_file(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'] + selectedColor = data['selectedColor'] + 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, + selectedColor, + { + '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 + + new_channels =[] + f = open(new_path_filename, "r", encoding="utf8") + Lines = f.readlines() + print('ouverture fichier', flush=True) + channel = {} + separator = '--------' + topic = '' + num_channel = 0 + TypeLangChannel = ['EN','FR','US','CA', 'UK' ] + + 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,'') + + # PROCESS URL LINE + if len(channel)>0 and line[0:4]=="http": + channel["url"] = line.strip('\n') + new_channels.append(channel) + #print(channel, flush=True) + if num_channel % 1000 == 0: + collection_channels.insert_many(new_channels) + new_channels = [] + print("Save channels ", num_channel, flush=True) + print('%d / %d' % (number_line, total_lines), flush=True) + await emit_analysis_progress({'percentage': number_line / total_lines}, sidClient) + + # PROCESS CHANNEL LINE + if line[0:4] == "#EXT": + channel = {} + + # ID CHANNEL + id_channel = str(uuid.uuid4()) + + # DEFINE LANGUAGE + LanguageChannel = next((lang for lang in TypeLangChannel if lang in line), '') + + # deternine url logo + url_logo = findInfo('tvg-logo', line) + + channel_exist = False + # case separator is | + name_channel = line[line.rfind(',')+1:].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} + num_channel += 1 + channel_exist = True + + # case separator is , + 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} + channel_exist = True + num_channel += 1 + + + 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 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): + data = await request.post() + id_playlist = data.get('id_playlist', '').strip() + title_playlist = data['title'].strip() + date_end_playlist = data['date_end'] + selectedColor = data.get('selectedColor', '') + playlist_comment = data.get('comment', '').strip() + sidClient = data.get('sidClient', '') + xtream_server = normalize_xtream_server(data.get('xtream_server', '')) + xtream_username = data.get('xtream_username', '').strip() + xtream_password = data.get('xtream_password', '').strip() + + 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) + + if not id_playlist: + existing_playlist = find_existing_xtream_playlist(xtream_server, xtream_username, xtream_password) + if existing_playlist: + id_playlist = existing_playlist['id_playlist'] + logger.info("Reusing existing Xtream playlist id=%s for %s", id_playlist, xtream_server) + else: + id_playlist = str(uuid.uuid4()) + + import_key = sidClient.strip() or id_playlist + if active_xtream_imports.get(import_key): + logger.warning("Xtream import already running for key=%s", import_key) + running_import = active_xtream_imports.get(import_key) or {} + return web.json_response({ + 'status': 'already_running', + 'message': 'An Xtream import is already running for this client. Please wait for it to finish.', + 'id_playlist': running_import.get('id_playlist', id_playlist), + }, status=202) + + active_xtream_imports[import_key] = { + 'id_playlist': id_playlist, + 'started_at': time.time(), + } + + collection_channels = get_playlist_collection(id_playlist) + collection_channels.drop() + + playlist_doc = build_playlist_document( + id_playlist, + title_playlist, + date_end_playlist, + selectedColor, + { + 'playlist_type': 'xtream_codes', + 'url_playlist': build_xtream_playlist_url(xtream_server, xtream_username, xtream_password), + 'comment': playlist_comment, + 'xtream_server': xtream_server, + 'xtream_username': xtream_username, + 'xtream_password': xtream_password, + } + ) + save_playlist_document(playlist_doc) + + try: + await emit_analysis_progress({'percentage': 0.02}, sidClient) + + live_categories = parse_xtream_category_map( + 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') + vod_streams = fetch_xtream_json(xtream_server, xtream_username, xtream_password, 'get_vod_streams') + series_list = fetch_xtream_json(xtream_server, xtream_username, xtream_password, 'get_series') + + total_steps = max(1, len(live_streams) + len(vod_streams) + len(series_list)) + processed_steps = 0 + pending_channels = [] + inserted_channels = 0 + + for item in live_streams: + channel = make_xtream_live_channel(id_playlist, item, live_categories, xtream_username, xtream_password, xtream_server) + if channel: + pending_channels.append(channel) + inserted_channels += 1 + processed_steps += 1 + if len(pending_channels) >= 1000: + bulk_insert_channels(collection_channels, pending_channels, inserted_channels) + pending_channels = [] + if processed_steps % 250 == 0: + await emit_analysis_progress({'percentage': processed_steps / total_steps}, sidClient) + + for item in vod_streams: + channel = make_xtream_vod_channel(id_playlist, item, vod_categories, xtream_username, xtream_password, xtream_server) + if channel: + pending_channels.append(channel) + inserted_channels += 1 + processed_steps += 1 + if len(pending_channels) >= 1000: + bulk_insert_channels(collection_channels, pending_channels, inserted_channels) + pending_channels = [] + if processed_steps % 250 == 0: + await emit_analysis_progress({'percentage': processed_steps / total_steps}, sidClient) + + for item in series_list: + series_channels = make_xtream_series_channels( + id_playlist, + item, + {}, + series_categories, + xtream_username, + xtream_password, + xtream_server + ) + pending_channels.extend(series_channels) + inserted_channels += len(series_channels) + processed_steps += 1 + if len(pending_channels) >= 1000: + bulk_insert_channels(collection_channels, pending_channels, inserted_channels) + pending_channels = [] + await emit_analysis_progress({'percentage': processed_steps / total_steps}, sidClient) + + bulk_insert_channels(collection_channels, pending_channels, inserted_channels) + number_channels = collection_channels.count_documents({"id_playlist": id_playlist}) + playlists.update_many({'id_playlist': id_playlist}, {"$set": {"num_channels": number_channels}}) + await emit_analysis_progress({'percentage': 1}, sidClient) + return web.json_response({'num_channel': number_channels, 'id_playlist': id_playlist}) + + except requests.RequestException as exc: + logger.exception("Xtream import failed") + return web.json_response({'error': f'Xtream request failed: {exc}'}, status=502) + except Exception as exc: + logger.exception("Xtream import failed") + return web.json_response({'error': f'Xtream import failed: {exc}'}, status=500) + finally: + active_xtream_imports.pop(import_key, None) + + +async def handle_upload(request): + reader = await request.multipart() + id_playlist = None + filename = None + filepath = None + + # Handle both form field and file in one pass + async for part in reader: + if part.name == "id_playlist_to_action": + id_playlist_data = await part.text() + if len(id_playlist_data) == 0: + print('No playlist to update', flush=True) + id_playlist = str(uuid.uuid4()) + else: + id_playlist = id_playlist_data + print('playlist to update :', id_playlist, flush=True) + + elif part.name == "file": + filename = part.filename + if not filename: + return web.Response(text="No file provided.", status=400) + + filepath = os.path.join(UPLOAD_PLAYLIST_DIR, filename) + + with open(filepath, "wb") as f: + while chunk := await part.read_chunk(): + f.write(chunk) + + if filepath: + return web.json_response({'status': 'ok', 'id_playlist': id_playlist}) + + return web.Response(text="No file field found in the request.", status=400) + + +async def upload_snapshot(request): + reader = await request.multipart() + id_device = None + file_bytes = bytearray() + + async for part in reader: + if part.name == 'id_device': + id_device = (await part.text()).strip() + elif part.name == 'file': + while True: + chunk = await part.read_chunk() + if not chunk: + break + file_bytes.extend(chunk) + + if not id_device or not file_bytes: + return web.json_response({'status': 'error', 'message': 'missing id_device or file'}, status=400) + + destination = DEVICE_SNAPSHOT_DIR / f'{id_device}.jpg' + with open(destination, 'wb') as output_file: + output_file.write(file_bytes) + + snapshot_relpath = f'/static/device_snapshots/{id_device}.jpg' + devices_db.update_one( + {'id_device': id_device}, + {'$set': {'snapshot_url': snapshot_relpath, 'snapshot_updated_at': time.time()}}, + upsert=True + ) + return web.json_response({'status': 'ok', 'snapshot_url': snapshot_relpath}) + + + +# COUNT CHANNELS +async def count_channels(request): + data = dict(request.query) + id_playlist = data['id_playlist'] + print('id_playlist : ', id_playlist, flush=True) + collection_channels = db_iptv['channels_'+id_playlist] + number_channels = collection_channels.count_documents({"id_playlist":id_playlist}) + print('number_channels : ', number_channels, flush=True) + return web.json_response({'number_channels': number_channels}) + + +# DELETE PLAYLIST +async def delete_playlist(request): + query_params = request.query + id_playlist = query_params.get("id_playlist") + nb_playlist_deleted = delete_playlist_resources(id_playlist) + + return web.json_response({ + 'nb_playlist_deleted': nb_playlist_deleted + }) + + +async def cleanup_xtream_duplicates(request): + duplicate_groups = {} + for playlist in playlists.find({"playlist_type": "xtream_codes"}): + xtream_server = normalize_xtream_server(playlist.get("xtream_server", "")) + xtream_username = (playlist.get("xtream_username", "") or "").strip() + xtream_password = (playlist.get("xtream_password", "") or "").strip() + if not xtream_server or not xtream_username or not xtream_password: + continue + duplicate_key = (xtream_server, xtream_username, xtream_password) + duplicate_groups.setdefault(duplicate_key, []).append(playlist) + + deleted_ids = [] + kept_ids = [] + + for playlists_group in duplicate_groups.values(): + if len(playlists_group) <= 1: + continue + + playlists_group.sort( + key=lambda playlist: ( + int(playlist.get("num_channels", 0) or 0), + int(playlist.get("activation", 0) or 0), + str(playlist.get("_id", "")), + ), + reverse=True, + ) + + kept_playlist = playlists_group[0] + kept_ids.append(kept_playlist["id_playlist"]) + + for duplicate_playlist in playlists_group[1:]: + duplicate_id = duplicate_playlist["id_playlist"] + if duplicate_id == kept_playlist["id_playlist"]: + continue + if delete_playlist_resources(duplicate_id): + deleted_ids.append(duplicate_id) + + return web.json_response({ + "status": "ok", + "deleted_count": len(deleted_ids), + "deleted_ids": deleted_ids, + "kept_ids": kept_ids, + }) + + +async def update_playlist_comment(request): + data = await request.post() + id_playlist = (data.get('id_playlist') or '').strip() + comment = (data.get('comment') or '').strip() + + if not id_playlist: + return web.json_response({'error': 'id_playlist is required'}, status=400) + + result = playlists.update_one( + {'id_playlist': id_playlist}, + {'$set': {'comment': comment}} + ) + + 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, 'comment': comment}) + + +# Get List Playlists +async def get_list_playlists(request): + active_count = playlists.count_documents({}) + #for p in playlists.find(): + #id_playlist = p['id_playlist'] + #number_channels = channels.count_documents({"id_playlist":id_playlist}) + #playlists.update_many({'id_playlist':id_playlist}, { "$set": { "num_channels": number_channels } } ) + #print("Document found:", p, flush=True) + #print("Number of channels:", number_channels, flush=True) + + print(str(active_count), flush=True) + if active_count == 0: + return web.json_response({'status':'no data'}) + else: + return web.json_response(dumps(playlists.find())) + + +async def get_source(request): + with open('static/IPTV.py') as f: + return web.Response(text=f.read(), content_type='text/plain') + + + +# List Channels +async def list_channel(request): + MAX_RECORDS = 30 + 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) + 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})) + per_playlist_counts = [] + for playlist in active_playlists: + collection_channels = db_iptv['channels_' + playlist['id_playlist']] + count = collection_channels.count_documents({ + 'id_playlist': playlist['id_playlist'], + 'Name': {'$regex': regex_pattern, '$options': "i"} + }) + per_playlist_counts.append((playlist, count)) + total_records += count + + if total_records == 0: + return web.json_response({'num_total_page': 0, 'current_page': 1, 'data': []}) + + num_total_page = max(1, (total_records + MAX_RECORDS - 1) // MAX_RECORDS) + current_page = max(1, min(current_page, num_total_page)) + begin = (current_page - 1) * MAX_RECORDS + remaining = MAX_RECORDS + + # Second pass: fetch only the page slice + serialized_data = [] + offset = 0 + for playlist, count in per_playlist_counts: + if remaining == 0: + break + if offset + count <= begin: + offset += count + continue + + local_skip = max(0, begin - offset) + fetch_limit = min(remaining, count - local_skip) + collection_channels = db_iptv['channels_' + playlist['id_playlist']] + cursor = collection_channels.find({ + '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) + remaining = MAX_RECORDS - len(serialized_data) + offset += count + + return web.json_response({'num_total_page': num_total_page, 'current_page': current_page, 'data': serialized_data}) + + + +def get_image_urls(keyword, max_results=10): + search_url = f"https://duckduckgo.com/?q={keyword}&iax=images&ia=images" + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" + } + + # Send the search request + response = requests.get(search_url, headers=headers) + soup = BeautifulSoup(response.text, 'html.parser') + + # Find image containers + image_urls = [] + for img in soup.find_all('img', {'class': 'tile--img__img'}): + src = img.get('src') + if src and src.startswith('http'): + image_urls.append(src) + if len(image_urls) >= max_results: + break + + return image_urls + +async def get_logo(request): + data = dict(request.query) + id_channel = data['id_channel'] + id_playlist = data['id_playlist'] + logo_channel_file_path = LOGO_DIR+'/'+id_channel+'.jpg' + collection_channels = db_iptv['channels_'+id_playlist] + d = collection_channels.find_one({ 'id_channel':id_channel}) + url_logo = d['urlLogo'] + name_channel = d['Name'] + cleaned_channel_name = clean_name_channel(name_channel) + + if not os.path.exists(logo_channel_file_path): + print(d['Name']+' :::: ' + id_channel + ' :::: ' + url_logo) + if len(url_logo) == 0: + print('Recherche url pour : ' + cleaned_channel_name, flush=True) + #words = cleaned_channel_name.split() + first_two_words = " ".join(cleaned_channel_name.split()[:3]) + regex_pattern = ".*" + ".*".join(first_two_words.split()) + ".*" + #regex_pattern = ".*" + ".*".join(words) + ".*" + l = logos.find_one({ 'logo_name': { '$regex': regex_pattern, '$options': "i" } }) + if l : + url_logo = l['logo_url'] + print('url trouvée : ' + url_logo, flush=True) + save_image_from_url(url_logo, logo_channel_file_path) + query = { 'id_channel':id_channel} + update = { "$set": { "urlLogo": url_logo } } + collection_channels.update_one(query, update) + else: + print('url identifiee et sauvegarde du fichier : ' + url_logo, flush=True) + if len(url_logo) > 0: + save_image_from_url(url_logo, logo_channel_file_path) + + image_data = None + if not os.path.exists(logo_channel_file_path): + with open(LOGO_DIR+'/none.jpg', "rb") as file: + image_data = file.read() + else: + with open(logo_channel_file_path, "rb") as file: + image_data = file.read() + print('image found '+logo_channel_file_path, flush=True) + + # Return the image as a response + 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): + result = logos.find({}).limit(1000) + search_string = "harry potter" + words = search_string.split() + regex_pattern = ".*" + ".*".join(words) + ".*" + logo_data = logos.find({ 'logo_name': { '$regex': regex_pattern, '$options': "i" } }) + html = '' + for r in logo_data: + html += '

' + r['logo_name'] + ' :::: ' + r['original_name'] + ' :::: ' + r['logo_url'] + return web.Response(text=html,content_type='text/html') + + +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))) + + +async def clean_recents(request): + recents.delete_many({}) + return web.json_response({'status':'ok'}) + + +async def get_recents(request): + # Step 1: Get the IDs of the 50 most recent documents + top_50 = list(recents.find({}, {"_id": 1}).sort("datetime", -1).limit(50)) + top_50_ids = [doc["_id"] for doc in top_50] + + # Step 2: Delete all documents that are NOT in the top 50 + result = recents.delete_many({ + "_id": {"$nin": top_50_ids} + }) + return web.json_response(dumps(recents.find({}).sort("datetime", -1))) + +async def most_recents(request): + pipeline = [ + { + "$sort": {"datetime": -1} + }, + { + "$limit": 50 + }, + { + "$group": { + "_id": "$id_channel", + "id_channel": {"$first": "$id_channel"}, + "id_playlist": {"$first": "$id_playlist"}, + "name": {"$first": "$name_channel"}, + "url": {"$first": "$url_channel"}, + "datetime": {"$first": "$datetime"}, + "count": {"$sum": 1} + } + }, + { + "$sort": {"count": -1, "datetime": -1, "name": 1} + } + ] + + # Run the aggregation + results = recents.aggregate(pipeline) + return web.json_response(dumps(results)) + +async def activation_playlist(request): + data = dict(request.query) + id_playlist = data['id_playlist'] + activation = int(data['activation']) + print('activation : ', activation, flush=True) + print(id_playlist,activation, flush=True ) + playlists.update_many({'id_playlist':id_playlist}, { "$set": { "activation": activation } } ) + return web.json_response({'id_playlist':id_playlist,'status':'ok'}) + + +async def delete_device(request): + data = dict(request.query) + id_device = data.get('id_device') # Use .get() to avoid KeyError + print(" ID device to delete : ",id_device,flush=True) + if not id_device: + return web.json_response({'error': 'id_device is required'}, status=400) + + result = devices_db.delete_many({"id_device": id_device}) + return web.json_response({'deleted_count': result.deleted_count}) + +async def delete_recent(request): + data = await request.post() + oid = data.get('oid') + + if not oid: + return web.json_response({'error': 'Missing oid'}, status=400) + + result = recents.delete_one({'_id': ObjectId(oid)}) + if result.deleted_count == 1: + return web.json_response({'status': 'success'}) + +async def get_epg_url(request): + doc = settings_collection.find_one({'key': 'epg_url'}) + epg_url = doc.get('value', '') if doc else '' + return web.json_response({'epg_url': epg_url}) + + +async def set_epg_url(request): + data = await request.post() + epg_url = data.get('epg_url', '').strip() + + if not epg_url: + return web.json_response({'error': 'epg_url is required'}, status=400) + + settings_collection.update_one( + {'key': 'epg_url'}, + {'$set': {'value': epg_url, 'updated_at': datetime.utcnow()}}, + upsert=True, + ) + return web.json_response({'status': 'ok', 'epg_url': epg_url}) + + +async def get_gemini_key(request): + doc = settings_collection.find_one({'key': 'gemini_key'}) + key = doc.get('value', '') if doc else '' + return web.json_response({'gemini_key': key}) + + +async def set_gemini_key(request): + data = await request.post() + key = data.get('gemini_key', '').strip() + if not key: + return web.json_response({'error': 'gemini_key is required'}, status=400) + settings_collection.update_one( + {'key': 'gemini_key'}, + {'$set': {'value': key, 'updated_at': datetime.utcnow()}}, + upsert=True, + ) + return web.json_response({'status': 'ok'}) + + +async def gemini_search(request): + data = await request.post() + query = data.get('query', '').strip() + if not query: + return web.json_response({'error': 'query is required'}, status=400) + + doc = settings_collection.find_one({'key': 'gemini_key'}) + api_key = doc.get('value', '') if doc else '' + if not api_key: + return web.json_response({'error': 'Gemini API key not configured. Go to Settings > Gemini API Key.'}, status=400) + + url = f'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={api_key}' + payload = { + 'contents': [{'parts': [{'text': query}]}], + 'tools': [{'google_search': {}}] + } + try: + resp = requests.post(url, json=payload, timeout=20) + resp.raise_for_status() + result = resp.json() + + answer = '' + sources = [] + candidates = result.get('candidates', []) + if candidates: + for part in candidates[0].get('content', {}).get('parts', []): + if 'text' in part: + answer += part['text'] + grounding = candidates[0].get('groundingMetadata', {}) + for chunk in grounding.get('groundingChunks', []): + web_info = chunk.get('web', {}) + if web_info.get('uri'): + sources.append({'uri': web_info['uri'], 'title': web_info.get('title', '')}) + + return web.json_response({'answer': answer, 'sources': sources}) + except requests.exceptions.HTTPError as e: + try: + err_msg = e.response.json().get('error', {}).get('message', str(e)) + except Exception: + err_msg = str(e) + return web.json_response({'error': err_msg}, status=502) + except Exception as e: + return web.json_response({'error': str(e)}, status=502) + + +async def channels_epg(request): + root = load_epg_root() + if root is None: + return web.json_response({'error': 'No EPG data available'}, status=503) + + channels_list = [] + for channel in root.findall('channel'): + channel_id = channel.attrib.get('id', '') + display_name = '' + name_node = channel.find('display-name') + if name_node is not None and name_node.text: + display_name = name_node.text + if display_name: + channels_list.append({'id': channel_id, 'name': display_name}) + + return web.json_response({'channels': channels_list}) + + +def parse_epg_datetime(raw_value: str) -> datetime: + ts = raw_value[:14] + return datetime.strptime(ts, "%Y%m%d%H%M%S") + + +async def epg_programs(request): + channel = request.query.get('channel') + if not channel: + return web.json_response({'error': 'channel is required'}, status=400) + + root = load_epg_root() + if root is None: + return web.json_response({'error': 'No EPG data available'}, status=503) + + programs = [] + for programme in root.findall('programme'): + if programme.attrib.get('channel') != channel: + continue + + start_raw = programme.attrib.get('start', '') + stop_raw = programme.attrib.get('stop', '') + title_node = programme.find('title') + desc_node = programme.find('desc') + + try: + start_dt = parse_epg_datetime(start_raw) + stop_dt = parse_epg_datetime(stop_raw) + except Exception: + continue + + programs.append({ + 'title': title_node.text if title_node is not None else 'No title', + 'desc': desc_node.text if desc_node is not None else '', + 'start': start_dt.isoformat(), + 'stop': stop_dt.isoformat() + }) + + programs.sort(key=lambda p: p['start']) + return web.json_response({'programs': programs}) + +async def download_epg(request): + data = dict(request.query) + url = data['url'] + filename = data['filename'] + filepath = os.path.join(DOWNLOAD_EPG_DIR, filename) + print('download file :', url, flush=True) + response = requests.get(url) + with open(filepath, 'wb') as f: + f.write(response.content) + return web.json_response({'status':'ok'}) + + +# Routage +BASE_DIR = pathlib.Path(__file__).parent +app.router.add_static('/static/', path=BASE_DIR / 'static', name='static') +app.router.add_static('/logos/', LOGO_DIR) +app.router.add_get('/', index) +app.router.add_get('/playlists', playlist_download) +app.router.add_get('/init_db', init_db) +app.router.add_get('/get_list_playlists', get_list_playlists) +app.router.add_post('/upload', handle_upload) +app.router.add_post('/upload_snapshot', upload_snapshot) +app.router.add_post('/analyse_m3u_file', analyse_m3u_file) +app.router.add_post('/import_xtream_codes', import_xtream_codes) +app.router.add_post('/update_playlist_comment', update_playlist_comment) +app.router.add_get('/delete_playlist', delete_playlist) +app.router.add_get('/cleanup_xtream_duplicates', cleanup_xtream_duplicates) +app.router.add_post('/list_channel', list_channel) +app.router.add_get('/get_logo', get_logo) +app.router.add_get('/list_logo', list_logo) +app.router.add_get('/list_devices', list_devices) +app.router.add_get('/recents', get_recents) +app.router.add_get('/clean_recents', clean_recents) +app.router.add_get('/activation_playlist', activation_playlist) +app.router.add_get('/delete_device', delete_device) +app.router.add_get('/count_channels', count_channels) +app.router.add_post('/delete_recent', delete_recent) +app.router.add_get('/get_source', get_source) +app.router.add_get('/most_recents', most_recents) +app.router.add_get('/epg_url', get_epg_url) +app.router.add_post('/epg_url', set_epg_url) +app.router.add_get('/gemini_key', get_gemini_key) +app.router.add_post('/gemini_key', set_gemini_key) +app.router.add_post('/gemini_search', gemini_search) +app.router.add_get('/channels_epg', channels_epg) +app.router.add_get('/epg', epg_programs) + +# SIO FUNCTIONS + +def messageReceived(): + print('messageReceived') + +@sio.event +def connect(sid, environ): + print("connect ", sid, flush=True) + + +@sio.event +async def communication(fromSid, data): + print("search request from ", fromSid, flush=True) + toSidDevice = data['toSidDevice'] + print("send signal to ", toSidDevice, flush=True) + await sio.emit('communication', data, room=toSidDevice ,callback=messageReceived) + + +@sio.event +def disconnect(sid): + print('disconnect ', sid, flush=True) + + +@sio.event +async def polling(sid, data): + global devices + device_name = data['name'] + IdDevice = data['IdDevice'] + mediaRunning = data['mediaRunning'] + device_last_datetime = time.time() + data_running = data['data_running'] + auto_snapshot_enabled = data.get('auto_snapshot_enabled', True) + snapshot_interval_seconds = data.get('snapshot_interval_seconds', 10) + print('polling from device :', IdDevice, flush=True) + print('data_running :', data_running, flush=True) + try: + vpn_status = data['vpn_status'] + except: + vpn_status = 'unknown' + device_data = devices_db.find_one({ 'id_device':IdDevice}) + if device_data: + query = { 'id_device':IdDevice} + update = { "$set": { "last_datetime": device_last_datetime ,"name": device_name, "sid":sid, "mediaRunning":mediaRunning, 'vpn_status':vpn_status, 'data_running':data_running, 'auto_snapshot_enabled': auto_snapshot_enabled, 'snapshot_interval_seconds': snapshot_interval_seconds} } + devices_db.update_one(query, update) + else: + 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} + devices_db.insert_one(new_device) + + if data_running: + new_record = { + "id_device": IdDevice, + "id_channel": data_running['id_channel'], + "id_playlist": data_running['id_playlist'], + "datetime": datetime.utcnow(), + "name_channel": data_running['name_channel'], + "url_channel":data_running['url_channel'], + "progress": data_running['progress'], + "percentage": data_running['percentage'] + } + latest_recent_record = recents.find_one( + {"id_device": IdDevice}, + sort=[("datetime", -1)] + ) + if latest_recent_record: + same_channel = ( + latest_recent_record.get("id_channel") == data_running['id_channel'] + and latest_recent_record.get("id_playlist") == data_running['id_playlist'] + ) + if same_channel: + recents.find_one_and_replace( + {"_id": latest_recent_record["_id"]}, + new_record, + return_document=True + ) + else: + recents.insert_one(new_record) + else: + 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 +if __name__ == '__main__': + logger.info('Starting server on port 443') + web.run_app(app, ssl_context=ssl_context, port=443) diff --git a/static/IPTV.py b/static/IPTV.py new file mode 100644 index 0000000..90ee051 --- /dev/null +++ b/static/IPTV.py @@ -0,0 +1,1344 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# pylint: disable=W0613, C0116 +# type: ignore[union-attr] + + +import logging +import vlc +import os +import re +import sys +import json +import queue +import psutil +import logging +import requests +import time +import threading +import tempfile +import socketio +import uuid +import subprocess +import tkinter as tk +from io import BytesIO +from wifi import Cell, Scheme +import sqlite3 as lite +from PIL import Image +from datetime import datetime +import urllib3 + +try: + from PIL import ImageTk + PIL_IMAGETK_AVAILABLE = True +except Exception: + ImageTk = None + PIL_IMAGETK_AVAILABLE = False + +sio = socketio.Client( + ssl_verify=False, + reconnection=True, + reconnection_attempts=0, + reconnection_delay=2, + reconnection_delay_max=15, +) +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + +class IdleScreenManager: + def __init__(self): + self.root = None + self.thread = None + self.command_queue = queue.Queue() + self.started = False + self.visible = False + self.enabled = True + self.clock_var = None + self.subtitle_var = None + self.footer_var = None + self.cards_frame = None + self.empty_var = None + self.empty_label = None + self.logo_refs = [] + + def start(self): + if self.started or not self.enabled or not idle_screen_supported(): + return + self.thread = threading.Thread(target=self._run, daemon=True) + self.thread.start() + self.started = True + + def show(self): + if not idle_screen_supported(): + return + self.start() + self.command_queue.put(("show", None)) + + def hide(self): + if not self.started: + return + self.command_queue.put(("hide", None)) + + def refresh(self): + if not self.started: + return + self.command_queue.put(("refresh", None)) + + def _run(self): + try: + self.root = tk.Tk() + self.root.configure(bg="#08111d") + self.root.title("IPTV Idle Screen") + self.root.attributes("-fullscreen", True) + self.root.attributes("-topmost", True) + self.root.bind("", lambda event: self.root.attributes("-fullscreen", False)) + + header = tk.Frame(self.root, bg="#08111d") + header.pack(fill="x", padx=60, pady=(45, 20)) + + title = tk.Label( + header, + text="Latest Channels", + fg="#f7fbff", + bg="#08111d", + font=("Helvetica", 34, "bold"), + anchor="w", + ) + title.pack(fill="x") + + self.subtitle_var = tk.StringVar(value="No channel running") + subtitle = tk.Label( + header, + textvariable=self.subtitle_var, + fg="#9ab4c8", + bg="#08111d", + font=("Helvetica", 18), + anchor="w", + ) + subtitle.pack(fill="x", pady=(8, 0)) + + self.clock_var = tk.StringVar(value="") + clock_label = tk.Label( + header, + textvariable=self.clock_var, + fg="#6ee7b7", + bg="#08111d", + font=("Helvetica", 18, "bold"), + anchor="e", + ) + clock_label.pack(fill="x", pady=(12, 0)) + + recent_frame = tk.Frame(self.root, bg="#0f1b2d", highlightbackground="#16324b", highlightthickness=1) + recent_frame.pack(fill="both", expand=True, padx=60, pady=(10, 20)) + + self.empty_var = tk.StringVar(value="Loading recent channels...") + self.empty_label = tk.Label( + recent_frame, + textvariable=self.empty_var, + fg="#f7fbff", + bg="#0f1b2d", + font=("Helvetica", 20), + justify="center", + anchor="center", + padx=28, + pady=28, + ) + self.empty_label.pack(fill="both", expand=True) + + self.cards_frame = tk.Frame(recent_frame, bg="#0f1b2d") + self.cards_frame.pack(fill="both", expand=True, padx=18, pady=18) + for column_index in range(IDLE_SCREEN_CARD_COLUMNS): + self.cards_frame.grid_columnconfigure(column_index, weight=1, uniform="idle_cards") + + self.footer_var = tk.StringVar(value="Waiting for a channel selection") + footer = tk.Label( + self.root, + textvariable=self.footer_var, + fg="#7b95aa", + bg="#08111d", + font=("Helvetica", 14), + anchor="w", + ) + footer.pack(fill="x", padx=60, pady=(0, 30)) + + self.root.withdraw() + self.root.after(250, self._process_commands) + self.root.after(1000, self._tick_clock) + self.root.after(IDLE_SCREEN_REFRESH_INTERVAL_MS, self._refresh_recent_data) + self.root.mainloop() + except Exception as exc: + print("Unable to start idle screen:", exc, flush=True) + self.enabled = False + + def _process_commands(self): + if self.root is None: + return + + while True: + try: + action, payload = self.command_queue.get_nowait() + except queue.Empty: + break + + if action == "show": + self.visible = True + self._render_recent_channels() + self.root.deiconify() + self.root.attributes("-fullscreen", True) + self.root.attributes("-topmost", True) + self.root.lift() + elif action == "hide": + self.visible = False + self.root.withdraw() + elif action == "refresh": + self._render_recent_channels() + + self.root.after(250, self._process_commands) + + def _tick_clock(self): + if self.root is None: + return + + self.clock_var.set(datetime.now().strftime("%Y-%m-%d %H:%M:%S")) + self.root.after(1000, self._tick_clock) + + def _refresh_recent_data(self): + if self.root is None: + return + + if self.visible: + sync_recent_channels_from_server() + self._render_recent_channels() + + self.root.after(IDLE_SCREEN_REFRESH_INTERVAL_MS, self._refresh_recent_data) + + def _render_recent_channels(self): + items = recent[:MAX_RECENT_CHANNELS] + if self.cards_frame is None or self.empty_label is None: + return + + self.logo_refs = [] + for child in self.cards_frame.winfo_children(): + child.destroy() + + if not items: + self.empty_var.set("No recent channel history available.") + self.empty_label.lift() + self.footer_var.set("The screen will update when the server sends history or a channel is played locally.") + return + + self.empty_label.lower() + for index, item in enumerate(items, start=1): + card = tk.Frame( + self.cards_frame, + bg="#13253a", + highlightbackground="#21425f", + highlightthickness=1, + padx=18, + pady=18, + ) + row_index = (index - 1) // IDLE_SCREEN_CARD_COLUMNS + column_index = (index - 1) % IDLE_SCREEN_CARD_COLUMNS + card.grid(row=row_index, column=column_index, sticky="nsew", padx=12, pady=12) + + logo_image, logo_status = fetch_channel_logo_photo(item) + logo_label = tk.Label(card, bg="#13253a") + logo_label.pack(pady=(0, 16)) + if logo_image is not None: + logo_label.configure(image=logo_image) + self.logo_refs.append(logo_image) + else: + logo_label.configure( + text="No Logo", + fg="#9ab4c8", + font=("Helvetica", 16, "bold"), + ) + + name = item.get("name_channel") or item.get("name") or "Unknown channel" + name_label = tk.Label( + card, + text=name, + fg="#f7fbff", + bg="#13253a", + font=("Helvetica", 18, "bold"), + wraplength=240, + justify="center", + ) + name_label.pack(fill="x") + + last_played_at = format_recent_timestamp(item.get("last_played_at")) + meta_label = tk.Label( + card, + text=f"Last played: {last_played_at}", + fg="#89a5ba", + bg="#13253a", + font=("Helvetica", 13), + justify="center", + ) + meta_label.pack(fill="x", pady=(10, 0)) + + self.footer_var.set(f"Showing {len(items)} recent channels cached locally and refreshed from the server") + + +def idle_screen_supported(): + if sys.platform.startswith("linux"): + return bool(os.environ.get("DISPLAY", "").strip()) + return True + + +def format_recent_timestamp(timestamp_value): + if not timestamp_value: + return "-" + + normalized_value = str(timestamp_value).replace("Z", "+00:00") + for parser in (datetime.fromisoformat,): + try: + return parser(normalized_value).strftime("%Y-%m-%d %H:%M") + except ValueError: + pass + + for pattern in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"): + try: + return datetime.strptime(str(timestamp_value), pattern).strftime("%Y-%m-%d %H:%M") + except ValueError: + pass + + return str(timestamp_value) + + +def build_logo_urls(item): + id_channel = item.get("id_channel", "").strip() + if not id_channel: + return [] + + return [url_update_server + f"logos/{id_channel}.jpg"] + + +def create_placeholder_logo(): + return Image.new("RGB", IDLE_SCREEN_LOGO_SIZE, color="#20364d") + + +def fetch_channel_logo_photo(item): + if not server_connection_ready: + return None, "Logo skipped: server connection not ready" + + if not PIL_IMAGETK_AVAILABLE: + return None, "Logo skipped: PIL ImageTk unavailable" + + cache_key = f"{item.get('id_channel', '')}:{item.get('id_playlist', '')}" + cached_logo = logo_cache.get(cache_key) + if cached_logo is not None: + return cached_logo, "Logo loaded from cache" + + image_obj = None + last_error = "Logo fetch failed" + for logo_url in build_logo_urls(item): + try: + response = requests.get(logo_url, timeout=LOGO_REQUEST_TIMEOUT_SECONDS, verify=False) + if not response.ok: + last_error = f"HTTP error {response.status_code}" + continue + + image_obj = Image.open(BytesIO(response.content)).convert("RGB") + last_error = f"HTTP {response.status_code}, {len(response.content)} bytes" + break + except Exception as exc: + last_error = f"Request/decode error: {exc}" + image_obj = None + + if image_obj is None: + return None, last_error + + image_obj.thumbnail(IDLE_SCREEN_LOGO_SIZE, IMAGE_RESAMPLING_LANCZOS) + background = Image.new("RGB", IDLE_SCREEN_LOGO_SIZE, color="#0c1827") + offset_x = max((IDLE_SCREEN_LOGO_SIZE[0] - image_obj.width) // 2, 0) + offset_y = max((IDLE_SCREEN_LOGO_SIZE[1] - image_obj.height) // 2, 0) + background.paste(image_obj, (offset_x, offset_y)) + + try: + photo_image = ImageTk.PhotoImage(background) + except Exception as exc: + print("Unable to create Tk logo image:", exc, flush=True) + return None, f"Tk image creation failed: {exc}" + + logo_cache[cache_key] = photo_image + return photo_image, last_error + + +def normalize_recent_channel(item): + return { + "id_channel": str(item.get("id_channel") or item.get("_id") or ""), + "id_playlist": str(item.get("id_playlist") or ""), + "name_channel": str(item.get("name_channel") or item.get("name") or "Unknown channel"), + "url_channel": str(item.get("url_channel") or item.get("url") or ""), + "last_played_at": str(item.get("last_played_at") or item.get("datetime") or datetime.now().isoformat(timespec="seconds")), + } + + +def load_recent_channels_from_db(): + global recent + + with db_lock: + cur_ = con.cursor() + cur_.execute( + """ + SELECT id_channel, id_playlist, name_channel, url_channel, last_played_at + FROM recent_channels_cache + ORDER BY position ASC + """ + ) + rows = cur_.fetchall() + + recent = [ + { + "id_channel": str(row[0] or ""), + "id_playlist": str(row[1] or ""), + "name_channel": str(row[2] or ""), + "url_channel": str(row[3] or ""), + "last_played_at": str(row[4] or ""), + } + for row in rows + ] + return recent + + +def save_recent_channels_to_db(items): + global recent + + normalized_items = [normalize_recent_channel(item) for item in items[:MAX_RECENT_CHANNELS]] + + with db_lock: + cur_ = con.cursor() + cur_.execute("DELETE FROM recent_channels_cache") + for position, item in enumerate(normalized_items): + cur_.execute( + """ + INSERT INTO recent_channels_cache ( + position, id_channel, id_playlist, name_channel, url_channel, last_played_at + ) VALUES (?, ?, ?, ?, ?, ?) + """, + ( + position, + item["id_channel"], + item["id_playlist"], + item["name_channel"], + item["url_channel"], + item["last_played_at"], + ), + ) + con.commit() + + recent = normalized_items + idle_screen_manager.refresh() + return normalized_items + + +def prepend_recent_channel(id_channel, url_channel, name_channel, id_playlist): + current_items = load_recent_channels_from_db() + new_item = normalize_recent_channel( + { + "id_channel": id_channel, + "id_playlist": id_playlist, + "name_channel": name_channel, + "url_channel": url_channel, + "last_played_at": datetime.now().isoformat(timespec="seconds"), + } + ) + + filtered_items = [ + item for item in current_items + if not ( + item.get("id_channel") == new_item["id_channel"] + and item.get("url_channel") == new_item["url_channel"] + ) + ] + save_recent_channels_to_db([new_item] + filtered_items) + + +def parse_server_json_payload(response): + payload = response.json() + if isinstance(payload, str): + payload = json.loads(payload) + return payload + + +def sync_recent_channels_from_server(): + if not server_connection_ready: + return recent + + try: + response = requests.get( + url_update_server + "recents", + headers={"Cache-Control": "no-cache"}, + timeout=10, + verify=False, + ) + response.raise_for_status() + payload = parse_server_json_payload(response) + if not isinstance(payload, list): + return recent + + normalized_items = [normalize_recent_channel(item) for item in payload[:MAX_RECENT_CHANNELS]] + if normalized_items: + return save_recent_channels_to_db(normalized_items) + except Exception as exc: + print("Unable to refresh recent channels from server:", exc, flush=True) + + return recent + + +def focus_vlc_player(): + global media_principal + + idle_screen_manager.hide() + try: + media_principal.set_fullscreen(True) + except Exception as exc: + print("Unable to focus VLC player:", exc, flush=True) + + +def wait_for_vlc_playback_start(): + global media_principal + + deadline = time.time() + VLC_PLAYBACK_START_TIMEOUT_SECONDS + while time.time() < deadline: + try: + if media_principal.is_playing(): + focus_vlc_player() + return True + except Exception as exc: + print("Unable to determine VLC playback state:", exc, flush=True) + break + time.sleep(VLC_PLAYBACK_POLL_INTERVAL_SECONDS) + + print("VLC did not report playback start before timeout", flush=True) + return False + + +def start_playback_transition(): + threading.Thread(target=wait_for_vlc_playback_start, daemon=True).start() + + +def show_idle_screen(): + if mediaRunning == '': + idle_screen_manager.show() + + +def hide_idle_screen(): + idle_screen_manager.hide() + + +def pulse_audio_available(): + pulse_server = os.environ.get("PULSE_SERVER", "").strip() + if pulse_server: + return True + + runtime_dir = os.environ.get("XDG_RUNTIME_DIR", "").strip() + pulse_candidates = [] + if runtime_dir: + pulse_candidates.append(os.path.join(runtime_dir, "pulse", "native")) + + pulse_candidates.append(f"/run/user/{os.getuid()}/pulse/native") + return any(os.path.exists(candidate) for candidate in pulse_candidates) + + +def ensure_runtime_environment(): + runtime_dir = os.environ.get("XDG_RUNTIME_DIR", "").strip() + if not runtime_dir: + fallback_runtime = f"/tmp/iptv-runtime-{os.getuid()}" + os.makedirs(fallback_runtime, exist_ok=True) + os.environ["XDG_RUNTIME_DIR"] = fallback_runtime + + +def default_vlc_vout(): + forced_vout = os.environ.get("IPTV_VLC_VOUT", "").strip() + if forced_vout: + return forced_vout + + platform = sys.platform.lower() + if platform.startswith("linux"): + if os.environ.get("DISPLAY", "").strip(): + return "xcb_x11" + return "fb" + + return "" + +def build_vlc_instance(): + vlc_options = [ + "--intf=dummy", + "--fullscreen", + "--no-osd", + "--no-video-title-show", + "--no-snapshot-preview", + + # network/live buffering + "--network-caching=3000", + "--live-caching=3000", + "--file-caching=1000", + + # late-frame handling + "--drop-late-frames", + "--skip-frames", + + # reduce timing pressure on live streams + "--clock-jitter=0", + "--clock-synchro=0", + + # troubleshooting + "--verbose=2", + ] + + forced_aout = os.environ.get("IPTV_VLC_AOUT", "").strip() + if forced_aout: + vlc_options.append(f"--aout={forced_aout}") + elif not pulse_audio_available(): + vlc_options.append("--aout=alsa") + + forced_vout = os.environ.get("IPTV_VLC_VOUT", "").strip() + if forced_vout: + vlc_options.append(f"--vout={forced_vout}") + + return vlc.Instance(*vlc_options) + + +def new_media_player(url): + player = vlc_instance.media_player_new() + media = vlc_instance.media_new(url) + + media.add_option(":network-caching=3000") + media.add_option(":live-caching=3000") + media.add_option(":drop-late-frames") + media.add_option(":skip-frames") + media.add_option(":clock-jitter=0") + media.add_option(":clock-synchro=0") + + forced_vout = os.environ.get("IPTV_VLC_VOUT", "").strip() + if forced_vout: + media.add_option(f":vout={forced_vout}") + + player.set_media(media) + return player + + +def get_snapshot_filepath(): + return os.path.join(tempfile.gettempdir(), f"iptv_snapshot_{IdDevice}.jpg") + + +def upload_snapshot_file(snapshot_path): + if not os.path.exists(snapshot_path): + return False + + with open(snapshot_path, "rb") as snapshot_file: + response = requests.post( + url_update_server + 'upload_snapshot', + data={'id_device': IdDevice}, + files={'file': (os.path.basename(snapshot_path), snapshot_file, 'image/jpeg')}, + timeout=10, + verify=False + ) + response.raise_for_status() + return True + + +def capture_and_upload_snapshot(): + global media_principal + global data_running + + if not data_running: + return False + + snapshot_path = get_snapshot_filepath() + snapshot_status = media_principal.video_take_snapshot(0, snapshot_path, 0, 0) + if snapshot_status != 0: + print('snapshot capture failed', flush=True) + return False + + upload_snapshot_file(snapshot_path) + return True + + +def snapshot_loop(): + while True: + try: + if auto_snapshot_enabled and data_running: + capture_and_upload_snapshot() + except Exception as exc: + print('snapshot loop error', exc, flush=True) + time.sleep(SNAPSHOT_INTERVAL_SECONDS) + +def RepresentsInt(s): + try: + int(s) + return True + except ValueError: + return False + +def convertMillis(millis): + seconds=(millis/1000)%60 + minutes=(millis/(1000*60))%60 + hours=(millis/(1000*60*60))%24 + print(str(seconds)+" "+str(minutes)+" "+ str(hours)) + return seconds, minutes, hours + +def is_vpn_connected(): + try: + output = subprocess.check_output("ip a | grep tun0", shell=True) + return "tun0" in output.decode() + except subprocess.CalledProcessError: + return False + +def connect_vpn(): + global vpn_process + + if is_vpn_connected(): + return "VPN already connected" + + subprocess.call("sudo pkill openvpn", shell=True) + subprocess.call("sudo ip link delete tun0", shell=True) + + time.sleep(1) + vpn_process = subprocess.Popen( + ["sudo", "openvpn", "--config", vpn_config, "--auth-user-pass", auth_file], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True + ) + + # Wait for connection confirmation + for _ in range(30): + if is_vpn_connected(): + return "VPN connected" + time.sleep(1) + + return "VPN connection failed" + +def disconnect_vpn(): + global vpn_process + subprocess.call("sudo pkill openvpn", shell=True) + subprocess.call("sudo ip link delete tun0", shell=True) + vpn_process = None + return "VPN disconnected" + +def findInfo(tag, s): + end = '"' + if (s.find(tag)>-1): + t = s[s.find(tag)+len(tag)+2:s.rfind(end)] + return t[0:t.find(end)] + else: + return "" + + +def restart_process(): + try: + print('restart new process') + p = psutil.Process(os.getpid()) + for handler in p.get_open_files() + p.connections(): + os.close(handler.fd) + except Exception as e: + logging.error(e) + + python = sys.executable + os.execl(python, python, *sys.argv) + + +def restart_main_iptv_process(): + python = sys.executable + main_script = os.path.join(path, 'IPTV.py') + os.execl(python, python, main_script) + + +def stop(): + global media_principal + global mediaRunning + global data_running + data_running = {} + mediaRunning = '' + media_principal.stop() + sync_recent_channels_from_server() + show_idle_screen() + + +def display_overlay_message(message, duration_ms=10000): + global media_principal + + try: + media_principal.video_set_marquee_int(vlc.VideoMarqueeOption.Enable, 1) + media_principal.video_set_marquee_string(vlc.VideoMarqueeOption.Text, message) + media_principal.video_set_marquee_int(vlc.VideoMarqueeOption.Size, 42) + media_principal.video_set_marquee_int(vlc.VideoMarqueeOption.Timeout, duration_ms) + media_principal.video_set_marquee_int(vlc.VideoMarqueeOption.Position, 8) + except Exception as exc: + print('Unable to display overlay message:', exc, flush=True) + + +def playMedia(id_channel, url_channel, name_channel, id_playlist): + global media_principal + global mediaRunning + global recent + global data_running + + try: + media_principal.stop() + media_principal.release() + except Exception as e: + print("Unable to reset VLC player:", e, flush=True) + + media_principal = new_media_player(url_channel) + try: + media_principal.set_fullscreen(True) + except Exception as e: + print("Unable to enable fullscreen:", e, flush=True) + media_principal.audio_set_volume(200) + media_principal.play() + start_playback_transition() + mediaRunning = name_channel + data_running = {'name_channel':name_channel, 'id_channel':id_channel, 'id_playlist':id_playlist, 'url_channel': url_channel, 'progress':0} + prepend_recent_channel(id_channel, url_channel, name_channel, id_playlist) + print(' DATA : ', data_running, flush=True) + + media_principal.video_set_marquee_int(vlc.VideoMarqueeOption.Enable, 1) + media_principal.video_set_marquee_string(vlc.VideoMarqueeOption.Text, name_channel) + media_principal.video_set_marquee_int(vlc.VideoMarqueeOption.Size, 48) # Font size + media_principal.video_set_marquee_int(vlc.VideoMarqueeOption.Timeout, 7000) # Duration in milliseconds + media_principal.video_set_marquee_int(vlc.VideoMarqueeOption.Position, 8) # Position on screen + + +def update_source(): + global path + r = requests.get(url_update_server+'get_source', headers={'Cache-Control': 'no-cache'}, verify=False) + r.raise_for_status() + temp_path = path + 'IPTV.py.tmp' + try: + with open(temp_path, 'wb') as updated_file: + updated_file.write(r.content) + os.replace(temp_path, path + 'IPTV.py') + except Exception as exc: + print('error update_source', exc, flush=True) + if os.path.exists(temp_path): + os.remove(temp_path) + raise + + +def wait_for_internet_connection(): + while True: + print("Checking connexion ...", flush=True) + for check_url, verify_ssl in CONNECTIVITY_CHECK_TARGETS: + try: + requests.get(check_url, timeout=CONNECTIVITY_CHECK_TIMEOUT_SECONDS, verify=verify_ssl) + print(f"Connectivity check succeeded with {check_url}", flush=True) + return + except requests.RequestException as exc: + print(f"Connectivity check failed for {check_url}: {exc}", flush=True) + + time.sleep(CONNECTIVITY_CHECK_RETRY_SECONDS) + + +def ensure_socket_connection(force_reset=False): + global last_connect_attempt_ts + global server_connection_ready + + with socket_connect_lock: + if sio.connected and not force_reset: + server_connection_ready = True + return True + + now = time.time() + if not force_reset and (now - last_connect_attempt_ts) < SOCKET_RETRY_MIN_INTERVAL: + return sio.connected + + last_connect_attempt_ts = now + + try: + if force_reset: + try: + sio.disconnect() + except Exception: + pass + + wait_for_internet_connection() + print('Attempting socket connection...', flush=True) + sio.connect( + url_update_server, + wait=True, + wait_timeout=SOCKET_CONNECT_TIMEOUT, + transports=['websocket', 'polling'] + ) + print('Socket connection ready', flush=True) + server_connection_ready = True + return True + except Exception as exc: + print('Socket reconnection failed', exc, flush=True) + server_connection_ready = False + return False + + +def polling_server(): + global media_principal + global data_running + global status_vpn + print('polling_server') + while True: + try: + if not sio.connected: + ensure_socket_connection() + time.sleep(POLLING_INTERVAL_SECONDS) + continue + + #r = requests.get(url_update_server+'ping?name=hirondelle&', allow_redirects=True) + data_running_tmp = data_running + milli = media_principal.get_time() + percentage = media_principal.get_position()*100 + + if data_running: + print(data_running_tmp) + data_running = {'name_channel':data_running_tmp['name_channel'], 'id_channel':data_running_tmp['id_channel'], 'id_playlist':data_running_tmp['id_playlist'], 'url_channel':data_running_tmp['url_channel'], 'progress':milli, 'percentage':percentage} + sio.emit('polling', { + 'name': name_device, + 'IdDevice': IdDevice, + 'mediaRunning': mediaRunning, + 'vpn_status': is_vpn_connected(), + 'data_running': data_running, + 'auto_snapshot_enabled': auto_snapshot_enabled, + 'snapshot_interval_seconds': SNAPSHOT_INTERVAL_SECONDS + }) + except Exception as exc: + print('Polling failed, will reconnect', exc, flush=True) + ensure_socket_connection(force_reset=True) + time.sleep(POLLING_INTERVAL_SECONDS) + + + + +def sendMessage(fromSidDevice, toSidDevice, typeCommand, payload, num_packet): + new_data = {'fromSidDevice': fromSidDevice, 'toSidDevice': toSidDevice, 'typeCommand': typeCommand, 'payload': payload, 'packet':num_packet} + print(new_data) + if sio.connected or ensure_socket_connection(): + sio.emit('communication', new_data) + else: + print('Socket disconnected, unable to send message', flush=True) + + +@sio.event +def connect(): + global server_connection_ready + server_connection_ready = True + print('connection established') + + +@sio.event +def connect_error(data): + print('connection error', data, flush=True) + + +@sio.event +def communication(data): + + toSidDevice = data['toSidDevice'] + fromSidDevice = data['fromSidDevice'] + typeCommand = data['typeCommand'] + + global recent + global media_principal + global mediaRunning + global data_running + + # -------------------------------- + # OFF + # -------------------------------- + if typeCommand == "off": + stop() + sendMessage(toSidDevice, fromSidDevice, 'warning', 'Off', 0) + sendMessage(toSidDevice, fromSidDevice, 'playing', 'no channel', 0) + + # -------------------------------- + # VPN MANAGEMENT + # -------------------------------- + if typeCommand == "vpn": + command_vpn = data['payload'] + if command_vpn == "connect": + sendMessage(toSidDevice, fromSidDevice, 'warning', 'Start VPN Connexion', 0) + result = connect_vpn() + elif command_vpn == "status": + result = "connected" if is_vpn_connected() else "disconnected" + elif command_vpn == "disconnect": + sendMessage(toSidDevice, fromSidDevice, 'warning', 'Start VPN Disconnexion', 0) + result = disconnect_vpn() + else: + result = "Unknown command" + sendMessage(toSidDevice, fromSidDevice, 'warning', result, 0) + + + + # -------------------------------- + # PLAY A CHANNEL ON + # -------------------------------- + if typeCommand == "on": + print ("switch on") + data_channel = data['payload'] + name_channel = data_channel['name'] + url_channel = data_channel['url'] + id_channel = data_channel['id_channel'] + id_playlist = data_channel['id_playlist'] + + print(id_channel, name_channel, url_channel, flush=True) + playMedia(id_channel, url_channel, name_channel, id_playlist) + sendMessage(toSidDevice, fromSidDevice, 'playing', name_channel, 0) + sendMessage(toSidDevice, fromSidDevice, 'warning', 'Starting media', 0) + + + # -------------------------------- + # GET IP ADDRESS + # -------------------------------- + if typeCommand == "getIP": + my_IP = requests.get("https://api.ipify.org").text + sendMessage(toSidDevice, fromSidDevice, 'warning', 'IP : ' + my_IP, 0) + + # -------------------------------- + # GET CHANNEL RUNNING + # -------------------------------- + if typeCommand == "getChannelRunning": + new_data ={} + if mediaRunning == '': + mr = 'no channel' + percentage = 0 + milli = 0 + id_channel = '' + else: + mr = mediaRunning + percentage = media_principal.get_position()*100 + milli = media_principal.get_time() + id_channel = data_running['id_channel'] + + new_data = {'name': mr, 'percentage':percentage, 'millisec':milli, 'id_channel': id_channel} + sendMessage(toSidDevice, fromSidDevice, 'playing', new_data, 0) + + # -------------------------------- + # SET SUBTITLE + # -------------------------------- + if typeCommand == "subtitle": + track = data['payload'] + media_principal.video_set_spu(track) + sendMessage(data['toSidDevice'], data['fromSidDevice'], 'warning', 'Track Num ' + str(track), 0) + + # -------------------------------- + # RESUME + # -------------------------------- + if typeCommand == "resume": + media_principal.set_pause(0) + sendMessage(toSidDevice, fromSidDevice, 'warning', 'Resume done', 0) + + # -------------------------------- + # PAUSE + # -------------------------------- + if typeCommand == "pause": + media_principal.set_pause(1) + sendMessage(toSidDevice, fromSidDevice, 'warning', 'Pause done', 0) + + # -------------------------------- + # GET SOFTWARE VERSION + # -------------------------------- + if typeCommand == "version": + version_message = 'Version ' + str(num_version) + sendMessage(toSidDevice, fromSidDevice, 'warning', version_message, 0) + display_overlay_message(version_message, 10000) + + # -------------------------------- + # AUTO SNAPSHOT TOGGLE + # -------------------------------- + if typeCommand == "toggleAutoSnapshot": + global auto_snapshot_enabled + auto_snapshot_enabled = bool(data['payload']) + state_label = 'enabled' if auto_snapshot_enabled else 'disabled' + sendMessage(toSidDevice, fromSidDevice, 'warning', 'Auto snapshot ' + state_label, 0) + + # -------------------------------- + # SNAPSHOT INTERVAL + # -------------------------------- + if typeCommand == "setSnapshotInterval": + global SNAPSHOT_INTERVAL_SECONDS + try: + interval_seconds = int(data['payload']) + except (TypeError, ValueError): + sendMessage(toSidDevice, fromSidDevice, 'warning', 'Invalid snapshot interval', 0) + else: + if interval_seconds < 2: + sendMessage(toSidDevice, fromSidDevice, 'warning', 'Snapshot interval must be at least 2 seconds', 0) + else: + SNAPSHOT_INTERVAL_SECONDS = interval_seconds + sendMessage( + toSidDevice, + fromSidDevice, + 'warning', + 'Snapshot interval set to ' + str(SNAPSHOT_INTERVAL_SECONDS) + ' seconds', + 0 + ) + + # -------------------------------- + # MOVETO + # -------------------------------- + if typeCommand == "moveto": + percentage = data['payload'] + new_position_value = float(percentage) / 100 + media_principal.set_position(new_position_value) + sendMessage(toSidDevice, fromSidDevice, 'warning', 'Moved to ' + str(percentage), 0) + + # -------------------------------- + # UPDATE SOFTWARE + # -------------------------------- + if typeCommand == "update_source": + sendMessage(toSidDevice, fromSidDevice, 'warning', 'Updating source and restarting client', 0) + time.sleep(1) + update_source() + restart_main_iptv_process() + + # -------------------------------- + # REBOOT + # -------------------------------- + if typeCommand == "reboot": + os.system('sudo reboot') + sendMessage(toSidDevice, fromSidDevice, 'warning', 'start rebooting ...', 0) + + + # -------------------------------- + # CHANGE DEVICE NAME AND REBUILD CONFIG FILE + # -------------------------------- + if typeCommand == "changeDeviceName": + global name_device + name_device = data['payload'] + print(name_device) + query = "UPDATE settings SET valueParameter = '" + name_device + "' WHERE nameParameter = 'deviceName'" + cur_ = con.cursor() + cur_.execute(query) + con.commit() + sendMessage(toSidDevice, fromSidDevice, 'warning', 'Configuration saved', 0) + + # -------------------------------- + # GET LIST OF WIFI NETWORKS + # -------------------------------- + if typeCommand == "listSSID": + sendMessage(toSidDevice, fromSidDevice, 'warning', 'Retreiving Wifi data', 0) + cells = Cell.all('wlan0') + resWifi = [] + for c in cells: + item = [] + item.append(c.ssid) + item.append(str(c.signal)) + item.append(str(c.quality)) + item.append(str(c.encrypted)) + resWifi.append(item) + sendMessage(toSidDevice, fromSidDevice, 'wifiupdate', resWifi, 0) + sendMessage(toSidDevice, fromSidDevice, 'warning', 'Wifi data received', 0) + + # -------------------------------- + # TAKE SNAPSHOT + # -------------------------------- + if typeCommand == "takeSnapshot": + try: + print('take snapshot') + time.sleep(1) + capture_and_upload_snapshot() + except Exception as e: + sendMessage(toSidDevice, fromSidDevice, 'warning', e, 0) + + # -------------------------------- + # GET NUMBER OF CHANNELS + # -------------------------------- + if typeCommand == "numberOfChannels": + numberOfChannels = len(channels) + sendMessage(toSidDevice, fromSidDevice, 'warning', 'Number of channels : ' + str(numberOfChannels), 0) + + + # -------------------------------- + # Command line + # -------------------------------- + if typeCommand == "command_line": + records = re.split(r' ', data['payload']) + #result = subprocess.run(records, capture_output=True, text=True) + process = subprocess.Popen(records, stdout=subprocess.PIPE, text=True) + for line in process.stdout: + print(line, end="") + sendMessage(toSidDevice, fromSidDevice, 'display_command_result', line, 0) + + + #print(result.stdout) + + +@sio.event +def disconnect(): + global server_connection_ready + server_connection_ready = False + print('Disconnected from server', flush=True) + + + +# -------------------------------- +# INITIALIZATION DATABASE +# -------------------------------- +def initDB(): + global con + try: + con = lite.connect(path+db_file, check_same_thread=False) + cur = con.cursor() + cur.execute('SELECT SQLITE_VERSION()') + data = cur.fetchone() + print ("Connexion succeed : SQLite version: %s" % data) + cur.execute( + """ + CREATE TABLE IF NOT EXISTS recent_channels_cache ( + position INTEGER PRIMARY KEY, + id_channel TEXT, + id_playlist TEXT, + name_channel TEXT NOT NULL, + url_channel TEXT NOT NULL, + last_played_at TEXT NOT NULL + ) + """ + ) + con.commit() + + except (lite.Error, e): + print ("Error %s:" % e.args[0]) + sys.exit(1) + + +def initIPTV(): + global token_telegram + global name_device + global channels + global playlists + global con + global IdDevice + global recent + + # GET ID UNIQUE DEVICE OR CREATE IT + query = "SELECT valueParameter FROM settings WHERE nameParameter = 'IdDevice'" + cur_ = con.cursor() + cur_.execute(query) + record = cur_.fetchone() + if record: + IdDevice = record[0] + print(IdDevice) + else: + print("No ID device found") + IdDevice = str(uuid.uuid4()) + query = "INSERT INTO settings (nameParameter, valueParameter) VALUES ('IdDevice', '"+IdDevice+"')" + cur_ = con.cursor() + cur_.execute(query) + con.commit() + + + # GET DEVICE NAME + query = "SELECT valueParameter FROM settings WHERE nameParameter = 'deviceName'" + cur_ = con.cursor() + cur_.execute(query) + valueParameter = cur_.fetchone() + if valueParameter is not None: + name_device = valueParameter + print('Update device name : ' + str(name_device)) + + + # SAVE PID + query = "DELETE FROM settings WHERE nameParameter = 'pid'" + cur_ = con.cursor() + cur_.execute(query) + con.commit() + pid = os.getpid() + query = "INSERT INTO settings (nameParameter, valueParameter) VALUES ('pid', '"+str(pid)+"')" + cur_ = con.cursor() + cur_.execute(query) + con.commit() + + + +# ---------------------------------------------------------------- +# ---------------------------------------------------------------- +num_version='5.0.4' +# ---------------------------------------------------------------- +# ---------------------------------------------------------------- + + +# ---------------------------------------------------------------- +# VARIABLES GLOBAL +# ---------------------------------------------------------------- +url_update_server = 'https://iptv.mrk.ovh/' +#url_update_server = 'https://tv.mrk.ovh:5011/' +db_file = 'config.db' +path = os.path.dirname(os.path.realpath(__file__))+'/' +con = None +pid = os.getppid() +print('PID : ', pid) + +channels = [] +playlists = [] +IdDevice = "0" +NUM_MAX_CHANNEL = 500000 + +recent = [] +mediaRunning = '' +status_vpn = 'disconnected' +data_running = {} +auto_snapshot_enabled = True +SNAPSHOT_INTERVAL_SECONDS = 10 +POLLING_INTERVAL_SECONDS = 10 +SOCKET_CONNECT_TIMEOUT = 10 +SOCKET_RETRY_MIN_INTERVAL = 3 +CONNECTIVITY_CHECK_TIMEOUT_SECONDS = 3 +CONNECTIVITY_CHECK_RETRY_SECONDS = 2 +CONNECTIVITY_CHECK_TARGETS = [ + (url_update_server, False), + ("https://www.google.com/generate_204", True), + ("http://clients3.google.com/generate_204", False), +] +last_connect_attempt_ts = 0 +server_connection_ready = False +socket_connect_lock = threading.Lock() +db_lock = threading.Lock() +MAX_RECENT_CHANNELS = 12 +IDLE_SCREEN_REFRESH_INTERVAL_MS = 60000 +idle_screen_manager = IdleScreenManager() +VLC_PLAYBACK_START_TIMEOUT_SECONDS = 8 +VLC_PLAYBACK_POLL_INTERVAL_SECONDS = 0.2 +IDLE_SCREEN_CARD_COLUMNS = 3 +IDLE_SCREEN_LOGO_SIZE = (220, 124) +LOGO_REQUEST_TIMEOUT_SECONDS = 5 +logo_cache = {} +IMAGE_RESAMPLING_LANCZOS = getattr(getattr(Image, "Resampling", Image), "LANCZOS") + + +ensure_runtime_environment() +vlc_instance = build_vlc_instance() +media_principal = new_media_player("http://mag.hi-ott.com:80/120643/NS3MV8YZTS/51648") + +token_telegram = "token" +name_device = '' +vpn_process = None +vpn_config = "/home/pi/Documents/ca1629.nordvpn.com.tcp.ovpn" +auth_file = "/home/pi/Documents/auth.txt" + + +# ---------------------------------------------------------------- +# ---------------------------------------------------------------- + +logging.basicConfig( + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', level=logging.INFO +) +logger = logging.getLogger(__name__) + +# ---------------------------------------------------------------- +# LOOP PRINCIPAL +# ---------------------------------------------------------------- +if __name__ == '__main__': + + initDB() + initIPTV() + + wait_for_internet_connection() + load_recent_channels_from_db() + show_idle_screen() + + threading.Thread(target=snapshot_loop, daemon=True).start() + ensure_socket_connection(force_reset=True) + sync_recent_channels_from_server() + idle_screen_manager.refresh() + + polling_server() diff --git a/static/code.js b/static/code.js new file mode 100644 index 0000000..f9d0e97 --- /dev/null +++ b/static/code.js @@ -0,0 +1,584 @@ + + var quote = String.fromCharCode(39) + var list_playlist = [] + var result_search = [] + + + + var socket = io('https://iptv.mrk.ovh', { + secure: true, + rejectUnauthorized: false // Only for self-signed certificates + }); + + socket.on('connect', function() { + $('#sidClient').val(socket.id) + console.log("client connected sid = " + $('#sidClient').val()) + }); + + + socket.on('communication', function(data) { + console.log('communication', data) + // RECEPTION RETURN WARNING + if (data['typeCommand'] == 'warning') { + $('#warning').html(data['payload']) + $('#warning').show('slow') + setTimeout(() => { $('#warning').hide('slow') }, 5000); + } + }) + + + $(window).on('scroll resize', lazyLoad); + + // INITIALISAITON DES TABS + $('#tab_home').show() + update_list_playlist() + //get_search_channel() + $('#tab_search').hide() + $('#tab_playlist').hide() + $('#tab_devices').hide() + + + // CLICK TAB HOME + $('#display_tab_home').click(function(){ + var triggerTab = new bootstrap.Tab($('#display_tab_home')); + triggerTab.show(); + $('#tab_home').show() + $('#tab_search').hide() + $('#tab_playlist').hide() + $('#tab_devices').hide() + get_search_channel() + }) + + + // CLICK TAB PLAYLIST + $('#display_tab_playlist').click(function(){ + var triggerTab = new bootstrap.Tab($('#display_tab_playlist')); + triggerTab.show(); + $('#tab_home').hide() + $('#tab_search').hide() + $('#tab_playlist').show() + $('#tab_devices').hide() + }) + + // CLICK TAB DEVICES + $('#display_tab_devices').click(function(){ + var triggerTab = new bootstrap.Tab($('#display_tab_devices')); + triggerTab.show(); + $('#tab_home').hide() + $('#tab_search').hide() + $('#tab_playlist').hide() + $('#tab_devices').show() + + }) + + // CLICK TAB SEARCH + $('#display_tab_search').click(function(){ + var triggerTab = new bootstrap.Tab($('#display_tab_devices')); + triggerTab.show(); + $('#tab_home').hide() + $('#tab_search').show() + $('#tab_playlist').hide() + $('#tab_devices').hide() + + }) + + // UPDATE TAB DEVICES + updateListDevices() + setInterval(updateListDevices, 10000) + + + $('#btn_search').click(function(){ + get_search_channel($('#input_search').val()) + }) + + $('#btn_initdb').click(function(){ + $.get('init_db').done(function(data){ + window.location.href = "/" + }) + }) + + + function isInViewport(element) { + const rect = element[0].getBoundingClientRect(); + return ( + rect.top >= 0 && + rect.left >= 0 && + rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && + rect.right <= (window.innerWidth || document.documentElement.clientWidth) + ); + } + + function lazyLoad() { + $('.lazy').each(function () { + const img = $(this); + if (isInViewport(img) && !img.hasClass('loaded')) { + img.attr('src', img.data('src')); // Load the image + img.on('load', function () { + img.addClass('loaded'); // Add loaded class for fade-in + }); + } + }); + } + + // DISPLAY CHANNELS + function displayResult() { + + $('#listChannels').show() + + if ($('#typeDisplay').val() == 'list') html = '

' + else html += '' + $('#listChannels').html(html) + + $('.btn_play_media').click(function(){ + var name = $(this).data('name_channel') + var url = $(this).data('url_channel') + console.log('play media', name, url) + socket.emit('communication', {fromSidDevice : $('#sidClient').val(), toSidDevice: $('#sidDevice').val(), typeCommand : "on", payload: {name:name, url:url}, "packet":0}); + }) + $('.btn_display_url').click(function() { + var url = $(this).data('display_url') + alert(url) + }) + + } + + // GET RESULT SEARCH + function get_search_channel() { + var keyword = $('#input_search').val() + if (keyword.length>0) { + $.post('list_channel', {keyword:encodeURIComponent(keyword)}).done(function(data){ + $('#list_channels').html('') + var html ='' + console.log(data) + result_search = JSON.parse(data) + displayResult() + lazyLoad(); + }) + } + } + + // UPDATE LIST PLAYLIST + function update_list_playlist() { + + $.get('get_list_playlists').done(function(data){ + console.log(data) + $('#list_playlist').html('') + var html = ' \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + ' + + + var dataJSON = JSON.parse(data) + //list_playlist = dataJSON + + for (var i=0;i\ + \ + \ + \ + \ + \ + \ + \ + \ + \ + ` + + } + html += '
#TitleEnd DateNum channels
${i.toString()}${dataJSON[i].title_playlist}${dataJSON[i].date_end_playlist}${dataJSON[i].num_channels}
Delete
' + $('#list_playlist').html(html) + + + for (var i=0;i' + var media = ''+dataJSON[i].mediaRunning+'' + if (delta < 60) { + status = 'Online' + status2 = '#ebfaeb' + } + if (delta > 86400) { + status = 'Offline' + status2 = '#e6e6ff' + } + if ((delta > 60) && (delta <= 86400)) { + status = '' + Math.floor(delta / 60).toString() + ' min' + status2 = '#ffe6e6' + } + + html += `\ + ${i.toString()}\ + ${dataJSON[i].name}\ + ${status}\ + ${media}\ + \ + ` + + html2 += '
  • '+dataJSON[i].name+'
  • ' + + html3 += '
    \ +
    \ +

    \ +
    '+dataJSON[i].name+'
    \ +

    \ +

    '+status+'

    \ +

    '+media+'

    \ +
    \ +
    ' + } + html += ' ' + $('#listDevices').html(html) + //$('#list_device_home').html(html2) + //$('#zone_device_main').html(html3) + + function configure_current_device(element) { + const id_device = $(element).data('param1'); + const name_device = $(element).data('param2'); + const sid = $(element).data('param3'); + console.log(id_device, name_device) + $('#id_device').val(id_device) + $('#sidDevice').val(sid) + $('#btn_select_device').html(name_device) + } + + for (var i=0;iPlease select a file') + return; + } + + const formData = new FormData(); + formData.append("file", file); + + const xhr = new XMLHttpRequest(); + xhr.open("POST", "/upload", true); + + // Update progress bar + xhr.upload.onprogress = function (event) { + if (event.lengthComputable) { + const percentComplete = Math.round((event.loaded / event.total) * 100); + $("#progress-bar").val(percentComplete); + $("#progress-text").text(percentComplete + "%"); + } + }; + + xhr.onloadstart = function () { + $("#progress-container").show(); + }; + + xhr.onload = function () { + if (xhr.status === 200) { + $('#add_playlist_alert').html('') + var response =JSON.parse(xhr.response) + id_playlist = response['id_playlist'] + if (response.status == 'ok') { + + $.post('analyse_m3u_file', {'id_playlist':id_playlist, + 'title':$('#playlist_title').val(), + 'url':$('#playlist_url').val(), + 'date_end':$('#playlist_end_date').val(), + 'filename':$("#fileName").val()}).done(function(data){ + + var num_channel = data.num_channel + if (num_channel>0) { + $('#add_playlist_alert').html('') + } else { + $('#add_playlist_alert').html('') + } + update_list_playlist() + $('#modal_add_playlist').modal('hide') + + }) + } + + } else { + $('#add_playlist_alert').html('') + } + $("#progress-container").hide(); + }; + + xhr.onerror = function () { + alert("An error occurred while uploading."); + $("#progress-container").hide(); + }; + + xhr.send(formData); + } + + }); + + + $("#typeDisplay").change(function(){ + console.log($("#typeDisplay").val()) + displayResult() + }) + + $('#btn_display_player').click(function () { + if($('#player').css('display') == 'none'){ + $('#player').show('slow'); + $('#valRange').html='0' + $('#Range').val(0) + } else { + $('#player').hide('slow'); + } + }) + + $(".dropdown-submenu .dropdown-toggle").on("click", function (e) { + e.preventDefault(); // Prevent link from navigating + e.stopPropagation(); // Prevent closing the whole dropdown + + let $submenu = $(this).next(".dropdown-menu"); + + // Toggle the submenu visibility + $(".dropdown-submenu .dropdown-menu").not($submenu).removeClass("show"); // Close other submenus + $submenu.toggleClass("show"); + }); + + // Close submenus when clicking outside + $(document).on("click", function (e) { + if (!$(e.target).closest(".dropdown-submenu").length) { + $(".dropdown-submenu .dropdown-menu").removeClass("show"); + } + }); + + function off(){ + console.log('OFF ',$('#sidDevice').val(), $('#sidClient').val()) + socket.emit('communication', {fromSidDevice : $('#sidClient').val(), toSidDevice: $('#sidDevice').val(), typeCommand : "off", payload: 0, "packet":0}); + } + + + \ No newline at end of file diff --git a/static/cog-wheel-silhouette.png b/static/cog-wheel-silhouette.png new file mode 100644 index 0000000..5cbe821 Binary files /dev/null and b/static/cog-wheel-silhouette.png differ diff --git a/static/cog-wheel.png b/static/cog-wheel.png new file mode 100644 index 0000000..d2367b2 Binary files /dev/null and b/static/cog-wheel.png differ diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..803ab94 --- /dev/null +++ b/static/index.html @@ -0,0 +1,3255 @@ + + + + Supervision 2 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    + +
    + + +
    + +
    +
    + + +
    +
    +

    Selected device

    +
    +
    +
    +
    +
    No device
    +
    +
    No channel
    + +
    +
    + +
    + + + + +
    + + + +
    + + +
    + + +
    + +
    +
    + +
    Preview
    +
    +
    +
    + + +
    +
    + +
    +

    Channel selector

    + +
    + +
    + +
    + +
    + + + + + + + + + + + +
    + +
    +
    +
    +
    +
    +
    +
    + + +
    +
    + +

    EPG Viewer

    +
    + +
    + + +
    + + Tip: leave empty to show the first 5 channels. +
    +
    + +
    +
    +
    + +
    + + +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/setup.sh b/static/setup.sh new file mode 100644 index 0000000..8eba104 --- /dev/null +++ b/static/setup.sh @@ -0,0 +1,73 @@ +#!/bin/bash + +set -e # Stop script on error + +IPTV_SOURCE_URL="${IPTV_SOURCE_URL:-https://iptv.mrk.ovh/get_source}" +IPTV_DEST="/home/pi/Documents/IPTV.py" +CONFIG_DB_SOURCE_URL="${CONFIG_DB_SOURCE_URL:-https://iptv.mrk.ovh/static/config_init.db}" +CONFIG_DB_DEST="/home/pi/Documents/config.db" +RC_LOCAL="/etc/rc.local" +RC_LOCAL_CMD='sudo -u pi DISPLAY=:0 python3 /home/pi/Documents/IPTV.py &' +CURL_CMD=(curl -fsSL) + +if [ "${CURL_INSECURE:-0}" = "1" ]; then + CURL_CMD+=( -k ) +fi + +echo "Updating system" +sudo apt update + +echo "Installing packages" +sudo apt -y install vim openvpn vsftpd curl ca-certificates python3-pil.imagetk python3-tk +sudo update-ca-certificates + +echo "Configuring vsftpd" +VSFTPD_CONF="/etc/vsftpd.conf" + +sudo sed -i 's/^#write_enable=YES/write_enable=YES/' $VSFTPD_CONF + +echo "Restarting vsftpd service" +sudo systemctl restart vsftpd + +PYTHON_STDLIB_DIR="$(python3 -c 'import sysconfig; print(sysconfig.get_path("stdlib"))')" + +echo "Fixing Python externally-managed restriction" +sudo rm -f "$PYTHON_STDLIB_DIR/EXTERNALLY-MANAGED" + +echo "Installing Python packages" +sudo pip3 install python-vlc python-socketio wifi aiohttp websocket-client + +echo "Downloading IPTV.py" +sudo mkdir -p /home/pi/Documents +sudo "${CURL_CMD[@]}" "$IPTV_SOURCE_URL" -o "$IPTV_DEST" +sudo chown pi:pi "$IPTV_DEST" +sudo chmod 755 "$IPTV_DEST" + +echo "Downloading config database" +sudo "${CURL_CMD[@]}" "$CONFIG_DB_SOURCE_URL" -o "$CONFIG_DB_DEST" +sudo chown pi:pi "$CONFIG_DB_DEST" +sudo chmod 644 "$CONFIG_DB_DEST" + +echo "Configuring rc.local" +if [ ! -f "$RC_LOCAL" ]; then + sudo tee "$RC_LOCAL" >/dev/null <<'EOF' +#!/bin/sh -e + +exit 0 +EOF +fi + +if ! sudo grep -Fq "$RC_LOCAL_CMD" "$RC_LOCAL"; then + sudo sed -i "\$i\\ +$RC_LOCAL_CMD +" "$RC_LOCAL" +fi + +if ! sudo tail -n 1 "$RC_LOCAL" | grep -Fxq "exit 0"; then + echo "rc.local must end with 'exit 0'" >&2 + exit 1 +fi + +sudo chmod 755 "$RC_LOCAL" + +echo "All tasks completed successfully!" diff --git a/static/tv.png b/static/tv.png new file mode 100644 index 0000000..95fa24f Binary files /dev/null and b/static/tv.png differ