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 f2231f55..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
@@ -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 <.
-'''
-
-
-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"" + name # + ">"
-
- 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/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/plugin.py b/usr/lib/enigma2/python/Plugins/Extensions/WorldCam/plugin.py
index a0192f5e..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())
@@ -1445,13 +1528,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 +1540,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,
@@ -1474,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(
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 = [":::", ";;", "::", ";"]
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 2e2a4a29..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
"""
@@ -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):
@@ -746,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"""
@@ -903,6 +947,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 +979,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 +988,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