From dfe4da52421a5e4ef70964d4fcf2dad16bab59c2 Mon Sep 17 00:00:00 2001 From: pQu4k3r Date: Thu, 8 Oct 2026 15:18:59 +0000 Subject: [PATCH 1/8] Fix player zapping, stale YouTube answers and DreamOS timers Same fixes as TVGarden's player: - Do not close the player on evStopped (fired while zapping) - Single cleanup on close, no double close - Ignore stream answers that arrive after the player closed or after another webcam was selected (YouTube and non-YouTube alike) - Resolve the webcam page and YouTube off the GUI thread - Keep eTimer connections alive (timers never fired on DreamOS) - Service type 4097 by default: ServiceApp maps it to the player chosen in its settings; 5001/5002 are no longer forced - Status and key help shown in an overlay dialog (the MoviePlayer skin has no widget for them, so they were never visible) - CH+/CH- and PREVIOUS keys work with the WorldCam keymap actions YouTube links: - Channel live embeds (embed/live_stream?channel=ID) resolved as the channel's /live page instead of a video called "live_stream" - /channel/, /c/, /user/ and /@name links resolved as /live, playlist links as their first video --- .../Plugins/Extensions/WorldCam/player.py | 292 +++++++++--------- .../Plugins/Extensions/WorldCam/utils.py | 24 +- .../Extensions/WorldCam/youtube_helper.py | 74 ++++- 3 files changed, 225 insertions(+), 165 deletions(-) diff --git a/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/player.py b/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/player.py index 4eb6d6cf..29ce20b2 100644 --- a/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/player.py +++ b/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/player.py @@ -2,12 +2,8 @@ # -*- coding: utf-8 -*- import sys -import subprocess from os import remove from os.path import abspath, dirname, exists -from re import IGNORECASE, search - -from urllib.parse import unquote from Components.ActionMap import ActionMap from Components.Label import Label @@ -34,6 +30,7 @@ is_youtube_url, convert_youtube_embed_to_watch, get_service_type, + timer_connect, ) from .youtube_helper import resolve_youtube @@ -67,6 +64,34 @@ sys.path.append(plugin_dir) +HELP_TEXT = _( + "OK = Info | CH-/CH+ = Prev/Next | BLUE = Fav | " + "PLAY/PAUSE = Toggle | STOP = Stop | EXIT = Exit | by Lululla") + + +class WorldCamOverlay(Screen): + """Text bar on top of the video (status and key help)""" + + def __init__(self, session): + if screen_width >= 1920: + width, height, font = screen_width, 70, 34 + else: + width, height, font = screen_width, 50, 26 + self.skin = ( + '' + '' % ( + width, height, width, height, font)) + Screen.__init__(self, session) + self["text"] = Label("") + + def setText(self, text): + self["text"].setText(text) + + class TvInfoBarShowHide(): """InfoBar show/hide control""" STATE_HIDDEN = 0 @@ -90,56 +115,36 @@ def __init__(self): self.__state = self.STATE_SHOWN self.__locked = 0 - self.helpOverlay = Label("") - self.helpOverlay.skinAttributes = [ - ("position", "0,0"), - ("size", "1280,50"), - ("font", "Regular;28"), - ("halign", "center"), - ("valign", "center"), - ("foregroundColor", "#FFFFFF"), - ("backgroundColor", "#666666"), - ("transparent", "0"), - ("zPosition", "100") - ] - - self["helpOverlay"] = self.helpOverlay - self["helpOverlay"].hide() + self.overlay = self.session.instantiateDialog(WorldCamOverlay) + self.overlay_timer = eTimer() + self.overlay_timer_conn = timer_connect( + self.overlay_timer, self.hide_overlay) self.hideTimer = eTimer() - try: - self.hideTimer_conn = self.hideTimer.timeout.connect( - self.doTimerHide) - except BaseException: - self.hideTimer.callback.append(self.doTimerHide) + self.hideTimer_conn = timer_connect(self.hideTimer, self.doTimerHide) self.hideTimer.start(5000, True) self.onShow.append(self.__onShow) self.onHide.append(self.__onHide) - def show_help_overlay(self): - help_text = ( - "OK = Info | CH-/CH+ = Prev/Next | BLUE = Fav | PLAY/PAUSE = Toggle | STOP = Stop | EXIT = Exit | by Lululla" - ) - self["helpOverlay"].setText(help_text) - self["helpOverlay"].show() - - if not hasattr(self, 'help_timer'): - self.help_timer = eTimer() - self.help_timer.callback.append(self.hide_help_overlay) + def show_overlay_text(self, text, timeout=0): + """Show text on top of the video (timeout in ms, 0 = keep)""" + self.overlay_timer.stop() + if self.overlay is None: + return + self.overlay.setText(text) + self.overlay.show() + if timeout: + self.overlay_timer.start(timeout, True) - self.help_timer.start(5000, True) + def hide_overlay(self): + self.overlay_timer.stop() + if self.overlay is not None: + self.overlay.hide() - def hide_help_overlay(self): - if self["helpOverlay"].visible: - self["helpOverlay"].hide() + def show_help_overlay(self): + self.show_overlay_text(HELP_TEXT, 5000) def OkPressed(self): - if self.__state == self.STATE_SHOWN: - if self["helpOverlay"].visible: - self.help_timer.stop() - self.hide_help_overlay() - else: - self.show_help_overlay() self.toggleShow() def __onShow(self): @@ -157,9 +162,6 @@ def doShow(self): def doHide(self): self.hideTimer.stop() self.hide() - if self["helpOverlay"].visible: - self.help_timer.stop() - self.hide_help_overlay() self.startHideTimer() def serviceStarted(self): @@ -175,9 +177,6 @@ def doTimerHide(self): self.hideTimer.stop() if self.__state == self.STATE_SHOWN: self.hide() - if self["helpOverlay"].visible: - self.help_timer.stop() - self.hide_help_overlay() def toggleShow(self): if not self.skipToggleShow: @@ -186,9 +185,7 @@ def toggleShow(self): self.show_help_overlay() else: self.doHide() - if self["helpOverlay"].visible: - self.help_timer.stop() - self.hide_help_overlay() + self.hide_overlay() else: self.skipToggleShow = False @@ -228,11 +225,18 @@ def __init__(self, session, webcams, current_index=0): disable_summary(self) self.session = session self.skinName = "MoviePlayer" - - # xml_path = join(self.get_skin_path(), "WorldCamPlayer.xml") - # self.skinName = xml_path self.logger = Logger() + self.webcams = webcams or [] + if not (0 <= current_index < len(self.webcams)): + current_index = 0 + self.current_index = current_index + self.state = self.STATE_PLAYING + # Bumped on every play: results of older requests are ignored + self.play_request = 0 + self.closing = False + self._cleaned_up = False + for base_class in ( InfoBarBase, InfoBarMenu, @@ -244,10 +248,6 @@ def __init__(self, session, webcams, current_index=0): ): base_class.__init__(self) - self.webcams = webcams - self.current_index = current_index - self.state = self.STATE_PLAYING - self.youtube_play_request = 0 self.aspect_manager = AspectManager() self.aspect_manager.set_aspect("16:9") self.scraper = SkylineScraper() @@ -273,9 +273,13 @@ def __init__(self, session, webcams, current_index=0): "prevBouquet": self.previous_webcam, "nextBouquet": self.next_webcam, + "channelDown": self.previous_webcam, + "channelUp": self.next_webcam, "prev": self.previous_webcam, + "previous": self.previous_webcam, "next": self.next_webcam, "leavePlayer": self.leavePlayer, + "leavePlayerOnExit": self.leavePlayer, "stop": self.leavePlayer, "blue": self.toggle_favorite, "playpauseService": self.playpauseService, @@ -314,11 +318,7 @@ def switch_webcam(self): self.start_playback() except Exception as e: self.logger.error("Error switching webcam: " + str(e)) - self.session.open( - MessageBox, - _("Error switching webcam"), - MessageBox.TYPE_ERROR - ) + self.show_error(_("Error switching webcam")) def __serviceStarted(self): """Service started playing""" @@ -327,6 +327,8 @@ def __serviceStarted(self): def toggle_favorite(self): """Add or remove from favorites""" + if not self.webcams: + return current_webcam = self.get_current_webcam() if FavoritesManager.is_favorite(current_webcam["url"]): success = FavoritesManager.remove_favorite(current_webcam["url"]) @@ -347,19 +349,25 @@ def toggle_favorite(self): def __evEOF(self): self.logger.info("Playback completed") - self.close() + self.leavePlayer() def __evStopped(self): + # Also fired while zapping to another webcam: do not close here self.logger.info("Playback stopped") - self.close() - def leavePlayer(self): + def leavePlayer(self, *args): + if self.closing: + return + self.closing = True self.close() def cancel(self): - self.close() + self.leavePlayer() def start_playback(self): + """Resolve the current webcam off the GUI thread, then play it""" + if self.closing or not self.webcams: + return try: current_webcam = self.get_current_webcam() self.logger.info( @@ -369,91 +377,67 @@ def start_playback(self): "URL: {0}".format(current_webcam["url"]) ) - # Check if it's YouTube - stream_url = self.scraper.get_stream_url(current_webcam["url"]) - if not stream_url: - self.logger.error("Could not extract stream URL") - self.show_error(_("Could not extract video stream")) - return + self.play_request += 1 + request_id = self.play_request - self.logger.info("Stream URL: {0}".format(stream_url)) - - if is_youtube_url(stream_url): - self.logger.info("Detected YouTube stream") - self.play_youtube(stream_url, current_webcam["name"]) + if is_youtube_url(current_webcam["url"]): + self.show_overlay_text(_("Resolving YouTube stream...")) else: - self.logger.info("Detected regular stream") - self.play_stream(stream_url, current_webcam["name"]) + self.show_overlay_text(_("Loading stream...")) + # Page scraping and yt-dlp can be slow (yt-dlp 20-60s on + # receivers): run them in a worker thread to keep the GUI alive + from twisted.internet import threads + d = threads.deferToThread( + self._resolve_stream, current_webcam["url"]) + d.addCallback( + self._stream_resolved, request_id, current_webcam["name"]) + d.addErrback( + self._resolve_failed, request_id, current_webcam["name"]) except Exception as e: self.logger.error("Playback error: " + str(e)) self.show_error(_("Playback error")) - def play_youtube(self, url, title): - """ - Main YouTube playback method - non-blocking. - - yt-dlp can take 20-60s on slow receivers, so we run it in a - worker thread via twisted.deferToThread to keep the GUI alive - and avoid the Enigma2 watchdog killing the player. - """ - try: - self.logger.info("[YouTube] Starting for: %s" % title) - - # Normalize URL (embed / nocookie / shorts / live → watch?v=) - normalized = convert_youtube_embed_to_watch(url) - self.logger.info("[YouTube] Normalized: %s" % normalized) - - self.youtube_play_request += 1 - request_id = self.youtube_play_request - - # Feedback in the state label (infobar keeps working) - if "state" in self: - self["state"].setText(_("Resolving YouTube stream...")) - - from twisted.internet import threads - d = threads.deferToThread(resolve_youtube, normalized) - d.addCallback(self._youtube_resolved, request_id, title) - d.addErrback(self._youtube_failed, request_id) - return True - except Exception as e: - self.logger.error("[YouTube] playback error: %s" % str(e)) - self.show_error(_("YouTube playback error")) - return False - - def _youtube_resolved(self, result, request_id, title): - """Runs on the GUI thread once yt-dlp has finished.""" - if request_id != self.youtube_play_request: - # A newer request superseded this one — drop stale answer - self.logger.info("[YouTube] stale answer ignored") + def _resolve_stream(self, url): + """Worker thread: return (stream_url, error_message)""" + if is_youtube_url(url): + return resolve_youtube(convert_youtube_embed_to_watch(url)) + + stream_url = self.scraper.get_stream_url(url) + if not stream_url: + return None, _("Could not extract video stream") + if is_youtube_url(stream_url): + self.logger.info("Detected YouTube stream") + return resolve_youtube(convert_youtube_embed_to_watch(stream_url)) + return stream_url, None + + def _stream_resolved(self, result, request_id, title): + """Runs on the GUI thread once the stream URL is known""" + if self.closing or request_id != self.play_request: + # Player closed or a newer webcam selected: drop the answer + self.logger.info("Stale stream answer ignored") return + self.hide_overlay() resolved, error = result if resolved: - self.logger.info("[YouTube] resolved: %s..." % resolved[:80]) - if "state" in self: - self["state"].setText("") + self.logger.info("Stream URL: %s..." % resolved[:80]) self.play_stream(resolved, title) - else: - self.logger.error("[YouTube] resolve failed: %s" % error) - if "state" in self: - self["state"].setText("") - msg = _("YouTube stream not available") - if error: - msg += "\n\n%s" % error - if error == "yt-dlp is not installed": - msg += "\n\n" + _( - "Install it with:\nopkg install python3-yt-dlp") - self.show_error(msg) - - def _youtube_failed(self, failure, request_id): - msg = "[YouTube] resolver error: %s" % failure - try: - self.logger.error(msg) - except AttributeError: - print(msg) - self._youtube_resolved( - (None, failure.getErrorMessage()), request_id, "") + return + + self.logger.error("Stream resolve failed: %s" % error) + msg = _("Stream not available") + if error: + msg += "\n\n%s" % error + if error == "yt-dlp is not installed": + msg += "\n\n" + _( + "Install it with:\nopkg install python3-yt-dlp") + self.show_error(msg) + + def _resolve_failed(self, failure, request_id, title): + self.logger.error("Resolver error: %s" % failure) + self._stream_resolved( + (None, failure.getErrorMessage()), request_id, title) def start_service_playback(self, service): """Start playback with special handling""" @@ -463,8 +447,7 @@ def start_service_playback(self, service): self.session.nav.playService(service) self.show() self.state = self.STATE_PLAYING - if self.state == self.STATE_PLAYING: - self.show_help_overlay() + self.show_help_overlay() def play_stream(self, stream_url, title=""): """ @@ -531,15 +514,32 @@ def playpauseService(self): def show_error(self, message): """Show error message and close player""" + if self.closing: + return + self.hide_overlay() self.session.openWithCallback( - self.close, + self.leavePlayer, MessageBox, message, MessageBox.TYPE_ERROR ) def cleanup(self): - """Cleanup resources on close""" + """Cleanup resources on close (runs once)""" + if self._cleaned_up: + return + self._cleaned_up = True + self.closing = True + self.hideTimer.stop() + self.overlay_timer.stop() + if self.overlay is not None: + try: + self.overlay.hide() + self.session.deleteDialog(self.overlay) + except Exception: + pass + self.overlay = None + if exists('/tmp/hls.avi'): try: remove('/tmp/hls.avi') diff --git a/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/utils.py b/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/utils.py index 2e2a4a29..2052b278 100644 --- a/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/utils.py +++ b/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/utils.py @@ -903,6 +903,10 @@ def convert_youtube_embed_to_watch(url): """ if not is_youtube_url(url): return url + # Channel live embed: keep it a channel URL (resolved as /live) + m = search(r'/embed/live_stream\?(?:.*&)?channel=([^&#]+)', url) + if m: + return "https://www.youtube.com/channel/%s/live" % m.group(1) patterns = [ r'(?:youtube-nocookie\.com|youtube\.com)/embed/([^/?#&]+)', r'youtube\.com/live/([^/?#&]+)', @@ -931,7 +935,7 @@ def get_service_type(preferred=None): Return the best eServiceReference type for playback. preferred: 'auto' (default), 'exteplayer3', 'gstplayer', 'mp3'. - Falls back to 4097 if ServiceApp is missing. + exteplayer3/gstplayer need ServiceApp, otherwise 4097 is used. """ if preferred in ("exteplayer3", "gstplayer"): if has_serviceapp(): @@ -940,7 +944,19 @@ def get_service_type(preferred=None): Logger().warning( "Player '%s' requires ServiceApp, falling back to 4097" % preferred) - if has_serviceapp(): - # gstplayer is generally safer than exteplayer3 for HLS - return SERVICE_GSTPLAYER + # 4097 by default: with ServiceApp installed, 4097 already follows + # the player chosen in the ServiceApp settings return SERVICE_MP3 + + +def timer_connect(timer, callback): + """ + Connect an eTimer callback on both DreamOS and OE images. + The returned connection object (DreamOS) MUST be kept alive by the + caller, otherwise the callback is disconnected immediately. + """ + try: + return timer.timeout.connect(callback) + except AttributeError: + timer.callback.append(callback) + return None diff --git a/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/youtube_helper.py b/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/youtube_helper.py index 7f6866af..e2809066 100644 --- a/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/youtube_helper.py +++ b/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/youtube_helper.py @@ -35,10 +35,17 @@ def _p(self, lvl, msg, *args): line = msg print("[WorldCam YouTube][%s] %s" % (lvl, line)) - def debug(self, msg, *a): self._p("DEBUG", msg, *a) - def info(self, msg, *a): self._p("INFO", msg, *a) - def warning(self, msg, *a): self._p("WARN", msg, *a) - def error(self, msg, *a): self._p("ERROR", msg, *a) + def debug(self, msg, *a): + self._p("DEBUG", msg, *a) + + def info(self, msg, *a): + self._p("INFO", msg, *a) + + def warning(self, msg, *a): + self._p("WARN", msg, *a) + + def error(self, msg, *a): + self._p("ERROR", msg, *a) return _StdoutLogger() @@ -131,17 +138,48 @@ def extract_video_id(url): decoded = unquote(url) for pattern in patterns: match = re.search(pattern, decoded, re.IGNORECASE) - if match: + # embed/live_stream?channel=ID is a channel, not a video + if match and match.group(1).lower() != "live_stream": return match.group(1) except Exception as e: log.error("Error extracting video ID: %s" % e) return None +def youtube_target_url(url): + """ + URL to pass to yt-dlp: watch?v=ID for videos, /live for channel + links (live_stream embeds, /channel/, /c/, /user/, /@name) and the + playlist URL for playlists. None if the URL is not usable. + """ + video_id = extract_video_id(url) + if video_id: + return "https://www.youtube.com/watch?v=" + video_id + try: + decoded = unquote(url) + except Exception: + decoded = url + match = re.search(r'/embed/live_stream\?(?:.*&)?channel=([^&#]+)', + decoded, re.IGNORECASE) + if match: + return "https://www.youtube.com/channel/%s/live" % match.group(1) + match = re.search( + r'youtube\.com/(channel/[^/?&#]+|c/[^/?&#]+|user/[^/?&#]+|@[^/?&#]+)', + decoded, re.IGNORECASE) + if match: + return "https://www.youtube.com/%s/live" % match.group(1) + match = re.search(r'youtube\.com/playlist\?(?:.*&)?list=([^&#]+)', + decoded, re.IGNORECASE) + if match: + return "https://www.youtube.com/playlist?list=%s" % match.group(1) + return None + + def _short_error(stderr): """Last meaningful yt-dlp error line.""" - lines = [l.strip() for l in (stderr or "").splitlines() if l.strip()] - errors = [l for l in lines if l.startswith("ERROR")] + lines = [line.strip() for line in (stderr or "").splitlines() + if line.strip()] + errors = [line for line in lines if line.startswith("ERROR")] text = (errors or lines or ["unknown error"])[-1] text = text.replace("ERROR: ", "") return text[:160] @@ -172,11 +210,17 @@ def _run_ytdlp(cmd): return None, _short_error(result.stderr) -def get_stream_with_ytdlp(ytdlp_cmd, video_id): - """Resolve video_id; return (stream_url, error_message).""" - youtube_url = "https://www.youtube.com/watch?v=" + video_id +def get_stream_with_ytdlp(ytdlp_cmd, video): + """ + Resolve a video ID or a YouTube URL; + return (stream_url, error_message). + """ + if video.startswith(("http://", "https://")): + youtube_url = video + else: + youtube_url = "https://www.youtube.com/watch?v=" + video base = ytdlp_cmd + [ - "-g", "--no-playlist", "--no-warnings", + "-g", "--no-playlist", "--playlist-items", "1", "--no-warnings", "--socket-timeout", "20", "-f", FORMAT_CHAIN, ] + find_js_runtime_args() @@ -193,8 +237,8 @@ def get_stream_with_ytdlp(ytdlp_cmd, video_id): def resolve_youtube(url): """Resolve a YouTube URL; return (stream_url, error_message).""" - video_id = extract_video_id(url) - if not video_id: + target = youtube_target_url(url) + if not target: log.error("Cannot extract video ID from: %s" % url) return None, "invalid YouTube URL" @@ -202,7 +246,7 @@ def resolve_youtube(url): if not ytdlp: return None, "yt-dlp is not installed" - return get_stream_with_ytdlp(ytdlp, video_id) + return get_stream_with_ytdlp(ytdlp, target) def get_youtube_stream(url): @@ -234,4 +278,4 @@ def get_diagnostics(): js_args = find_js_runtime_args() if js_args: diag["js_runtime"] = js_args[-1] - return diag \ No newline at end of file + return diag From 1395966ea9c80cf9b72e26558672043f24da23fd Mon Sep 17 00:00:00 2001 From: pQu4k3r Date: Thu, 8 Oct 2026 15:19:45 +0000 Subject: [PATCH 2/8] Fix bouquet export of YouTube webcams and location lists YouTube webcams were exported as http://localhost:8000/proxy.m3u8?url= (a proxy that does not exist), so they never played from a bouquet. - Export YouTube entries as https://www.youtube.com/watch?v=ID, the form ytdlpwrapper resolves when playing from a bouquet; the export message says the wrapper is needed - Direct streams (m3u8, mp4...) exported as plain 4097 services; streamlink:// is only used for skylinewebcams pages - Export / "Export All to Bouquet" on a location exports that location's webcams to their own bouquet instead of the favorites - Bouquets are reloaded, no Enigma2 restart is needed - Favorites stored in worldcam_favorites.json (favorites.json is used by other plugins too); WorldCam favorites are migrated once - Logger: never raise on a format mismatch (safe_cleanup crashed), fix exception() argument order --- .../Plugins/Extensions/WorldCam/plugin.py | 14 +- .../Plugins/Extensions/WorldCam/utils.py | 207 +++++++++++++----- 2 files changed, 153 insertions(+), 68 deletions(-) diff --git a/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/plugin.py b/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/plugin.py index a0192f5e..00b3a990 100644 --- a/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/plugin.py +++ b/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/plugin.py @@ -1445,13 +1445,8 @@ def context_menu_callback(self, choice, webcam): MessageBox.TYPE_INFO, timeout=3 ) - if choice[1] == "export_bouquet": - success, message = FavoritesManager.export_to_bouquet() - self.session.open( - MessageBox, - message, - MessageBox.TYPE_INFO if success else MessageBox.TYPE_ERROR - ) + elif choice[1] == "export_bouquet": + self.export_bouquet_direct() elif choice[1] == "play": # self.session.open(WorldCamPlayer, webcam["name"], webcam["url"]) index = self["list"].getCurrentIndex() @@ -1462,8 +1457,9 @@ def context_menu_callback(self, choice, webcam): ) def export_bouquet_direct(self): - """Esegue direttamente l'esportazione nel bouquet""" - success, message = FavoritesManager.export_to_bouquet() + """Export the webcams of this location to their own bouquet""" + success, message = FavoritesManager.export_to_bouquet( + self.webcams, self.location["name"]) self.session.open( MessageBox, message, diff --git a/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/utils.py b/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/utils.py index 2052b278..dd265d39 100644 --- a/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/utils.py +++ b/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/utils.py @@ -118,26 +118,36 @@ def log(self, message, level="INFO", *args): except Exception as e: print(f"Log write failed: {str(e)}") + @staticmethod + def _format(message, args): + """message % args, never raising on a format mismatch""" + if not args: + return message + try: + return message % args + except (TypeError, ValueError): + return " ".join([str(message)] + [str(a) for a in args]) + def debug(self, message, *args): - self.log(message % args if args else message, "DEBUG") + self.log(self._format(message, args), "DEBUG") def info(self, message, *args): - self.log(message % args if args else message, "INFO") + self.log(self._format(message, args), "INFO") def warning(self, message, *args): - self.log(message % args if args else message, "WARNING") + self.log(self._format(message, args), "WARNING") def error(self, message, *args): - self.log(message % args if args else message, "ERROR") + self.log(self._format(message, args), "ERROR") def critical(self, message, *args): - self.log("CRITICAL: " + (message % args if args else message), "ERROR") + self.log(self._format(message, args), "CRITICAL") def exception(self, message, *args): exc_info = self._get_exception_info() self.log( - "ERROR", - f"EXCEPTION: {message % args if args else message}\n{exc_info}") + "EXCEPTION: %s\n%s" % (self._format(message, args), exc_info), + "ERROR") def _get_exception_info(self): """Get formatted exception info""" @@ -166,9 +176,12 @@ def _get_exception_info(self): # self.log(message, "DEBUG") -# FAVORITES_FILE = join(PLUGIN_PATH, "favorites.json") -# FAVORITES_FILE = "/etc/enigma2/favorites.json" -FAVORITES_FILE = join(eEnv.resolve("${sysconfdir}/enigma2"), "favorites.json") +# Own file name: the generic favorites.json is used by other plugins too +FAVORITES_FILE = join( + eEnv.resolve("${sysconfdir}/enigma2"), "worldcam_favorites.json") +# File used by older versions (read once to migrate the favorites) +OLD_FAVORITES_FILE = join( + eEnv.resolve("${sysconfdir}/enigma2"), "favorites.json") # Update the safe_encode_url function @@ -241,22 +254,74 @@ def clean_html_entities(text): # 4097:0:1:46DE:221E:EC:0:0:0:0:streamlink%3a//https%3a//www.skylinewebcams.com/it/webcam/italia/sardegna/sassari/stintino.html:Sassari # - Stintino - La Pelosa +def is_skyline_page(url): + """True for a skylinewebcams.com webcam page (not a direct stream)""" + u = (url or "").lower() + return "skylinewebcams.com" in u and u.split("?")[0].endswith(".html") + + +def bouquet_service_lines(url, name): + """ + #SERVICE / #DESCRIPTION lines for a webcam. + Returns (lines, kind) with kind 'youtube', 'streamlink' or 'direct'. + - YouTube: https://www.youtube.com/watch?v=ID, played from the + bouquet by the yt-dlp wrapper plugin + - skylinewebcams pages: streamlink:// (streamlink wrapper plugin) + - anything else is a direct stream + """ + url = str(url or "").replace("\r", " ").replace("\n", " ").strip() + name = str(name or "").replace("\r", " ").replace("\n", " ").strip() + if is_youtube_url(url): + kind = "youtube" + url = convert_youtube_embed_to_watch(url) + service_url = url.replace(":", "%3a") + elif is_skyline_page(url): + kind = "streamlink" + service_url = "streamlink%3a//" + url.replace(":", "%3a") + else: + kind = "direct" + service_url = url.replace(":", "%3a") + lines = "#SERVICE 4097:0:1:0:0:0:0:0:0:0:%s:%s\n#DESCRIPTION %s\n" % ( + service_url, name.replace(":", "%3a"), name) + return lines, kind + + class FavoritesManager: + @staticmethod + def _migrate_old_file(): + """Copy WorldCam favorites from the old generic favorites.json""" + if exists(FAVORITES_FILE) or not exists(OLD_FAVORITES_FILE): + return + try: + with open(OLD_FAVORITES_FILE, "r", encoding="utf-8") as f: + data = load(f) + # Only a WorldCam list: [{"name": ..., "url": ...}, ...] + if isinstance(data, list) and all( + isinstance(x, dict) and set(x) == {"name", "url"} + for x in data): + with open(FAVORITES_FILE, "w", encoding="utf-8") as f: + dump(data, f, indent=4, ensure_ascii=False) + Logger().info("Favorites migrated to " + FAVORITES_FILE) + except Exception as e: + Logger().warning("Favorites migration skipped: %s" % str(e)) + @staticmethod def load_favorites(): - """Carica i preferiti dal file JSON""" + """Load favorites from the JSON file""" + FavoritesManager._migrate_old_file() if not exists(FAVORITES_FILE): return [] try: with open(FAVORITES_FILE, "r", encoding="utf-8") as f: - return load(f) + data = load(f) + return data if isinstance(data, list) else [] except Exception as e: Logger().error(f"Error loading favorites: {str(e)}") return [] @staticmethod def save_favorites(favorites): - """Salva i preferiti nel file JSON""" + """Save favorites to the JSON file""" try: with open(FAVORITES_FILE, "w", encoding="utf-8") as f: dump(favorites, f, indent=4, ensure_ascii=False) @@ -267,10 +332,10 @@ def save_favorites(favorites): @staticmethod def add_favorite(name, url): - """Aggiunge un webcam ai preferiti""" + """Add a webcam to favorites""" favorites = FavoritesManager.load_favorites() - # Controlla se esiste già + # Already there if any(fav["url"] == url for fav in favorites): return False @@ -279,77 +344,103 @@ def add_favorite(name, url): @staticmethod def remove_favorite(url): - """Rimuove un webcam dai preferiti""" + """Remove a webcam from favorites""" favorites = FavoritesManager.load_favorites() new_favorites = [fav for fav in favorites if fav["url"] != url] if len(new_favorites) == len(favorites): - return False # Non trovato + return False # Not found return FavoritesManager.save_favorites(new_favorites) @staticmethod def is_favorite(url): - """Controlla se un URL è nei preferiti""" + """Check whether a URL is in favorites""" return any( fav["url"] == url for fav in FavoritesManager.load_favorites()) @staticmethod - def export_to_bouquet(): - """Export favorites to Enigma2 bouquet""" + def export_to_bouquet(webcams=None, title=None): + """ + Export webcams to an Enigma2 bouquet. + Without arguments the favorites are exported to + 'WorldCam Favorites'; otherwise the given webcams go to their + own bouquet named after title. + """ try: - favorites = FavoritesManager.load_favorites() - if not favorites: - return False, _("No favorites to export") + if webcams is None: + webcams = FavoritesManager.load_favorites() + display_name = "WorldCam Favorites" + bouquet_name = "userbouquet.worldcam_favorites.tv" + if not webcams: + return False, _("No favorites to export") + else: + title = title or "Webcams" + display_name = "WorldCam - %s" % title + safe = "".join( + c for c in title.lower() if c.isalnum() or c == "_")[:30] + bouquet_name = "userbouquet.worldcam_%s.tv" % ( + safe or "webcams") + if not webcams: + return False, _("No webcams to export") - # Create bouquet directory if needed bouquet_dir = eEnv.resolve("${sysconfdir}/enigma2") if not exists(bouquet_dir): makedirs(bouquet_dir) - - # Create bouquet file - bouquet_name = "userbouquet.worldcam_favorites.tv" bouquet_path = join(bouquet_dir, bouquet_name) + exported = 0 + kinds = set() with open(bouquet_path, "w", encoding="utf-8") as f: - f.write("#NAME WorldCam Favorites\n") - for fav in favorites: - # Create service reference - if "youtube.com" in fav["url"] or "youtu.be" in fav["url"]: - # YouTube streams require special handling - service_type = 5001 # HLS - service_url = "http://localhost:8000/proxy.m3u8?url={0}".format( - quote(fav["url"])) - else: - service_type = 4097 # HTTP - encoded_url = quote(fav["url"], safe="") - service_url = "streamlink%3a//{0}".format(encoded_url) - - # Create service line - service_line = "#SERVICE {0}:0:1:0:0:0:0:0:0:0:{1}\n".format( - service_type, service_url) - f.write(service_line) - f.write("#DESCRIPTION {0}\n".format(fav["name"])) - - # Add to bouquet list + f.write("#NAME %s\n" % display_name) + for cam in webcams: + url = cam.get("url") + if not url: + continue + lines, kind = bouquet_service_lines( + url, cam.get("name", "")) + f.write(lines) + kinds.add(kind) + exported += 1 + + if not exported: + try: + remove(bouquet_path) + except Exception: + pass + return False, _("No valid streams found") + + # Add to the bouquet list (once) bouquets_path = join(bouquet_dir, "bouquets.tv") if not exists(bouquets_path): with open(bouquets_path, "w") as f: f.write("#NAME Bouquets (TV)\n") - # Check if already exists in bouquets with open(bouquets_path, "r") as f: content = f.read() - bouquet_ref = f"#SERVICE 1:7:1:0:0:0:0:0:0:0:FROM BOUQUET \"{bouquet_name}\" ORDER BY bouquet" + bouquet_ref = '#SERVICE 1:7:1:0:0:0:0:0:0:0:FROM BOUQUET "%s" ORDER BY bouquet' % bouquet_name if bouquet_ref not in content: with open(bouquets_path, "a") as f: - f.write(f"{bouquet_ref}\n") - - from twisted.internet import reactor - reactor.callFromThread(reload_services) - return True, _( - "Favorites exported successfully! Restart Enigma2 to see them.") + if content and not content.endswith("\n"): + f.write("\n") + f.write(bouquet_ref + "\n") + + reload_services() + + message = _("Exported %d webcams to '%s'") % ( + exported, display_name) + if "youtube" in kinds: + message += "\n\n" + _( + "YouTube entries play from the bouquet only with the " + "yt-dlp wrapper plugin " + "(enigma2-plugin-extensions-ytdlpwrapper).") + if "streamlink" in kinds: + message += "\n\n" + _( + "SkylineWebcams entries play from the bouquet only with " + "the streamlink wrapper plugin " + "(enigma2-plugin-extensions-streamlinkwrapper).") + return True, message except Exception as e: Logger().error(f"Export error: {str(e)}") return False, _("Export failed: ") + str(e) @@ -481,15 +572,13 @@ def safe_cleanup(screen_instance): else: logger.debug( "No cleanup method for " + - screen_instance.__class__.__name__, - "SAFE_CLEANUP") + screen_instance.__class__.__name__) except Exception as e: logger.error( "Cleanup error in " + screen_instance.__class__.__name__ + ": " + - str(e), - "SAFE_CLEANUP") + str(e)) def _sort_by_name(items): From 98f70e28e2223212c6cc9de29bbc6dd977eb8e04 Mon Sep 17 00:00:00 2001 From: pQu4k3r Date: Thu, 8 Oct 2026 15:20:10 +0000 Subject: [PATCH 3/8] Fix M3U playlists listing every webcam N times - The M3U branch re-parsed the whole file for every URL line, so a playlist with N entries showed N*N; parse M3U files once - Check YouTube links before the direct-stream test - Expire cached pages after one hour instead of a repeating eTimer per scraper (timers leaked and used callback.append, missing on DreamOS) - Use only languages skylinewebcams.com offers, English otherwise --- .../Plugins/Extensions/WorldCam/scraper.py | 123 ++++++++---------- 1 file changed, 56 insertions(+), 67 deletions(-) diff --git a/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/scraper.py b/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/scraper.py index a8514ab8..f4aa4393 100644 --- a/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/scraper.py +++ b/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/scraper.py @@ -1,7 +1,7 @@ #!/usr/bin/python # -*- coding: utf-8 -*- -from enigma import eTimer +from time import time from re import search, escape, findall, DOTALL, IGNORECASE # , sub from os import listdir from os.path import ( @@ -40,6 +40,13 @@ BASE_URL = "https://www.skylinewebcams.com" +# Languages available on skylinewebcams.com (others fall back to English) +SKYLINE_LANGUAGES = ( + "en", "it", "es", "de", "fr", "pl", "el", "hr", "sl", "ru", "zh") + +# Fetched pages are cached for one hour +CACHE_TTL = 3600 + """ 1. **WorldCamContinentScreen** @@ -89,13 +96,10 @@ def __init__(self, lang="en"): """ self.logger = Logger() self.logger.info("Entering __init__") - self.lang = lang + self.lang = lang if lang in SKYLINE_LANGUAGES else "en" + # url -> (time fetched, content); entries expire after CACHE_TTL self.cache = {} - self.timer = eTimer() - self.timer.callback.append(self.clear_cache) - self.timer.start(3600000) # Clear cache every hour (3600000 ms) - def clear_cache(self): """Clear the internal content cache.""" self.logger.info("Entering clear_cache") @@ -144,16 +148,18 @@ def get_stream_url(self, webcam_page_url): """ self.logger.info("Entering get_stream_url") try: - # Case 1: URL is already a direct stream - if self.is_direct_stream(webcam_page_url): + # Case 1: YouTube URL + lower_url = webcam_page_url.lower() + if ('youtube.com' in lower_url or 'youtu.be' in lower_url or + 'youtube-nocookie.com' in lower_url): self.logger.info( - "URL is already a direct stream, returning as is") + "Detected direct YouTube URL, returning as is") return webcam_page_url - # Case 2: YouTube URL - if 'youtube.com' in webcam_page_url or 'youtu.be' in webcam_page_url: + # Case 2: URL is already a direct stream + if self.is_direct_stream(webcam_page_url): self.logger.info( - "Detected direct YouTube URL, returning as is") + "URL is already a direct stream, returning as is") return webcam_page_url # Case 3: Explore.org direct stream (specific pattern) @@ -223,8 +229,11 @@ def fetch(self, url, use_cache=True): safe_url = str(url) if use_cache and safe_url in self.cache: - self.logger.info("Using cached content for: " + safe_url) - return self.cache[safe_url] + fetched, cached = self.cache[safe_url] + if time() - fetched < CACHE_TTL: + self.logger.info("Using cached content for: " + safe_url) + return cached + del self.cache[safe_url] self.logger.info(f"Fetching URL: {safe_url}") @@ -251,7 +260,7 @@ def fetch(self, url, use_cache=True): decoded_content = content.decode("utf-8", errors="ignore") if use_cache: - self.cache[safe_url] = decoded_content + self.cache[safe_url] = (time(), decoded_content) self.logger.info("Cached content for URL: " + safe_url) return decoded_content @@ -452,75 +461,55 @@ def parse_local_playlist_file(path): f.seek(0) content = f.read() - # Processa il contenuto invece di leggere linea per linea lines = content.splitlines() + # M3U format: #EXTINF:-1,Name followed by the URL line + if is_m3u: + current_name = "Unknown" + for line in lines: + line = line.strip() + if not line: + continue + if line.startswith("#EXTINF"): + parts = line.split(',', 1) + current_name = parts[1].strip() if len( + parts) > 1 else "Unknown" + current_name = current_name.split('@')[0].strip() + current_name = current_name.split(' - ')[0].strip() + current_name = current_name or "Unknown" + elif line.startswith("#"): + continue + elif line.startswith( + ("http://", "https://", "rtmp://", "rtsp://")): + webcams.append({ + "group": "M3U Playlist", + "name": current_name, + "url": line + }) + current_name = "Unknown" + else: + logger.warning(f"Invalid URL in M3U: {line}") + logger.info(f"Parsed {len(webcams)} webcams from {path}") + return webcams + for line in lines: line = line.strip() if not line or line.startswith("#"): continue - # M3U format parsing - if is_m3u: - current_name = "Unknown" - for line in lines: - line = line.strip() - - # Salta righe vuote - if not line: - continue - - # Gestione righe EXTINF - if line.startswith("#EXTINF"): - try: - # Estrai nome da: #EXTINF:-1, Nome Canale - parts = line.split(',', 1) - current_name = parts[1].strip() if len( - parts) > 1 else "Unknown" - - # Pulizia aggiuntiva - current_name = current_name.split( - '@')[0].strip() - current_name = current_name.split( - ' - ')[0].strip() - except Exception as e: - logger.error( - f"Error parsing EXTINF line: {line} - {str(e)}") - current_name = "Unknown" - - # Salta altri commenti - elif line.startswith("#"): - continue - - # Righe URL - else: - # Verifica che sia un URL valido - if line.startswith( - ("http://", "https://", "rtmp://", "rtsp://")): - webcams.append({ - "group": "M3U Playlist", - "name": current_name, - "url": line - }) - else: - logger.warning(f"Invalid URL in M3U: {line}") - - # Resetta per il prossimo - current_name = "Unknown" - - # Existing formats parsing + # Name###URL format if "###" in line: parts = line.split("###") parts = [p.strip() for p in parts if p.strip()] if len(parts) >= 2: - url = parts[-1].replace("###", "").strip() + url = parts[-1] name = " ".join(parts[:-1]) webcams.append({ "group": "User List", "name": name, "url": url }) - continue + continue # Traditional formats with separators separators = [":::", ";;", "::", ";"] From 057508bf1fa0f390e2af39e0b2ac00410d3b958d Mon Sep 17 00:00:00 2001 From: pQu4k3r Date: Thu, 8 Oct 2026 15:22:26 +0000 Subject: [PATCH 4/8] Fix wrong webcam/location opened, updater and screen loading - Top Webcams: the "Top Live Cams" row was removed from the names but not from the webcams, so the selected row played another webcam - Locations: the names were sorted but not the locations, so the selected row opened another location - Pages are fetched in a worker thread: the GUI no longer freezes while a list loads ("Loading..." in the title) - Update check off the GUI thread with a timeout (it could hang the main screen), versions compared as numbers ("7.0" < "10.0") - "Update completed" no longer crashes (Screen has no addCallback); images that pass no return code are treated as success - Removing the last favorite clears the list - No AttributeError on OK when a list failed to load - Chosen language is used for the site lists (screens always used English); only languages skylinewebcams.com offers are listed - Do not close the menu ChoiceBox twice, do not reopen the menu over the Settings message - Keep eTimer connections alive (DreamOS) --- .../Plugins/Extensions/WorldCam/plugin.py | 231 ++++++++++++------ 1 file changed, 160 insertions(+), 71 deletions(-) diff --git a/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/plugin.py b/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/plugin.py index 00b3a990..a318ab08 100644 --- a/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/plugin.py +++ b/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/plugin.py @@ -4,6 +4,7 @@ # Standard library from os import makedirs from os.path import exists, join, splitext, getsize +from re import findall from urllib.request import Request, urlopen @@ -43,7 +44,7 @@ DEFAULT_ICON ) from .player import WorldCamPlayer -from .scraper import SkylineScraper +from .scraper import SKYLINE_LANGUAGES, SkylineScraper from .utils import ( CATEGORY_ICONS, FavoritesManager, @@ -57,10 +58,10 @@ get_system_language, language_flag_mapping, set_current_language, + timer_connect, ) - """ ######################################################### # # @@ -85,17 +86,17 @@ # Initialize logger logger = Logger() -# System language +# System language (English if skylinewebcams.com does not offer it) current_language = get_system_language() +if current_language not in SKYLINE_LANGUAGES: + current_language = "en" set_current_language(current_language) screen_width = getDesktop(0).size().width() -# try export with# -# DESCRIPTION Alghero - Mugoni Beach -# SERVICE -# 4097:0:1:46DE:221E:EC:0:0:0:0:streamlink%3a//https%3a//www.skylinewebcams.com/it/webcam/italia/sardegna/sassari/stintino.html:Sassari -# - Stintino - La Pelosa +def version_tuple(version): + """'7.0.1' -> (7, 0, 1): versions compare as numbers, not strings""" + return tuple(int(x) for x in findall(r"\d+", str(version))) class WebcamList(MenuList): @@ -284,6 +285,36 @@ def __init__(self, session, lang=None): self.skin_path = self.get_skin_path() self.skin = self.load_skin() self["list"] = WebcamList([]) + self.screen_closed = False + self._async_title = None + self.onClose.append(self._mark_closed) + + def _mark_closed(self): + self.screen_closed = True + + def run_async(self, func, callback, title=None): + """ + Run func() in a worker thread (network fetches) and call + callback(result) on the GUI thread; result is None on error. + """ + self._async_title = title + if title is not None and "title" in self: + self["title"].setText(_("Loading...")) + from twisted.internet import threads + d = threads.deferToThread(func) + d.addCallback(self._async_done, callback) + d.addErrback(self._async_failed, callback) + + def _async_done(self, result, callback): + if self.screen_closed: + return + if self._async_title is not None and "title" in self: + self["title"].setText(self._async_title) + callback(result) + + def _async_failed(self, failure, callback): + self.logger.error("Loading error: %s" % failure.getErrorMessage()) + self._async_done(None, callback) def get_skin_path(self): """Determine skin path based on screen resolution""" @@ -393,7 +424,7 @@ def __init__(self, session, lang=None): "about": self.open_about_direct }) self.timer = eTimer() - self.timer.callback.append(self.check_update_silent) + self.timer_conn = timer_connect(self.timer, self.check_update_silent) self.timer.start(500, 1) self.onLayoutFinish.append(self.initialize) self.onLayoutFinish.append(self.set_flag_icon) @@ -433,7 +464,7 @@ def on_item_selected(self): category = self.categories[index] self.logger.info( f"Opening screen for category: {category['name']}") - self.session.open(category["screen"]) + self.session.open(category["screen"], lang=self.lang) else: self.logger.warning("Invalid selection index") self.logger.info( @@ -480,8 +511,8 @@ def menu_callback(self, selection): label, callback = self.menu_items[index] self.logger.info("Selected menu item: %s" % label) if callable(callback): - if hasattr(self, 'current_menu') and self.current_menu: - self.current_menu.close() + # The ChoiceBox is already closed here + self.current_menu = None callback() except Exception as e: self.logger.error("Exception during menu callback: %s" % str(e)) @@ -494,7 +525,8 @@ def change_language(self): self.on_language_selected, LanguageScreen, self.lang, - language_flag_mapping + dict((code, flag) for code, flag in language_flag_mapping.items() + if code in SKYLINE_LANGUAGES) ) except Exception as e: self.logger.error(f"Language screen error: {str(e)}") @@ -582,8 +614,6 @@ def open_settings(self): self.defer_message( _("Error opening settings:\n%s") % str(e), MessageBox.TYPE_ERROR) - finally: - self.open_menu() # def settings_closed(self, result=None): # self.logger.info( @@ -595,36 +625,48 @@ def check_update_silent(self): self.update_plugin(silent=True) def update_plugin(self, silent=False): - """Check and update the plugin""" + """Check for a new version (network access off the GUI thread)""" + from twisted.internet import threads + d = threads.deferToThread(self._fetch_remote_version) + d.addCallback(self._version_fetched, silent) + d.addErrback(self._version_failed, silent) + + @staticmethod + def _fetch_remote_version(): + """Worker thread: return (version, changelog) from installer.sh""" + req = Request( + b64decoder(installer_url), headers={ + "User-Agent": AgentRequest}) + page = urlopen(req, timeout=15).read().decode("utf-8") remote_version = "0.0" remote_changelog = "" - - try: - req = Request( - b64decoder(installer_url), headers={ - "User-Agent": AgentRequest}) - page = urlopen(req).read().decode("utf-8") - except Exception as e: - if not silent: - self.defer_message( - _("Unable to fetch version info:\n{}").format( - str(e)), MessageBox.TYPE_ERROR) - return - for line in page.split("\n"): line = line.strip() if line.startswith("version"): remote_version = line.split( - "=")[-1].strip().strip("'").strip('"') + "=", 1)[-1].strip().strip("'").strip('"') elif line.startswith("changelog"): remote_changelog = line.split( - "=")[-1].strip().strip("'").strip('"') + "=", 1)[-1].strip().strip("'").strip('"') break + return remote_version, remote_changelog + def _version_failed(self, failure, silent): + self.logger.error( + "Update check failed: %s" % failure.getErrorMessage()) + if not silent and not self.screen_closed: + self.defer_message( + _("Unable to fetch version info:\n{}").format( + failure.getErrorMessage()), MessageBox.TYPE_ERROR) + + def _version_fetched(self, result, silent): + if self.screen_closed: + return + remote_version, remote_changelog = result self.new_version = str(remote_version) self.new_changelog = str(remote_changelog) - if PLUGIN_VERSION < self.new_version: + if version_tuple(PLUGIN_VERSION) < version_tuple(self.new_version): self.ask_update() else: if not silent: @@ -651,7 +693,7 @@ def ask(): MessageBox.TYPE_YESNO ) self._defer_timer = eTimer() - self._defer_timer.callback.append(ask) + self._defer_timer_conn = timer_connect(self._defer_timer, ask) self._defer_timer.start(100, True) def install_update(self, answer=False): @@ -678,13 +720,16 @@ def install_update(self, answer=False): def update_callback(self, result=None): """Handle update completion""" self.logger.info("Update finished with result: %s" % str(result)) - if result == 0: - self.session.open( + # Most images call finishedCallback() without a return code + if result in (None, 0): + self.session.openWithCallback( + lambda *args: self.close(), MessageBox, - _("Update completed successfully!\n\nThe plugin will now close."), + _("Update completed successfully!\n\n" + "Restart Enigma2 to use the new version.\n" + "The plugin will now close."), type=MessageBox.TYPE_INFO, - timeout=4).addCallback( - lambda _: self.close()) + timeout=4) else: self.defer_message( _("Update encountered an error (code: %s)") % str(result), @@ -697,7 +742,7 @@ def get_about_text(self): _("Live webcam viewer featuring global locations") + "\n\n" + _("Developed by Lululla") + "\n\n" + _("Powered by Enigma2 and Python") + "\n\n" + - _("GitHub: %s") % "https://github.com/Belfagor2005/" + "\n" + + _("GitHub: %s") % "https://github.com/OwnerPlugins/WorldCam" + "\n" + _("Forum support: www.corvoboys.org") + "\n\n" + _("Included playlists and sources:") + "\n" + _("- Local webcam list") + "\n" + @@ -726,9 +771,9 @@ def open_about_direct(self): def defer_message(self, text, mtype=MessageBox.TYPE_INFO): """Show message with a short delay to avoid UI modal conflicts""" self._defer_timer = eTimer() - self._defer_timer.callback.append( - lambda: self.session.open( - MessageBox, text, type=mtype)) + self._defer_timer_conn = timer_connect( + self._defer_timer, + lambda: self.session.open(MessageBox, text, type=mtype)) self._defer_timer.start(100, True) @@ -753,6 +798,7 @@ def __init__(self, session, lang=None): "yellow": self.remove_favorite, "blue": self.export_favorites }) + self.favorites = [] self.onLayoutFinish.append(self.load_favorites) self.onLayoutFinish.append(self.set_flag_icon) @@ -761,6 +807,8 @@ def load_favorites(self): try: self.favorites = FavoritesManager.load_favorites() if not self.favorites: + # Clear the list (the last favorite may just be removed) + showlist([], self["list"], is_category=True) self["title"].setText(_("No favorites yet")) self["key_yellow"].setText("") return @@ -768,8 +816,10 @@ def load_favorites(self): webcam_names = [fav["name"] for fav in self.favorites] showlist(webcam_names, self["list"], is_category=True) self["list"].setCurrentIndex(0) + self["title"].setText(_("Your Favorites")) self["key_yellow"].setText(_("Remove")) except Exception as e: + self.favorites = [] self.logger.error("Error loading favorites: %s" % str(e)) self["title"].setText(_("Error loading favorites")) @@ -820,7 +870,7 @@ def __init__(self, session, lang=None): super().__init__(session, lang) disable_summary(self) self.logger.info("Initializing WorldCamLocalScreen") - self.scraper = SkylineScraper(lang if lang else "en") + self.scraper = SkylineScraper(self.lang) self["title"] = Label(_("User Lists")) self["flag_icon"] = Pixmap() self["language_label"] = Label(self.lang.upper()) @@ -833,6 +883,7 @@ def __init__(self, session, lang=None): "ok": self.on_item_selected, "cancel": self.close, }) + self.user_lists = [] self.onLayoutFinish.append(self.load_user_lists) self.onLayoutFinish.append(self.set_flag_icon) @@ -889,7 +940,7 @@ def on_item_selected(self): playlists = self.user_lists[idx] self.logger.info("Opening user playlist: %s" % playlists) - self.session.open(WorldCamLocal, playlists) + self.session.open(WorldCamLocal, playlists, lang=self.lang) except Exception as e: self.logger.error("Error on item selected: %s" % str(e)) self["title"].setText(_("Error opening playlist")) @@ -907,8 +958,9 @@ def __init__(self, session, playlists, lang=None): "Initializing WorldCamLocal with category: %s" % playlists) self.category = playlists - self.scraper = SkylineScraper(lang if lang else "en") + self.scraper = SkylineScraper(self.lang) self["title"] = Label(splitext(playlists)[0]) + self.webcams = [] self["flag_icon"] = Pixmap() self["language_label"] = Label(self.lang.upper()) self["paypal"] = Label(paypal()) @@ -938,7 +990,7 @@ def load_webcams(self): self["title"].setText(_("Empty playlist file")) return - self.webcams = self.scraper.parse_local_playlist_file(path) + self.webcams = self.scraper.parse_local_playlist_file(path) or [] if not self.webcams: self.logger.warning("No webcams parsed from file") @@ -981,7 +1033,7 @@ def __init__(self, session, lang=None): super().__init__(session, lang) disable_summary(self) self.logger.info("Initializing WorldCamContinentScreen") - self.scraper = SkylineScraper(lang if lang else "en") + self.scraper = SkylineScraper(self.lang) self["title"] = Label(_("Continents")) self["flag_icon"] = Pixmap() self["language_label"] = Label(self.lang.upper()) @@ -1000,8 +1052,14 @@ def __init__(self, session, lang=None): def load_continents(self): """Load continents from scraper""" + self.run_async(self.scraper.get_continents, self._continents_loaded, + _("Continents")) + + def _continents_loaded(self, continents): try: - self.continents = self.scraper.get_continents() + if continents is None: + raise ValueError("no data") + self.continents = continents continent_names = [continent["name"] for continent in self.continents] self.logger.info(f"Loaded {len(continent_names)} continents") @@ -1021,7 +1079,8 @@ def on_item_selected(self): return continent = self.continents[index] self.logger.info(f"Selected continent: {continent['name']}") - self.session.open(WorldCamContinentCountryScreen, continent) + self.session.open( + WorldCamContinentCountryScreen, continent, lang=self.lang) class WorldCamContinentCountryScreen(WebcamBaseScreen): @@ -1034,7 +1093,7 @@ def __init__(self, session, continent, lang=None): "Initializing WorldCamContinentCountryScreen for {0}".format( continent["name"])) self.continent = continent - # self.scraper = SkylineScraper(lang if lang else "en") + # self.scraper = SkylineScraper(self.lang) self["title"] = Label(continent["name"]) self["flag_icon"] = Pixmap() self["language_label"] = Label(self.lang.upper()) @@ -1077,7 +1136,7 @@ def on_item_selected(self): country = self.countries[index] self.logger.info(f"Selected country: {country['name']}") - self.session.open(WorldCamLocationScreen, country) + self.session.open(WorldCamLocationScreen, country, lang=self.lang) class WorldCamCountryScreen(WebcamBaseScreen): @@ -1089,7 +1148,7 @@ def __init__(self, session, category=None, lang=None): super().__init__(session, lang) disable_summary(self) self.logger.info("Initializing WorldCamCountryScreen") - self.scraper = SkylineScraper(lang if lang else "en") + self.scraper = SkylineScraper(self.lang) self["title"] = Label(_("Country")) self["flag_icon"] = Pixmap() self["language_label"] = Label(self.lang.upper()) @@ -1102,13 +1161,19 @@ def __init__(self, session, category=None, lang=None): "ok": self.on_item_selected, "cancel": self.close, }) + self.countries = [] self.onLayoutFinish.append(self.load_countries) self.onLayoutFinish.append(self.set_flag_icon) def load_countries(self): """Load and sort countries from scraper, then show in list.""" + self.run_async(self.scraper.get_countries, self._countries_loaded, + _("Country")) + + def _countries_loaded(self, countries): try: - countries = self.scraper.get_countries() + if countries is None: + raise ValueError("no data") self.countries = sorted(countries, key=lambda c: c["name"].lower()) country_names = [country["name"] for country in self.countries] self.logger.info("Loaded and sorted countries: %s" % country_names) @@ -1132,7 +1197,7 @@ def on_item_selected(self): country = self.countries[index] self.logger.info("Selected country: %s" % country) - self.session.open(WorldCamLocationScreen, country) + self.session.open(WorldCamLocationScreen, country, lang=self.lang) class WorldCamCategoryScreen(WebcamBaseScreen): @@ -1144,7 +1209,7 @@ def __init__(self, session, lang=None): super().__init__(session, lang) disable_summary(self) self.logger.info("Initializing WorldCamCategoryScreen") - self.scraper = SkylineScraper(lang if lang else "en") + self.scraper = SkylineScraper(self.lang) self["title"] = Label(_("Categories")) self["flag_icon"] = Pixmap() self["language_label"] = Label(self.lang.upper()) @@ -1163,8 +1228,13 @@ def __init__(self, session, lang=None): def load_categories(self): """Load categories from scraper and display them sorted.""" + self.run_async(self.scraper.get_categories, self._categories_loaded, + _("Categories")) + + def _categories_loaded(self, categories): try: - categories = self.scraper.get_categories() + if categories is None: + raise ValueError("no data") self.categories = sorted( categories, key=lambda c: c["name"].lower()) category_names = [cat["name"] for cat in self.categories] @@ -1192,7 +1262,7 @@ def on_item_selected(self): category = self.categories[index] self.logger.info("Selected category: %s" % category) - self.session.open(WorldCamWebcamScreen, category) + self.session.open(WorldCamWebcamScreen, category, lang=self.lang) class WorldCamTopScreen(WebcamBaseScreen): @@ -1204,7 +1274,7 @@ def __init__(self, session, lang=None): super().__init__(session, lang) disable_summary(self) self.logger.info("Initializing WorldCamTopScreen") - self.scraper = SkylineScraper(lang if lang else "en") + self.scraper = SkylineScraper(self.lang) self["title"] = Label(_("Top Webcams")) self["flag_icon"] = Pixmap() self["language_label"] = Label(self.lang.upper()) @@ -1225,14 +1295,19 @@ def load_top_webcams(self): """ Load the list of top webcams, sort them by name and display in the list. """ + self.run_async(self.scraper.get_top_webcams, self._top_loaded, + _("Top Webcams")) + + def _top_loaded(self, top_webcams): try: - top_webcams = self.scraper.get_top_webcams() + if top_webcams is None: + raise ValueError("no data") + # Filter the list itself, so list rows and webcams match self.top_webcams = sorted( - top_webcams, key=lambda c: c["name"].lower()) - webcam_names = [ - w["name"] for w in self.top_webcams - if w["name"].strip().lower() != "top live cams" - ] + [w for w in top_webcams + if w["name"].strip().lower() != "top live cams"], + key=lambda c: c["name"].lower()) + webcam_names = [w["name"] for w in self.top_webcams] self.logger.info( "Loaded and sorted top webcams: %s" % webcam_names) @@ -1277,7 +1352,7 @@ def __init__(self, session, country, lang=None): disable_summary(self) self.logger.info("Initializing WorldCamLocationScreen") self.country = country - self.scraper = SkylineScraper(lang if lang else "en") + self.scraper = SkylineScraper(self.lang) self["title"] = Label(country["name"]) self["flag_icon"] = Pixmap() self["language_label"] = Label(self.lang.upper()) @@ -1298,10 +1373,18 @@ def load_locations(self): """ Load and display the list of locations for the current country. """ + self.run_async( + lambda: self.scraper.get_locations(self.country["url"]), + self._locations_loaded, self.country["name"]) + + def _locations_loaded(self, locations): try: - self.locations = self.scraper.get_locations(self.country["url"]) - location_names = sorted( - [loc["name"] for loc in self.locations], key=lambda s: s.lower()) + if locations is None: + raise ValueError("no data") + # Sort the list itself, so list rows and locations match + self.locations = sorted( + locations, key=lambda loc: loc["name"].lower()) + location_names = [loc["name"] for loc in self.locations] self.logger.info( "Loaded locations for country %s: %s" % (self.country["name"], location_names)) @@ -1336,7 +1419,7 @@ def on_item_selected(self): self.logger.info("Selected location name: %s" % selected_name) location = self.locations[index] self.logger.info("Selected location dict: %s" % str(location)) - self.session.open(WorldCamWebcamScreen, location) + self.session.open(WorldCamWebcamScreen, location, lang=self.lang) else: self.logger.warning( "Selected item is None or not a list: %s" % @@ -1353,7 +1436,7 @@ def __init__(self, session, location, lang=None): self.logger.info("Initializing WorldCamWebcamScreen") disable_summary(self) self.location = location - self.scraper = SkylineScraper(lang if lang else "en") + self.scraper = SkylineScraper(self.lang) self["title"] = Label(location["name"]) self["flag_icon"] = Pixmap() self["language_label"] = Label(self.lang.upper()) @@ -1470,8 +1553,14 @@ def load_webcams(self): """ Load and display the list of webcams for the current location. """ + self.run_async( + lambda: self.scraper.get_webcams(self.location["url"]), + self._webcams_loaded, self.location["name"]) + + def _webcams_loaded(self, webcams): try: - webcams = self.scraper.get_webcams(self.location["url"]) + if webcams is None: + raise ValueError("no data") self.webcams = sorted(webcams, key=lambda w: w["name"].lower()) webcam_names = [webcam["name"] for webcam in self.webcams] self.logger.info( From 20a5c594c66b82edf42ecc3c09d772aec998b716 Mon Sep 17 00:00:00 2001 From: pQu4k3r Date: Thu, 8 Oct 2026 15:22:26 +0000 Subject: [PATCH 5/8] Do not run opkg at startup, update from OwnerPlugins - Importing the plugin ran 'opkg update && opkg install python3-requests' synchronously, blocking Enigma2 while plugins load (requests is not needed) - Updater and developer URLs point to OwnerPlugins/WorldCam instead of the upstream repository, which overwrote this fork on update - Dependency check finds the yt_dlp module without importing it (slow on receivers) and no longer reports requests as missing --- .../Plugins/Extensions/WorldCam/__init__.py | 33 ++----------------- .../Extensions/WorldCam/checkdependencies.py | 14 +++----- 2 files changed, 6 insertions(+), 41 deletions(-) diff --git a/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/__init__.py b/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/__init__.py index a60849e1..d41d2f20 100644 --- a/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/__init__.py +++ b/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/__init__.py @@ -5,35 +5,6 @@ from Components.Language import language from Tools.Directories import resolveFilename, SCOPE_PLUGINS -# ========== CHECK AND INSTALL REQUESTS IF MISSING ========== -def ensure_requests(): - """ - Check if 'requests' module is available. If not, try to install it via opkg. - Returns the requests module if successful, otherwise None. - """ - try: - import requests - return requests - except ImportError: - print("[WorldCam] requests module not found, attempting to install...") - cmd = "opkg update && opkg install python3-requests" - try: - import subprocess - subprocess.call(cmd, shell=True) - # Try to import again after installation - import requests - print("[WorldCam] requests successfully installed.") - return requests - except Exception as e: - print("[WorldCam] Installation failed: {}".format(e)) - return None - -# Run the check at plugin startup (optional) -_requests = ensure_requests() -if _requests is None: - print("[WorldCam] WARNING: 'requests' not available. Network functions may fail.") -# ============================================================ - __author__ = "Lululla" __email__ = "ekekaz@gmail.com" __copyright__ = 'Copyright (c) 2024 Lululla' @@ -49,8 +20,8 @@ def ensure_requests(): PluginLanguagePath = 'Extensions/WorldCam/locale' # AgentRequest = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.3' AgentRequest = 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.130 Safari/537.36 OPR/66.0.3515.115' -installer_url = 'aHR0cHM6Ly9yYXcuZ2l0aHVidXNlcmNvbnRlbnQuY29tL0JlbGZhZ29yMjAwNS9Xb3JsZENhbS9tYWluL2luc3RhbGxlci5zaA==' -developer_url = 'aHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9CZWxmYWdvcjIwMDUvV29ybGRDYW0=' +installer_url = 'aHR0cHM6Ly9yYXcuZ2l0aHVidXNlcmNvbnRlbnQuY29tL093bmVyUGx1Z2lucy9Xb3JsZENhbS9tYWluL2luc3RhbGxlci5zaA==' +developer_url = 'aHR0cHM6Ly9hcGkuZ2l0aHViLmNvbS9yZXBvcy9Pd25lclBsdWdpbnMvV29ybGRDYW0=' def _(txt): diff --git a/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/checkdependencies.py b/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/checkdependencies.py index e227c553..3e7d0e50 100644 --- a/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/checkdependencies.py +++ b/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/checkdependencies.py @@ -1,4 +1,5 @@ import subprocess +from importlib.util import find_spec from shutil import which from os.path import exists @@ -49,11 +50,10 @@ def _has_ytdlp(): for path in ("/usr/bin/yt-dlp", "/usr/local/bin/yt-dlp"): if exists(path): return True - # Fallback: python module + # Fallback: python module (found without importing it: slow) try: - from yt_dlp import YoutubeDL # noqa: F401 - return True - except ImportError: + return find_spec("yt_dlp") is not None + except Exception: return False @@ -72,12 +72,6 @@ def check_requirements(logger=None): if which("streamlink") is None: missing.append("streamlink") - # requests (Python module) - try: - import requests # noqa: F401 - except ImportError: - missing.append("requests") - if logger and missing: logger.warning("Missing optional components: " + ", ".join(missing)) return missing From 2fc027ade0d2f991c2c3838dcfb8feee145c1d93 Mon Sep 17 00:00:00 2001 From: pQu4k3r Date: Thu, 8 Oct 2026 15:22:51 +0000 Subject: [PATCH 6/8] Installer: install from OwnerPlugins, Python 3 and yt-dlp - Download the plugin from OwnerPlugins/WorldCam instead of the upstream repository - Detect Python with python3: images without a 'python' binary took the Python 2 branch and never installed yt-dlp - Install yt-dlp from the feed, or with pip when the feed has none, and report whether it works - Refresh the package lists once instead of before every package - Fix the streamlinkproxy package name, no Python 2 packages - The installer does not restart the receiver: say so --- installer.sh | 116 ++++++++++++++++++++++++++++----------------------- 1 file changed, 64 insertions(+), 52 deletions(-) diff --git a/installer.sh b/installer.sh index f2231f55..25de52f1 100644 --- a/installer.sh +++ b/installer.sh @@ -41,60 +41,52 @@ detect_os cleanup mkdir -p "$TMPPATH" -if ! command -v wget >/dev/null 2>&1; then - echo "Installing wget..." +# Refresh the package lists once +FEED_UPDATED=0 +update_feeds() { + [ "$FEED_UPDATED" = "1" ] && return + echo "Updating package lists..." case "$OSTYPE" in - "DreamOs") - apt-get update && apt-get install -y wget || { echo "Failed to install wget"; exit 1; } - ;; - "OE") - opkg update && opkg install wget || { echo "Failed to install wget"; exit 1; } - ;; - *) - echo "Unsupported OS type. Cannot install wget." - exit 1 - ;; + "DreamOs") apt-get update >/dev/null 2>&1 ;; + "OE") opkg update >/dev/null 2>&1 ;; esac -fi - -if python --version 2>&1 | grep -q '^Python 3\.'; then - echo "Python3 image detected" - PYTHON="PY3" - Packagesix="python3-six" - Packagerequests="python3-requests" - PY="python3" -else - echo "Python2 image detected" - PYTHON="PY2" - Packagerequests="python-requests" - Packagesix="python-six" - PY="python" -fi + FEED_UPDATED=1 +} install_pkg() { - local pkg=$1 - if [ -z "$STATUS" ] || ! grep -qs "Package: $pkg" "$STATUS" 2>/dev/null; then - echo "Installing $pkg..." - case "$OSTYPE" in - "DreamOs") - apt-get update && apt-get install -y "$pkg" || { echo "Could not install $pkg, continuing anyway..."; } - ;; - "OE") - opkg update && opkg install "$pkg" || { echo "Could not install $pkg, continuing anyway..."; } - ;; - *) - echo "Cannot install $pkg on unknown OS type, continuing..." - ;; - esac - else + pkg=$1 + if [ -n "$STATUS" ] && grep -qx "Package: $pkg" "$STATUS" 2>/dev/null; then echo "$pkg already installed" + return 0 fi + update_feeds + echo "Installing $pkg..." + case "$OSTYPE" in + "DreamOs") + apt-get install -y "$pkg" >/dev/null 2>&1 && return 0 ;; + "OE") + opkg install "$pkg" >/dev/null 2>&1 && return 0 ;; + *) + echo "Cannot install $pkg on unknown OS type" + return 1 ;; + esac + echo "Could not install $pkg, continuing anyway..." + return 1 } -if [ "$PYTHON" = "PY3" ]; then - install_pkg "$Packagesix" +if ! command -v wget >/dev/null 2>&1; then + install_pkg wget + command -v wget >/dev/null 2>&1 || { echo "wget is required"; exit 1; } fi -install_pkg "$Packagerequests" + +# WorldCam is Python 3 only (images may have python3 without 'python') +if ! command -v python3 >/dev/null 2>&1; then + echo "ERROR: WorldCam requires a Python 3 image" + exit 1 +fi +echo "Python: $(python3 --version 2>&1)" + +install_pkg python3-six if [ "$OSTYPE" = "OE" ]; then echo "Installing additional multimedia packages..." @@ -103,8 +95,30 @@ if [ "$OSTYPE" = "OE" ]; then done fi +# yt-dlp: needed for YouTube webcams +ytdlp_ok() { + command -v yt-dlp >/dev/null 2>&1 || python3 -c "import yt_dlp" >/dev/null 2>&1 +} + +if ! ytdlp_ok; then + install_pkg python3-yt-dlp +fi +if ! ytdlp_ok; then + echo "yt-dlp not in the feed, installing it with pip..." + command -v pip3 >/dev/null 2>&1 || install_pkg python3-pip + if command -v pip3 >/dev/null 2>&1; then + pip3 install -U yt-dlp >/dev/null 2>&1 || \ + pip3 install -U --break-system-packages yt-dlp >/dev/null 2>&1 + fi +fi +if ytdlp_ok; then + echo "yt-dlp OK: $(yt-dlp --version 2>/dev/null || python3 -m yt_dlp --version 2>/dev/null)" +else + echo "WARNING: yt-dlp could not be installed: YouTube webcams will not play" +fi + echo "Downloading WorldCam..." -wget --no-check-certificate 'https://github.com/Belfagor2005/WorldCam/archive/refs/heads/main.tar.gz' -O "$FILEPATH" +wget --no-check-certificate "https://github.com/OwnerPlugins/WorldCam/archive/refs/heads/main.tar.gz" -O "$FILEPATH" if [ $? -ne 0 ]; then echo "Failed to download WorldCam package!" cleanup @@ -157,15 +171,13 @@ if [ "$OSTYPE" = "OE" ]; then for pkg in \ streamlink \ enigma2-plugin-extensions-streamlinkwrapper \ - enigma2-plugin-extensions-streamlinkproxyv \ + enigma2-plugin-extensions-streamlinkproxy \ enigma2-plugin-extensions-ytdlpwrapper \ enigma2-plugin-extensions-ytdlwrapper \ python3-re \ gstreamer1.0-plugins-bad \ gstreamer1.0-plugins-ugly \ - gstreamer1.0-libav \ - ${PY}-yt-dlp \ - ${PY}-youtube-dl; do + gstreamer1.0-libav; do install_pkg "$pkg" done fi @@ -203,7 +215,7 @@ fi [ -z "$distro_value" ] && distro_value="Unknown" [ -z "$distro_version" ] && distro_version="Unknown" -python_vers=$(python --version 2>&1) +python_vers=$(python3 --version 2>&1) cat < Date: Thu, 8 Oct 2026 15:22:59 +0000 Subject: [PATCH 7/8] Remove broken and unused modules - translate_utils.py (copied from Foreca) imported DEBUG, HEADERS and SYSTEM_DIR, which do not exist here: any import would fail - client.py was only used by VideoURLHelper, which nothing uses (the scraper extracts the stream URLs) --- .../Plugins/Extensions/WorldCam/client.py | 523 --------------- .../Extensions/WorldCam/translate_utils.py | 621 ------------------ .../Plugins/Extensions/WorldCam/utils.py | 47 +- 3 files changed, 1 insertion(+), 1190 deletions(-) delete mode 100644 usr/lib/enigma2/python/Plugins/Extensions/WorldCam/client.py delete mode 100644 usr/lib/enigma2/python/Plugins/Extensions/WorldCam/translate_utils.py diff --git a/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/client.py b/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/client.py deleted file mode 100644 index 9ec9d0e0..00000000 --- a/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/client.py +++ /dev/null @@ -1,523 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- - -from random import randrange, choice -from re import sub, compile, findall, S, M -import time -from urllib import error as urllib_error, request as urllib_request -from html import unescape as html_unescape -from urllib.parse import quote_plus, urlparse -import http.cookiejar as cookielib -from urllib.request import ProxyHandler - -''' - Tulip routine libraries, based on lambda's lamlib - Author Twilight0 - - License summary below, for more details please read license.txt file - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 2 of the License, or - (at your option) any later version. - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - You should have received a copy of the GNU General Public License - along with this program. If not, see . -''' - - -try: - import requests - requester = requests.get -except ImportError: - requester = None - - -def request( - url, - close=True, - redirect=True, - error=False, - proxy=None, - post=None, - headers=None, - mobile=False, - limit=None, - referer=None, - cookie=None, - output='', - timeout='30'): - handlers = [] - - if proxy is not None: - handlers += [ProxyHandler({'http': '{0}'.format(proxy)}), - urllib_request.HTTPHandler] - opener = urllib_request.build_opener(*handlers) - urllib_request.install_opener(opener) - - if output == 'cookie' or output == 'extended' or close is not True: - cookies = cookielib.LWPCookieJar() - handlers += [urllib_request.HTTPHandler(), - urllib_request.HTTPSHandler(), - urllib_request.HTTPCookieProcessor(cookies)] - opener = urllib_request.build_opener(*handlers) - urllib_request.install_opener(opener) - - try: - - import ssl - ssl_context = ssl.create_default_context() - ssl_context.check_hostname = False - ssl_context.verify_mode = ssl.CERT_NONE - handlers += [urllib_request.HTTPSHandler(context=ssl_context)] - opener = urllib_request.build_opener(*handlers) - urllib_request.install_opener(opener) - - except BaseException: - pass - - try: - headers.update(headers) - except BaseException: - headers = {} - - if 'User-Agent' in headers: - pass - elif mobile is not True: - headers['User-Agent'] = 'Apple-iPhone/701.341' - - if 'Referer' in headers: - pass - elif referer is None: - headers['Referer'] = '%s://%s/' % (urlparse(url).scheme, - urlparse(url).netloc) - else: - headers['Referer'] = referer - - if 'Accept-Language' not in headers: - headers['Accept-Language'] = 'en-US' - - if 'Cookie' in headers: - pass - elif cookie is not None: - headers['Cookie'] = cookie - - if redirect is False: - - class NoRedirection(urllib_error.HTTPError): - - def http_response(self, request, response): - return response - - opener = urllib_request.build_opener(NoRedirection) - urllib_request.install_opener(opener) - - try: - del headers['Referer'] - except BaseException: - pass - - req = urllib_request.Request(url, data=post, headers=headers) - - try: - response = urllib_request.urlopen(req, timeout=int(timeout)) - - except urllib_error.HTTPError as response: - - if response.code == 503: - print('error 503 ') - elif error is False: - return - - if output == 'cookie': - - try: - result = '; '.join(['%s=%s' % (i.name, i.value) for i in cookies]) - except BaseException: - pass - - elif output == 'response': - - if limit == '0': - result = (str(response.code), response.read(224 * 1024)) - elif limit is not None: - result = (str(response.code), response.read(int(limit) * 1024)) - else: - result = (str(response.code), response.read(5242880)) - - elif output == 'chunk': - - try: - content = int(response.headers['Content-Length']) - except BaseException: - content = (2049 * 1024) - - if content < (2048 * 1024): - return - result = response.read(16 * 1024) - - elif output == 'extended': - - try: - cookie = '; '.join(['%s=%s' % (i.name, i.value) for i in cookies]) - except BaseException: - pass - - content = response.headers - result = response.read(5242880) - return result, headers, content, cookie - - elif output == 'geturl': - result = response.geturl() - - elif output == 'headers': - content = response.headers - return content - - else: - if limit == '0': - result = response.read(224 * 1024) - elif limit is not None: - result = response.read(int(limit) * 1024) - else: - result = response.read(5242880) - - if close is True: - response.close() - return result - - -def retriever(source, destination, *args): - - class Opener(urllib_request.URLopener): - version = randomagent() - - Opener().retrieve(source, destination, *args) - - -def parseDOM(html, name=u"", attrs=None, ret=False): - - if attrs is None: - attrs = {} - - if isinstance(name, str): # Should be handled - try: - name = name # .decode("utf-8") - except BaseException: - pass - - if isinstance(html, str): - try: - html = [html.decode("utf-8")] # Replace with chardet thingy - except BaseException: - html = [html] - elif isinstance(html, bytes): - html = [html.decode("utf-8", "replace")] - elif not isinstance(html, list): - return u"" - - if not name.strip(): - return u"" - - ret_lst = [] - for item in html: - temp_item = compile('(<[^>]*?\n[^>]*?>)').findall(item) - for match in temp_item: - item = item.replace(match, match.replace("\n", " ")) - - lst = _getDOMElements(item, name, attrs) - - if isinstance(ret, str): - lst2 = [] - for match in lst: - lst2 += _getDOMAttributes(match, name, ret) - lst = lst2 - else: - lst2 = [] - for match in lst: - temp = _getDOMContent(item, name, match, ret).strip() - item = item[item.find(temp, item.find(match)) + len(temp):] - lst2.append(temp) - lst = lst2 - ret_lst += lst - - return ret_lst - - -def _getDOMContent(html, name, match, ret): # Cleanup - - endstr = u"" - - start = html.find(match) - end = html.find(endstr, start) - pos = html.find("<" + name, start + 1) - - while pos < end and pos != -1: # Ignore too early return - tend = html.find(endstr, end + len(endstr)) - if tend != -1: - end = tend - pos = html.find("<" + name, pos + 1) - - if start == -1 and end == -1: - result = u"" - elif start > -1 and end > -1: - result = html[start + len(match):end] - elif end > -1: - result = html[:end] - elif start > -1: - result = html[start + len(match):] - - if ret: - endstr = html[end:html.find(">", html.find(endstr)) + 1] - result = match + result + endstr - - return result - - -def _getDOMAttributes(match, name, ret): - - lst = compile( - '<' + - name + - '.*?' + - ret + - '=([\'"].[^>]*?[\'"])>', - M | S).findall(match) - if len(lst) == 0: - lst = compile( - '<' + name + '.*?' + ret + '=(.[^>]*?)>', - M | S).findall(match) - ret = [] - for tmp in lst: - cont_char = tmp[0] - if cont_char in "'\"": - - # Limit down to next variable. - if tmp.find('=' + cont_char, tmp.find(cont_char, 1)) > -1: - tmp = tmp[:tmp.find('=' + cont_char, tmp.find(cont_char, 1))] - - # Limit to the last quotation mark - if tmp.rfind(cont_char, 1) > -1: - tmp = tmp[1:tmp.rfind(cont_char)] - else: - - if tmp.find(" ") > 0: - tmp = tmp[:tmp.find(" ")] - elif tmp.find("/") > 0: - tmp = tmp[:tmp.find("/")] - elif tmp.find(">") > 0: - tmp = tmp[:tmp.find(">")] - - ret.append(tmp.strip()) - - return ret - - -def _getDOMElements(item, name, attrs): - - lst = [] - for key in attrs: - lst2 = compile( - '(<' + name + '[^>]*?(?:' + key + '=[\'"]' + attrs[key] + '[\'"].*?>))', - M | S).findall(item) - if len(lst2) == 0 and attrs[key].find( - " ") == -1: # Try matching without quotation marks - lst2 = compile( - '(<' + name + '[^>]*?(?:' + key + '=' + attrs[key] + '.*?>))', - M | S).findall(item) - - if len(lst) == 0: - lst = lst2 - lst2 = [] - else: - - test = range(len(lst)) - test.reverse() - for i in test: # Delete anything missing from the next list. - if lst[i] not in lst2: - del (lst[i]) - - if len(lst) == 0 and attrs == {}: - lst = compile('(<' + name + '>)', M | S).findall(item) - if len(lst) == 0: - lst = compile('(<' + name + ' .*?>)', M | S).findall(item) - - return lst - - -def replaceHTMLCodes(txt): - - txt = sub("(&#[0-9]+)([^;^0-9]+)", "\\1;\\2", txt) - txt = html_unescape(txt) - txt = txt.replace(""", "\"") - txt = txt.replace("&", "&") - txt = txt.replace("&", "&") - txt = txt.replace(" ", "") - return txt - - -def randomagent(): - - BR_VERS = [['%s.0' % i for i in range(18, - 43)], - ['37.0.2062.103', - '37.0.2062.120', - '37.0.2062.124', - '38.0.2125.101', - '38.0.2125.104', - '38.0.2125.111', - '39.0.2171.71', - '39.0.2171.95', - '39.0.2171.99', - '40.0.2214.93', - '40.0.2214.111', - '40.0.2214.115', - '42.0.2311.90', - '42.0.2311.135', - '42.0.2311.152', - '43.0.2357.81', - '43.0.2357.124', - '44.0.2403.155', - '44.0.2403.157', - '45.0.2454.101', - '45.0.2454.85', - '46.0.2490.71', - '46.0.2490.80', - '46.0.2490.86', - '47.0.2526.73', - '47.0.2526.80'], - ['11.0']] - - WIN_VERS = [ - 'Windows NT 10.0', - 'Windows NT 7.0', - 'Windows NT 6.3', - 'Windows NT 6.2', - 'Windows NT 6.1', - 'Windows NT 6.0', - 'Windows NT 5.1', - 'Windows NT 5.0'] - - FEATURES = ['; WOW64', '; Win64; IA64', '; Win64; x64', ''] - - RAND_UAS = [ - 'Mozilla/5.0 ({win_ver}{feature}; rv:{br_ver}) Gecko/20100101 Firefox/{br_ver}', - 'Mozilla/5.0 ({win_ver}{feature}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{br_ver} Safari/537.36', - 'Mozilla/5.0 ({win_ver}{feature}; Trident/7.0; rv:{br_ver}) like Gecko'] - - index = randrange(len(RAND_UAS)) - - return RAND_UAS[index].format( - win_ver=choice(WIN_VERS), - feature=choice(FEATURES), - br_ver=choice( - BR_VERS[index])) - - -def agent(): - return 'Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; rv:11.0) like Gecko' - - -def mobile_agent(): - return 'Mozilla/5.0 (Android 4.4; Mobile; rv:18.0) Gecko/18.0 Firefox/18.0' - - -def ios_agent(): - return 'Mozilla/5.0 (iPhone; CPU iPhone OS 6_0 like Mac OS X) AppleWebKit/536.26 (KHTML, like Gecko) Version/6.0 Mobile/10A5376e Safari/8536.25' - - -def spoofer(_agent=True, age_str=randomagent(), referer=False, ref_str=''): - - if _agent and referer: - return '|User-Agent=' + \ - quote_plus(age_str) + '&Referer=' + quote_plus(ref_str) - elif _agent: - return '|User-Agent=' + quote_plus(age_str) - elif referer: - return '|Referer=' + quote_plus(ref_str) - - -def cfcookie(netloc, ua, timeout): - try: - headers = {'User-Agent': ua} - - req = urllib_request.Request(netloc, headers=headers) - - try: - urllib_request.urlopen(req, timeout=int(timeout)) - except urllib_request.HTTPError as response: - result = response.read(5242880) - - jschl = findall('name="jschl_vc" value="(.+?)"/>', result)[0] - - init = findall( - r'setTimeout\(function\(\){\s*.*?.*:(.*?)};', result)[-1] - - builder = findall(r"challenge-form\'\);\s*(.*)a.v", result)[0] - - decryptVal = parseJSString(init) - - lines = builder.split(';') - - for line in lines: - - if len(line) > 0 and '=' in line: - - sections = line.split('=') - line_val = parseJSString(sections[1]) - decryptVal = int( - eval(str(decryptVal) + sections[0][-1] + str(line_val))) - - answer = decryptVal + len(urlparse(netloc).netloc) - - query = '%s/cdn-cgi/l/chk_jschl?jschl_vc=%s&jschl_answer=%s' % ( - netloc, jschl, answer) - - if 'type="hidden" name="pass"' in result: - passval = findall('name="pass" value="(.*?)"', result)[0] - query = '%s/cdn-cgi/l/chk_jschl?pass=%s&jschl_vc=%s&jschl_answer=%s' % ( - netloc, quote_plus(passval), jschl, answer) - time.sleep(5) - - cookies = cookielib.LWPCookieJar() - handlers = [ - urllib_request.HTTPHandler(), - urllib_request.HTTPSHandler(), - urllib_request.HTTPCookieProcessor(cookies)] - opener = urllib_request.build_opener(*handlers) - urllib_request.install_opener(opener) - - try: - request = urllib_request.Request(query, headers=headers) - urllib_request.urlopen(request, timeout=int(timeout)) - except BaseException: - pass - - cookie = '; '.join(['%s=%s' % (i.name, i.value) for i in cookies]) - - return cookie - except BaseException: - pass - - -def parseJSString(s): - try: - offset = 1 if s[0] == '+' else 0 - val = int( - eval( - s.replace( - '!+[]', - '1').replace( - '!![]', - '1').replace( - '[]', - '0').replace( - '(', - 'str(')[ - offset:])) - return val - except BaseException: - pass diff --git a/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/translate_utils.py b/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/translate_utils.py deleted file mode 100644 index 06b9bc1c..00000000 --- a/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/translate_utils.py +++ /dev/null @@ -1,621 +0,0 @@ -#!/usr/bin/env python -# -*- coding: UTF-8 -*- -# Copyright (c) @Lululla 2026 -# Google Translate API for Foreca One Weather Plugin - -import hashlib -import json -import socket -import time -from json import JSONDecodeError, loads -from os import makedirs, remove -from os.path import dirname, exists, join - -from urllib.error import HTTPError, URLError -from urllib.parse import urlencode -from urllib.request import Request, urlopen - -from Components.config import config - -from . import DEBUG, HEADERS, SYSTEM_DIR - -# ============================================================ -# CUSTOM CONFIGURATION -# ============================================================ - -# Translation API URL (can be changed if needed) -TRANSLATE_API_URL = "https://translate.googleapis.com/translate_a/single" - -# Timeout for HTTP requests (in seconds) -REQUEST_TIMEOUT = 8 - -# Character limit for batch translation (to avoid errors) -MAX_CHARS_PER_REQUEST = 2000 - -# Local cache to avoid repetitive requests -CACHE_FILE = join(SYSTEM_DIR, "translation_cache.json") -_translation_cache = {} -_cache_hits = 0 -_cache_misses = 0 -_cache_dirty = False # flag to know if there are changes to save - -# Enable logging -ENABLE_LOGGING = True - - -# ============================================================ -# CACHE PERSISTENCE -# ============================================================ - - -def _ensure_cache_dir(): - """Create the directory for the cache file if it does not exist.""" - cache_dir = dirname(CACHE_FILE) - if not exists(cache_dir): - try: - makedirs(cache_dir) - except Exception as e: - _log(f"Error creating cache directory: {e}") - - -def load_cache_from_disk(): - """Load the cache from the JSON file at startup.""" - global _translation_cache - _ensure_cache_dir() - if exists(CACHE_FILE): - try: - with open(CACHE_FILE, 'r', encoding='utf-8') as f: - _translation_cache = json.load(f) - _log(f"Cache loaded from disk ({len(_translation_cache)} entries)") - except Exception as e: - _log(f"Error loading cache: {e}") - _translation_cache = {} - else: - _translation_cache = {} - - -def save_cache_to_disk(): - """Save the cache to disk if there are changes.""" - global _cache_dirty - if not _cache_dirty: - return - _ensure_cache_dir() - try: - with open(CACHE_FILE, 'w', encoding='utf-8') as f: - json.dump(_translation_cache, f, ensure_ascii=False, indent=2) - _log(f"Cache saved to disk ({len(_translation_cache)} entries)") - _cache_dirty = False - except Exception as e: - _log(f"Error saving cache: {e}") - - -# ============================================================ -# UTILITY FUNCTIONS -# ============================================================ - - -def _log(message): - """Custom logging""" - if ENABLE_LOGGING and DEBUG: - timestamp = time.time() - print(f"[Foreca-1-Translate][{timestamp:.2f}] {message}") - - -def _get_system_language(): - """Get system language in short format""" - try: - lang = config.misc.language.value - return lang.split('_')[0].lower() - except Exception: - lang = config.osd.language.value - return lang.split('_')[0].lower() - -# print("System Language:", _get_system_language()) - - -def _to_unicode(text): - """Convert any input into a Unicode string.""" - if text is None: - return "" - - if isinstance(text, str): - return text - - if isinstance(text, bytes): - try: - return text.decode("utf-8", errors="ignore") - except Exception: - return str(text, errors="ignore") - - try: - return str(text) - except Exception: - return "" - - -def _clean_whitespace(text): - text_unicode = _to_unicode(text) - while " " in text_unicode: - text_unicode = text_unicode.replace(" ", " ") - return text_unicode.strip() - - -# ============================================================ -# ARABIC LANGUAGE DETECTION -# ============================================================ - - -def _is_arabic_char(char): - """Check if a character is Arabic""" - try: - code = ord(char) - # Unicode ranges for Arabic characters - return ( - 0x0600 <= code <= 0x06FF or - 0x0750 <= code <= 0x077F or - 0x08A0 <= code <= 0x08FF or - 0xFB50 <= code <= 0xFDFF or - 0xFE70 <= code <= 0xFEFF - ) - except Exception: - return False - - -def _is_text_arabic(text): - """ - Determines whether a text is predominantly Arabic. - Returns True if more than 60% of alphabetic characters are Arabic. - """ - text_unicode = _to_unicode(text) - if not text_unicode: - return False - - total_letters = 0 - arabic_letters = 0 - - for char in text_unicode: - # Consider only alphabetic characters (exclude spaces, numbers, - # punctuation) - if char.isalpha(): - total_letters += 1 - if _is_arabic_char(char): - arabic_letters += 1 - - # If there are no letters, it's not Arabic - if total_letters == 0: - return False - - # Calculate percentage - arabic_ratio = float(arabic_letters) / float(total_letters) - - # Threshold to consider the text Arabic (60%) - return arabic_ratio >= 0.6 - - -# ============================================================ -# CACHE AND PERFORMANCE -# ============================================================ - - -def _get_cache_key(text, target_lang): - """Generate a unique cache key using MD5 (stable across runs)""" - # Use MD5 because it is fast and deterministic - key_string = f"{target_lang}:{text}".encode('utf-8') - return hashlib.md5(key_string).hexdigest() - - -def _cache_translation(text, target_lang, translated): - """Store a translation in the cache and save immediately to disk.""" - global _cache_dirty - cache_key = _get_cache_key(text, target_lang) - _translation_cache[cache_key] = translated - _cache_dirty = True - save_cache_to_disk() - return translated - - -def _get_cached_translation(text, target_lang): - """Retrieve a translation from the cache""" - global _cache_hits, _cache_misses - cache_key = _get_cache_key(text, target_lang) - - if cache_key in _translation_cache: - _cache_hits += 1 - return _translation_cache[cache_key] - - _cache_misses += 1 - return None - - -def get_cache_stats(): - """Return cache statistics""" - return { - 'hits': _cache_hits, - 'misses': _cache_misses, - 'size': len(_translation_cache), - 'hit_rate': _cache_hits / max(1, _cache_hits + _cache_misses) - } - - -def clear_cache(): - """Clear the translation cache and delete the file""" - global _cache_hits, _cache_misses, _cache_dirty - _translation_cache.clear() - _cache_hits = 0 - _cache_misses = 0 - _cache_dirty = False - if exists(CACHE_FILE): - try: - remove(CACHE_FILE) - except Exception as e: - _log(f"Error deleting cache file: {e}") - _log("Cache cleared") - - -# ============================================================ -# MAIN TRANSLATION FUNCTION -# ============================================================ - -def translate_text(text, target_lang=None, use_cache=True): - """ - Translates text using the Google Translate API. - - Args: - text (str): Text to translate - target_lang (str): Target language (e.g. 'it', 'en', 'de') - If None, uses the system language - use_cache (bool): Whether to use the local cache - - Returns: - str: Translated text or original text in case of error - """ - start_time = time.time() - _log(f"Target language: '{target_lang}'") - # Input validation - if not text: - return "" - - # Convert to Unicode - text_unicode = _to_unicode(text) - - # Use system language if not specified - if target_lang is None: - target_lang = _get_system_language() - - # Normalize language (ensure lowercase) - target_lang = target_lang.lower() - - # If the text is already Arabic, do not translate it - if _is_text_arabic(text_unicode): - _log(f"Arabic text detected, not translated: '{text_unicode[:50]}...'") - return text_unicode - - # Check cache if enabled - if use_cache: - cached = _get_cached_translation(text_unicode, target_lang) - if cached is not None: - _log(f"Cache HIT: '{text_unicode[:30]}...' -> '{cached[:30]}...'") - return cached - - # Error handling for overly long texts - if len(text_unicode) > MAX_CHARS_PER_REQUEST: - _log("Text too long (" + - str(len(text_unicode)) + - " chars), truncated to " + - str(MAX_CHARS_PER_REQUEST)) - text_unicode = text_unicode[:MAX_CHARS_PER_REQUEST] - - # Prepare the request - params = { - "client": "gtx", # Fake client to bypass restrictions - "sl": "auto", # Automatic source language - "tl": target_lang, # Target language - "dt": "t", # Response type: translation only - "q": text_unicode, # Text to translate - } - - try: - # Build the URL - query_string = urlencode(params) - url = f"{TRANSLATE_API_URL}?{query_string}" - - _log(f"Translating: '{text_unicode[:40]}...' -> {target_lang}") - - # Set timeout to avoid blocking - socket.setdefaulttimeout(REQUEST_TIMEOUT) - - # Perform the request - req = Request(url) - for key, value in HEADERS.items(): - req.add_header(key, value) - response = urlopen(req, timeout=REQUEST_TIMEOUT) - raw_data = response.read() - - # Decode the response - if isinstance(raw_data, bytes): - raw_data = raw_data.decode('utf-8') - - # Parse JSON response - data = loads(raw_data) - - # Extract the translation from the JSON structure - translated_text = "" - if isinstance(data, list) and data: - # Typical structure: [[[translation, original], ...], ...] - for item in data[0]: - if item and isinstance(item, list) and item[0]: - translated_text += item[0] - - # Clean the result - if translated_text: - translated_text = _clean_whitespace(translated_text) - - # Save to cache - if use_cache: - _cache_translation(text_unicode, target_lang, translated_text) - - elapsed = time.time() - start_time - _log(( - f"Translation completed in {elapsed:.2f}s: '{text_unicode[:30]}...' -> " - f"'{translated_text[:30]}...'" - )) - - return translated_text - else: - _log(f"Empty API response for: '{text_unicode[:30]}...'") - return text_unicode - - except socket.timeout: - _log(f"TIMEOUT during translation: '{text_unicode[:30]}...'") - return text_unicode - - except (URLError, HTTPError) as e: - _log(f"HTTP error {getattr(e, 'code', 'N/A')}: {str(e)}") - return text_unicode - - except JSONDecodeError as e: - _log(f"JSON error: {str(e)}") - return text_unicode - - except Exception as e: - error_type = type(e).__name__ - _log(f"Error {error_type}: {str(e)}") - return text_unicode - - finally: - # Restore default timeout - socket.setdefaulttimeout(None) - - -# ============================================================ -# AUXILIARY FUNCTIONS FOR SPECIAL CASES -# ============================================================ - - -def translate_batch(texts, target_lang=None, use_cache=True): - """ - Translates a list of texts in batch. - Optimized to reduce the number of HTTP requests. - - Args: - texts (list): List of texts to translate - target_lang (str): Target language - use_cache (bool): Use cache - - Returns: - list: List of translated texts - """ - if not texts: - return [] - - # Use system language if not specified - if target_lang is None: - target_lang = _get_system_language() - results = [] - batch_text = [] - batch_indices = [] - - for i, text in enumerate(texts): - text_unicode = _to_unicode(text) - - # Check cache - if use_cache: - cached = _get_cached_translation(text_unicode, target_lang) - if cached is not None: - results.append(cached) - continue - - # If the text is Arabic, do not translate it - if _is_text_arabic(text_unicode): - results.append(text_unicode) - continue - - # Add to batch - batch_text.append(text_unicode) - batch_indices.append(i) - results.append(None) # Placeholder - - # If there are texts to translate in batch - if batch_text: - try: - # Join texts with a special separator - separator = u" ||| " - combined_text = separator.join(batch_text) - - # Translate the batch - combined_translated = translate_text( - combined_text, - target_lang, - use_cache=False # Do not use cache for batch - ) - - # Split results - if separator in combined_translated: - translated_parts = combined_translated.split(separator) - else: - # Fallback: split by approximate number - translated_parts = [combined_translated] * len(batch_text) - - # Update results - for idx, translated in zip(batch_indices, translated_parts): - results[idx] = translated - - # Save to cache - if use_cache and idx < len(texts): - text_unicode = _to_unicode(texts[idx]) - _cache_translation(text_unicode, target_lang, translated) - - except Exception as e: - _log(f"Batch translation error: {str(e)}") - # Fallback: translate individually - for idx in batch_indices: - if results[idx] is None and idx < len(texts): - results[idx] = translate_text( - texts[idx], target_lang, use_cache) - - # Replace None with original text - for i in range(len(results)): - if results[i] is None: - results[i] = _to_unicode(texts[i]) - - return results - - -def safe_translate(text, fallback=None, **kwargs): - """ - Safe version of translate_text that always returns a valid string. - Args: - text (str): Text to translate - fallback (str): Fallback text if translation fails - **kwargs: Additional arguments for translate_text - Returns: - str: Translated text, fallback or original - """ - try: - translated = translate_text(text, **kwargs) - if translated and translated.strip(): - return translated - - # If translation is empty, use fallback - if fallback is not None: - return _to_unicode(fallback) - - return _to_unicode(text) - - except Exception as e: - _log(f"Error in safe_translate: {str(e)}") - if fallback is not None: - return _to_unicode(fallback) - return _to_unicode(text) - - -def trans(text, target_lang=None): - """ - Simplified translation function for single strings. - Uses cache and translate_text. - """ - if target_lang is None: - target_lang = _get_system_language() - target_lang = target_lang.lower() - - if not text or not isinstance(text, str): - return text or "" - - text = text.strip() - if not text: - return "" - - # Do not translate Arabic text - if _is_text_arabic(text): - return text - - # Check cache using full key (language + hash) - cached = _get_cached_translation(text, target_lang) - if cached is not None: - return cached - - # Translate (translate_text already handles internal caching if - # use_cache=True) - translated = translate_text(text, target_lang, use_cache=True) - if translated and translated != text: - return translated - return text - - -def translate_batch_strings(texts, target_lang=None): - """ - High-level batch translation for a list of strings. - """ - if not texts: - return [] - valid_texts = [str(t).strip() for t in texts if t and str(t).strip()] - if not valid_texts: - return [] - - # Use the existing cache via translate_batch - return translate_batch(valid_texts, target_lang, use_cache=True) - - -# ============================================================ -# TEST FUNCTION (for debugging) -# ============================================================ - - -def test_translation(): - """Test function to verify functionality""" - test_cases = [ - ("Hello world", "it", "Ciao mondo"), - ("Weather forecast", "es", "Pronóstico del tiempo"), - ("Temperature", "fr", "Température"), - ] - if DEBUG: - print("=" * 60) - print("Foreca One TRANSLATION TEST") - print("=" * 60) - - all_passed = True - - for original, lang, expected in test_cases: - result = translate_text(original, lang) - - if result and result.lower() == expected.lower(): - status = "✓ PASS" - else: - status = "✗ FAIL" - all_passed = False - if DEBUG: - print( - f"{status}: '{original}' -> '{result}' (expected: '{expected}')") - if DEBUG: - print("=" * 60) - stats = get_cache_stats() - print(( - f"Cache statistics: {stats['hits']} hits, {stats['misses']} misses, " - f"rate: {stats['hit_rate']:.1%}" - )) - print("=" * 60) - - return all_passed - - -# ============================================================ -# INITIALIZATION -# ============================================================ - -# Load cache at module startup -load_cache_from_disk() - -if __name__ == "__main__": - # Test mode when run directly - if DEBUG: - print("Google Translate API for Foreca") - print("Enhanced custom version") - - if test_translation(): - print("✓ All tests passed!") - else: - print("✗ Some tests failed") -else: - # Imported as a module - _log("Foreca One translation module loaded") - _log(f"System language: {_get_system_language()}") diff --git a/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/utils.py b/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/utils.py index dd265d39..32d6078e 100644 --- a/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/utils.py +++ b/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/utils.py @@ -20,7 +20,7 @@ except ImportError: from Components.AVSwitch import eAVControl as AVSwitch -from . import _, BASE_URL +from . import _ from . import checkdependencies """ @@ -835,51 +835,6 @@ def get_flag_path(country_code=None): # flag_path = get_flag_path(country_code) # Returns .../countries/de.png -class VideoURLHelper: - - def __init__(self): - self.logger = Logger() - - def get_video_url(self, url): - """ - Extracts the video URL from a webcam page, safely handling errors. - """ - self.logger.info("Fetching video URL for: " + url) - headers = {"User-Agent": "Mozilla/5.0", "Referer": BASE_URL} - - try: - from . import client - content = client.request(url, headers=headers) - if not content: - self.logger.warning("Empty content received") - return None - - if isinstance(content, bytes): - content = content.decode("utf-8", errors="ignore") - - # Search for HLS stream - hls_match = search(r"source:\s*'livee\.m3u8\?a=([^']+)'", content) - if hls_match: - video_id = hls_match.group(1) - final_url = "https://hd-auth.skylinewebcams.com/live.m3u8?a=" + video_id - self.logger.info("Found HLS stream: " + final_url) - return final_url - - # Search for YouTube video - yt_match = search(r"videoId:\s*'([^']+)'", content) - if yt_match: - video_id = yt_match.group(1) - yt_url = "https://www.youtube.com/watch?v=" + video_id - self.logger.info("Found YouTube video: " + yt_url) - return yt_url - - except Exception as e: - self.logger.error("Error getting video URL: " + str(e)) - - self.logger.warning("No video URL found") - return None - - class AspectManager: """Manages aspect ratio settings for the plugin""" From d0601b8eec97e9226da0e7a0832f07b2b8424671 Mon Sep 17 00:00:00 2001 From: pQu4k3r Date: Thu, 8 Oct 2026 16:37:23 +0000 Subject: [PATCH 8/8] Bump version to 7.1 Package, plugin and installer metadata updated to 7.1, with the changelog of the fixes in this release. --- CONTROL/control | 2 +- installer.sh | 4 ++-- .../enigma2/python/Plugins/Extensions/WorldCam/__init__.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CONTROL/control b/CONTROL/control index 7aa1da63..9c16dffd 100644 --- a/CONTROL/control +++ b/CONTROL/control @@ -1,5 +1,5 @@ Package: enigma2-plugin-extensions-worldcam -Version: 7.0 +Version: 7.1 Section: extra Priority: optional Architecture: all diff --git a/installer.sh b/installer.sh index 25de52f1..1299e64e 100644 --- a/installer.sh +++ b/installer.sh @@ -1,7 +1,7 @@ #!/bin/bash -version='7.0' -changelog='Bump version to 6.9 and refactor YouTube playback to be non-blocking. player.py now normalizes YouTube URLs, uses twisted.deferToThread to call youtube_helper.resolve_youtube, and handles stale requests and errors. Removed embedded yt-dlp probing/format logic. Added helpers in utils.py (is_youtube_url, convert_youtube_embed_to_watch, has_serviceapp, get_service_type) and improved is_ytdlp_available to detect a working yt-dlp binary. checkdependencies.py now detects yt-dlp as binary or python module and imports os.path.exists. Removed legacy dom_parser.py. Overall improves responsiveness and service selection for YouTube streams.' +version='7.1' +changelog='Player no longer closes when zapping, stale YouTube answers ignored, YouTube channel and live links resolved, webcams exported to bouquets play (YouTube via ytdlpwrapper), location export exports its own webcams, M3U playlists no longer duplicated, Top Webcams and Locations open the selected row, lists load without freezing the GUI, no opkg at startup, updates from OwnerPlugins, installer installs yt-dlp on Python 3 images.' TMPPATH=/tmp/WorldCam-install FILEPATH=/tmp/WorldCam-main.tar.gz diff --git a/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/__init__.py b/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/__init__.py index d41d2f20..cc854ad9 100644 --- a/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/__init__.py +++ b/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/__init__.py @@ -9,7 +9,7 @@ __email__ = "ekekaz@gmail.com" __copyright__ = 'Copyright (c) 2024 Lululla' __license__ = "GPL-v2" -__version__ = "7.0" +__version__ = "7.1" PLUGIN_VERSION = __version__ PLUGIN_PATH = dirname(__file__)