diff --git a/README.md b/README.md index d295dff..f1a8528 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,12 @@ nur eine kompilierte .exe mit eigenem Namen und eigener Versionsinfo kann das ae powershell -ExecutionPolicy Bypass -File packaging\build.ps1 ``` -Ergebnis liegt danach unter `dist\Playtube\Playtube.exe`. Das Build-Skript benennt +Ergebnis liegt danach unter `dist\Playtube\Playtube.exe`. Beim ersten Start dieser +`.exe` legt Playtube automatisch eine Verknuepfung im Windows-Startmenue an (Playtube +wird ja als portables ZIP ohne Installer ausgeliefert - ohne diesen Schritt gaebe es +sonst keinen Startmenue-Eintrag). + +Das Build-Skript benennt zusaetzlich den QtWebEngine-Hilfsprozess (der den eigentlichen Ton ausgibt) zu `PlaytubeHelper.exe` um, damit er im Taskmanager nicht als `QtWebEngineProcess` auftaucht. Fuer eine vollstaendige Umbenennung inkl. Icon/Versionsinfo dieses @@ -91,6 +96,12 @@ fragt ein Dialog, ob sie installiert werden soll: Auto-Update laesst sich im Einstellungen-Tab oder in `config.json` unter `updates.enabled` deaktivieren. +Nach einem erkannten Update wird beim naechsten Start automatisch der QtWebEngine- +HTTP-Cache geleert (`webprofile/cache`) - alte Cache-Eintraege koennen sonst nicht mehr +zum neuen Code passen (fruehere Ursache fuer fehlende Icons). Der Login bleibt davon +unberuehrt, da Cookies/LocalStorage in einem komplett getrennten Ordner +(`webprofile/storage`) liegen. + ### Eine neue Version veroeffentlichen ```powershell diff --git a/main.py b/main.py index 754891c..e883b09 100644 --- a/main.py +++ b/main.py @@ -10,8 +10,9 @@ from pathlib import Path # Branding-Schritte MUESSEN vor dem Import von QtWebEngine passieren. sys.path.insert(0, str(Path(__file__).resolve().parent)) -from playtube import app_id # noqa: E402 -from playtube.config import APP_NAME, load_config # noqa: E402 +from playtube import __version__, app_id # noqa: E402 +from playtube.config import APP_NAME, clear_cache_on_update, load_config # noqa: E402 +from playtube.shortcuts import ensure_start_menu_shortcut # noqa: E402 app_id.set_app_user_model_id() app_id.configure_webengine_process_path() @@ -45,6 +46,12 @@ def main() -> int: app.setWindowIcon(QIcon(str(icon_path))) config = load_config() + # Cache leeren, wenn seit dem letzten Start ein Update installiert wurde (Login + # bleibt erhalten, siehe clear_cache_on_update); Startmenue-Verknuepfung fehlt sonst + # komplett, da Playtube als portables ZIP ohne Installer ausgeliefert wird. + clear_cache_on_update(__version__) + ensure_start_menu_shortcut(APP_NAME) + window = MainWindow(config) window.show() diff --git a/playtube/config.py b/playtube/config.py index be53143..ab02247 100644 --- a/playtube/config.py +++ b/playtube/config.py @@ -3,6 +3,7 @@ from __future__ import annotations import json import os +import shutil import sys from pathlib import Path from typing import Any @@ -101,3 +102,28 @@ def profile_dir() -> Path: d = _app_data_dir() / "webprofile" d.mkdir(parents=True, exist_ok=True) return d + + +def clear_cache_on_update(current_version: str) -> None: + """Loescht den QtWebEngine-HTTP-Cache (webprofile/cache), wenn seit dem letzten + Start ein Update installiert wurde - der Login (Cookies/LocalStorage liegen in + webprofile/storage, einem komplett getrennten Ordner) bleibt dabei unangetastet. + Alte Cache-Eintraege (z.B. Icon-Sprites, Skripte) koennen nach einem Update nicht + mehr zum neuen Code passen - frueher Ursache fuer fehlende Icons nach einem + beschaedigten Cache, siehe README.""" + marker = _app_data_dir() / "installed_version.txt" + previous = None + if marker.exists(): + try: + previous = marker.read_text(encoding="utf-8").strip() + except OSError: + previous = None + + if previous != current_version: + cache_dir = profile_dir() / "cache" + if cache_dir.exists(): + shutil.rmtree(cache_dir, ignore_errors=True) + try: + marker.write_text(current_version, encoding="utf-8") + except OSError: + pass diff --git a/playtube/media_probe.py b/playtube/media_probe.py index 73458fd..e44895b 100644 --- a/playtube/media_probe.py +++ b/playtube/media_probe.py @@ -28,6 +28,54 @@ MEDIA_PROBE_JS = r""" function ytThumbnailUrl(videoId) { return videoId ? ('https://i.ytimg.com/vi/' + videoId + '/hqdefault.jpg') : null; } + // "1:23" oder "1:02:03" -> Sekunden. + function parseClock(str) { + if (!str) { return null; } + var parts = String(str).trim().split(':').map(function(p) { return parseInt(p, 10); }); + if (!parts.length) { return null; } + for (var i = 0; i < parts.length; i++) { if (isNaN(parts[i])) { return null; } } + var seconds = 0; + for (var j = 0; j < parts.length; j++) { seconds = seconds * 60 + parts[j]; } + return seconds; + } + function numAttr(el, name) { + if (!el) { return null; } + var raw = el.getAttribute(name); + var n = raw !== null ? parseFloat(raw) : NaN; + return isFinite(n) ? n : null; + } + // WICHTIG: video.currentTime/video.duration sind bei YouTube Music NICHT + // verlaesslich pro Titel - bei nahtlosem (gapless) Songwechsel laeuft darunter + // teils ein durchgehender Buffer weiter, dessen currentTime/duration beim + // Songwechsel NICHT auf den neuen Titel zurueckgesetzt wird, sondern einfach ueber + // mehrere Songs hinweg weiterzaehlt (empirisch bestaetigt: nach einem Songwechsel + // wurde die verbleibende Spielzeit des VORHERIGEN Titels als Start-Offset des NEUEN + // uebernommen, und die "Dauer" wuchs bei jedem Poll weiter statt konstant zu + // bleiben). Deshalb wird die sichtbar angezeigte Fortschrittsanzeige ausgelesen + // (ARIA-Attribute des Sliders in Sekunden, sonst der Zeit-Text im Player) - die + // zeigt garantiert den Fortschritt des GERADE LAUFENDEN Titels, weil der Nutzer sie + // ja selbst so sieht. + function musicTimeInfo() { + var slider = document.querySelector('#progress-bar, tp-yt-paper-slider#progress-bar'); + var current = numAttr(slider, 'aria-valuenow'); + var total = numAttr(slider, 'aria-valuemax'); + if (current !== null && total !== null && total > 0) { + return { current: current, total: total }; + } + var el = document.querySelector('.time-info.ytmusic-player-bar, ytmusic-player-bar .time-info'); + var parts = el ? (el.textContent || '').split('/') : []; + if (parts.length === 2) { + var c = parseClock(parts[0]), t = parseClock(parts[1]); + if (c !== null && t !== null) { return { current: c, total: t }; } + } + return null; + } + function youtubeTimeInfo() { + var current = parseClock(txt('.ytp-time-current')); + var total = parseClock(txt('.ytp-time-duration')); + if (current !== null && total !== null) { return { current: current, total: total }; } + return null; + } var video = document.querySelector('video'); var isMusic = location.hostname.indexOf('music.youtube.com') !== -1; @@ -49,6 +97,10 @@ MEDIA_PROBE_JS = r""" thumbnail = (imgY ? imgY.href : null) || ytThumbnailUrl(videoId); } + var timeInfo = isMusic ? musicTimeInfo() : youtubeTimeInfo(); + var currentTime = timeInfo ? timeInfo.current : (video ? video.currentTime : 0); + var duration = timeInfo ? timeInfo.total : ((video && isFinite(video.duration)) ? video.duration : 0); + // WICHTIG: QtWebEngine's runJavaScript()-Bruecke liefert bei einem direkt // zurueckgegebenen JS-Objekt zuverlaessig nur einen leeren String statt des // Objekts (Zahlen/Strings funktionieren, Objekte nicht) - deshalb hier als @@ -61,8 +113,8 @@ MEDIA_PROBE_JS = r""" thumbnail: thumbnail, url: location.href, playing: video ? (!video.paused && !video.ended && video.readyState > 2) : false, - currentTime: video ? video.currentTime : 0, - duration: (video && isFinite(video.duration)) ? video.duration : 0, + currentTime: currentTime, + duration: duration, hasVideo: !!video }); })(); diff --git a/playtube/shortcuts.py b/playtube/shortcuts.py new file mode 100644 index 0000000..e1aa8ac --- /dev/null +++ b/playtube/shortcuts.py @@ -0,0 +1,52 @@ +"""Erstellt bei Bedarf eine Windows-Startmenue-Verknuepfung. + +Playtube wird als portables ZIP ausgeliefert (kein MSI/Installer) - ohne das gaebe es +also nie einen Eintrag im Windows-Startmenue, wie man ihn von "richtig installierten" +Programmen kennt. Wird beim Start der gepackten .exe einmalig nachgeholt (idempotent - +prueft vorher, ob die Verknuepfung schon existiert).""" +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + + +def _find_icon(exe_dir: Path) -> Path | None: + matches = list(exe_dir.rglob("icon.ico")) + return matches[0] if matches else None + + +def ensure_start_menu_shortcut(app_name: str) -> None: + if sys.platform != "win32" or not getattr(sys, "frozen", False): + return + try: + appdata = os.environ.get("APPDATA") + if not appdata: + return + start_menu = Path(appdata) / "Microsoft" / "Windows" / "Start Menu" / "Programs" + shortcut_path = start_menu / f"{app_name}.lnk" + if shortcut_path.exists(): + return + + exe_path = Path(sys.executable).resolve() + icon_path = _find_icon(exe_path.parent) or exe_path + + ps_script = ( + '$WshShell = New-Object -ComObject WScript.Shell\n' + f'$Shortcut = $WshShell.CreateShortcut("{shortcut_path}")\n' + f'$Shortcut.TargetPath = "{exe_path}"\n' + f'$Shortcut.WorkingDirectory = "{exe_path.parent}"\n' + f'$Shortcut.IconLocation = "{icon_path}"\n' + f'$Shortcut.Description = "{app_name}"\n' + '$Shortcut.Save()\n' + ) + subprocess.run( + ["powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", ps_script], + capture_output=True, + timeout=15, + creationflags=subprocess.CREATE_NO_WINDOW, + ) + except Exception: + # Kein Startmenue-Eintrag ist kein Grund, den App-Start scheitern zu lassen. + pass