import asyncio import os import schedule import time import logging import aiohttp # --- KONFIGURATION --- INPUT_FOLDER = "./m3u_playlists" OUTPUT_FILE = "comb_and_cleaned.m3u" LOG_FILE = "m3u_manager.log" TIMEOUT_SECONDS = 4 MAX_CONCURRENT_TASKS = 300 CHECK_INTERVAL_MINUTES = 30 # --- LOGGING EINRICHTEN --- logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", handlers=[ logging.FileHandler(LOG_FILE, encoding="utf-8"), logging.StreamHandler() ] ) processed_count = 0 total_count = 0 valid_count = 0 def parse_m3u(file_path): """Liest eine M3U-Datei und merkt sich den Ursprungspfad jedes Streams.""" streams = [] try: with open(file_path, "r", encoding="utf-8", errors="ignore") as f: lines = f.readlines() except Exception as e: logging.error(f"Fehler beim Lesen von {file_path}: {e}") return streams current_info = None for line in lines: line = line.strip() if not line: continue if line.startswith("#EXTINF:"): current_info = line elif line.startswith("#"): continue else: if current_info: streams.append({"info": current_info, "url": line, "source_file": file_path}) current_info = None else: streams.append({ "info": f'#EXTINF:-1,Channel {len(streams)+1}', "url": line, "source_file": file_path }) return streams #def is_live_stream(url): # """Erkennt anhand der URL, ob es sich um einen Live-Stream handelt.""" # url_lower = url.lower() # # Typische IPTV-Muster für Live-Kanäle im Gegensatz zu Movies/Series # if "/live/" in url_lower: # return True # if url_lower.endswith(".m3u8") or ".m3u8?" in url_lower: # return True # if url_lower.endswith(".ts") or ".ts?" in url_lower: # # Falls .ts auch für VOD genutzt wird, hier ggf. einschränken # if "/movie/" in url_lower or "/series/" in url_lower: # return False # return True # return False def is_live_stream(url): """Erkennt präzise Live-Streams, auch wenn sie keine Endung haben.""" url_lower = url.lower() # 1. Eindeutige Live-Indikatoren (Haben immer Vorrang) if "/live/" in url_lower or "device=live" in url_lower: return True if url_lower.endswith(".m3u8") or ".m3u8?" in url_lower: return True if url_lower.endswith(".ts") or ".ts?" in url_lower: # .ts wird selten für Filme genutzt, es sei denn es steht im Pfad if "/movie/" in url_lower or "/series/" in url_lower: return False return True # 2. Eindeutige VOD-Indikatoren (Filme/Serien) vod_extensions = (".mp4", ".mkv", ".avi", ".mov", "/movie/", "/movies/", "/series/", "/get.php") if any(vod_indicator in url_lower for vod_indicator in vod_extensions): return False # 3. Fallback für Streams ohne Endung: # Wenn es kein bekannter Film/Serie ist, wird es als Live-Stream behandelt. return True async def check_and_write_stream(session, semaphore, stream, f_live, f_vod, active_sources, lock): """Prüft einen Stream und schreibt ihn getrennt nach Live/VOD sofort weg.""" global processed_count, valid_count async with semaphore: is_valid = False try: async with session.head(stream["url"], timeout=TIMEOUT_SECONDS, allow_redirects=True) as response: if response.status == 200: is_valid = True if not is_valid: async with session.get(stream["url"], timeout=TIMEOUT_SECONDS, allow_redirects=True) as get_resp: if get_resp.status == 200: is_valid = True except Exception: pass processed_count += 1 if is_valid: valid_count += 1 async with lock: active_sources.add(stream["source_file"]) # Entscheide anhand der URL, in welche temporäre Datei geschrieben wird if is_live_stream(stream["url"]): f_handle = f_live else: f_handle = f_vod f_handle.write(f"{stream['info']}\n") f_handle.write(f"{stream['url']}\n") f_handle.flush() async def progress_reporter(): """Gibt jede Minute den aktuellen Fortschritt im Log aus.""" while True: await asyncio.sleep(60) if total_count > 0 and processed_count < total_count: prozent = (processed_count / total_count) * 100 logging.info(f"[Fortschritt] {processed_count}/{total_count} Streams geprüft ({prozent:.1f}%). Aktiv: {valid_count}") async def validate_and_stream_write_async(all_streams): """Steuert die parallele Überprüfung mit zwei temporären Dateien.""" global processed_count, total_count, valid_count processed_count = 0 valid_count = 0 total_count = len(all_streams) semaphore = asyncio.Semaphore(MAX_CONCURRENT_TASKS) lock = asyncio.Lock() active_sources = set() headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"} # Temporäre Dateien für die Echtzeit-Trennung öffnen temp_live = "temp_live.m3u" temp_vod = "temp_vod.m3u" with open(temp_live, "w", encoding="utf-8") as f_live, open(temp_vod, "w", encoding="utf-8") as f_vod: reporter_task = asyncio.create_task(progress_reporter()) async with aiohttp.ClientSession(headers=headers) as session: tasks = [ check_and_write_stream(session, semaphore, stream, f_live, f_vod, active_sources, lock) for stream in all_streams ] await asyncio.gather(*tasks) reporter_task.cancel() # Zusammenfügen: Live-Streams oben, VODs unten if valid_count > 0: with open(OUTPUT_FILE, "w", encoding="utf-8") as f_out: f_out.write("#EXTM3U\n") # 1. Live-Streams schreiben if os.path.exists(temp_live): with open(temp_live, "r", encoding="utf-8") as f_l: f_out.write(f_l.read()) # 2. VOD/Serien-Streams hintendran hängen if os.path.exists(temp_vod): with open(temp_vod, "r", encoding="utf-8") as f_v: f_out.write(f_v.read()) # Aufräumen der temporären Dateien for temp_file in [temp_live, temp_vod]: if os.path.exists(temp_file): os.remove(temp_file) return active_sources def job_merge_and_clean(): """Hauptprozess.""" logging.info("M3U Smart-Update (mit Live-Stream-Sortierung) gestartet.") if not os.path.exists(INPUT_FOLDER): os.makedirs(INPUT_FOLDER) logging.info(f"Ordner '{INPUT_FOLDER}' wurde erstellt.") return found_files = [] for root, dirs, files in os.walk(INPUT_FOLDER): for file in files: if file.lower().endswith(('.m3u', '.m3u8')): found_files.append(os.path.join(root, file)) if not found_files: logging.warning(f"Keine M3U-Dateien in '{INPUT_FOLDER}' gefunden.") return all_streams = [] for file_path in found_files: all_streams.extend(parse_m3u(file_path)) unique_urls = set() deduplicated_streams = [] for s in all_streams: if s["url"] not in unique_urls: unique_urls.add(s["url"]) deduplicated_streams.append(s) if not deduplicated_streams: logging.warning("Keine Streams zur Überprüfung vorhanden.") return logging.info(f"Starte Echtzeit-Prüfung von {len(deduplicated_streams)} Streams...") start_time = time.time() active_sources = asyncio.run(validate_and_stream_write_async(deduplicated_streams)) end_time = time.time() logging.info(f"Prüfung beendet in {end_time - start_time:.2f} Sekunden.") # Tote Originaldateien löschen for file_path in found_files: if file_path not in active_sources: try: os.remove(file_path) rel_path = os.path.relpath(file_path, INPUT_FOLDER) logging.info(f"CRITICAL: Datei gelöscht (0 aktive Streams): {rel_path}") except Exception as e: logging.error(f"Fehler beim Löschen von {file_path}: {e}") if valid_count > 0: logging.info(f"SUCCESS: '{OUTPUT_FILE}' wurde rotiert und gespeichert ({valid_count} Streams live).") else: if os.path.exists(OUTPUT_FILE): os.remove(OUTPUT_FILE) logging.warning(f"ALERT: Kein einziger Stream war erreichbar. '{OUTPUT_FILE}' wurde entfernt.") logging.info("M3U Smart-Update beendet.\n" + "-"*50) if __name__ == "__main__": job_merge_and_clean() schedule.every(CHECK_INTERVAL_MINUTES).minutes.do(job_merge_and_clean) logging.info(f"Scheduler aktiv. Nächster Scan in {CHECK_INTERVAL_MINUTES} Minuten.") while True: schedule.run_pending() time.sleep(1)