2 Commits
Author SHA1 Message Date
Fojadrachi 7c202600cb Release v2.2.0
Release / build-windows (push) Waiting to run
Release / build-linux (push) Waiting to run
Release / publish-release (push) Blocked by required conditions
2026-09-12 13:09:59 +02:00
FojadrachiandClaude Sonnet 5 7c37868cd1 Echter Discord-Zeit-/Bild-Fix, Cache-Reset nach Update, Windows-Startmenue-Icon
Anhand der neuen Debug-Logs (siehe vorheriger Commit) konnte der Bug endlich anhand
echter Daten diagnostiziert werden: video.currentTime/video.duration sind bei YouTube
Music beim Songwechsel NICHT verlaesslich - wegen nahtloser (gapless) Wiedergabe laeuft
darunter ein durchgehender Buffer, dessen currentTime/duration nicht pro Titel
zurueckgesetzt wird. Log-Beweis: beim Wechsel zu einem neuen Titel 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.

- media_probe.py: liest Fortschritt/Gesamtlaenge jetzt aus der sichtbar angezeigten
  Fortschrittsanzeige (ARIA-Attribute des YT-Music-Sliders in Sekunden, sonst
  Zeit-Text im Player; bei YouTube .ytp-time-current/.ytp-time-duration) statt aus den
  unzuverlaessigen <video>-Eigenschaften - diese Anzeige MUSS pro Titel stimmen, weil
  der Nutzer sie selbst so sieht.
- config.py/main.py: Cache (webprofile/cache) wird automatisch geleert, wenn seit dem
  letzten Start ein Update installiert wurde - Login (webprofile/storage) bleibt
  unangetastet.
- shortcuts.py/main.py: legt beim ersten Start der gepackten .exe automatisch eine
  Windows-Startmenue-Verknuepfung an (Playtube wird als portables ZIP ohne Installer
  ausgeliefert, haette sonst nie einen Startmenue-Eintrag).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-12 13:09:54 +02:00
6 changed files with 154 additions and 6 deletions
+12 -1
View File
@@ -55,7 +55,12 @@ nur eine kompilierte .exe mit eigenem Namen und eigener Versionsinfo kann das ae
powershell -ExecutionPolicy Bypass -File packaging\build.ps1 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 zusaetzlich den QtWebEngine-Hilfsprozess (der den eigentlichen Ton ausgibt) zu
`PlaytubeHelper.exe` um, damit er im Taskmanager nicht als `QtWebEngineProcess` `PlaytubeHelper.exe` um, damit er im Taskmanager nicht als `QtWebEngineProcess`
auftaucht. Fuer eine vollstaendige Umbenennung inkl. Icon/Versionsinfo dieses 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 Auto-Update laesst sich im Einstellungen-Tab oder in `config.json` unter
`updates.enabled` deaktivieren. `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 ### Eine neue Version veroeffentlichen
```powershell ```powershell
+9 -2
View File
@@ -10,8 +10,9 @@ from pathlib import Path
# Branding-Schritte MUESSEN vor dem Import von QtWebEngine passieren. # Branding-Schritte MUESSEN vor dem Import von QtWebEngine passieren.
sys.path.insert(0, str(Path(__file__).resolve().parent)) sys.path.insert(0, str(Path(__file__).resolve().parent))
from playtube import app_id # noqa: E402 from playtube import __version__, app_id # noqa: E402
from playtube.config import APP_NAME, load_config # 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.set_app_user_model_id()
app_id.configure_webengine_process_path() app_id.configure_webengine_process_path()
@@ -45,6 +46,12 @@ def main() -> int:
app.setWindowIcon(QIcon(str(icon_path))) app.setWindowIcon(QIcon(str(icon_path)))
config = load_config() 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 = MainWindow(config)
window.show() window.show()
+1 -1
View File
@@ -1,4 +1,4 @@
"""Playtube - ein eigenstaendiger YouTube- & YouTube-Music-Player mit Discord Rich Presence.""" """Playtube - ein eigenstaendiger YouTube- & YouTube-Music-Player mit Discord Rich Presence."""
__app_name__ = "Playtube" __app_name__ = "Playtube"
__version__ = "2.1.5" __version__ = "2.2.0"
+26
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import json import json
import os import os
import shutil
import sys import sys
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -101,3 +102,28 @@ def profile_dir() -> Path:
d = _app_data_dir() / "webprofile" d = _app_data_dir() / "webprofile"
d.mkdir(parents=True, exist_ok=True) d.mkdir(parents=True, exist_ok=True)
return d 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
+54 -2
View File
@@ -28,6 +28,54 @@ MEDIA_PROBE_JS = r"""
function ytThumbnailUrl(videoId) { function ytThumbnailUrl(videoId) {
return videoId ? ('https://i.ytimg.com/vi/' + videoId + '/hqdefault.jpg') : null; 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 video = document.querySelector('video');
var isMusic = location.hostname.indexOf('music.youtube.com') !== -1; var isMusic = location.hostname.indexOf('music.youtube.com') !== -1;
@@ -49,6 +97,10 @@ MEDIA_PROBE_JS = r"""
thumbnail = (imgY ? imgY.href : null) || ytThumbnailUrl(videoId); 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 // WICHTIG: QtWebEngine's runJavaScript()-Bruecke liefert bei einem direkt
// zurueckgegebenen JS-Objekt zuverlaessig nur einen leeren String statt des // zurueckgegebenen JS-Objekt zuverlaessig nur einen leeren String statt des
// Objekts (Zahlen/Strings funktionieren, Objekte nicht) - deshalb hier als // Objekts (Zahlen/Strings funktionieren, Objekte nicht) - deshalb hier als
@@ -61,8 +113,8 @@ MEDIA_PROBE_JS = r"""
thumbnail: thumbnail, thumbnail: thumbnail,
url: location.href, url: location.href,
playing: video ? (!video.paused && !video.ended && video.readyState > 2) : false, playing: video ? (!video.paused && !video.ended && video.readyState > 2) : false,
currentTime: video ? video.currentTime : 0, currentTime: currentTime,
duration: (video && isFinite(video.duration)) ? video.duration : 0, duration: duration,
hasVideo: !!video hasVideo: !!video
}); });
})(); })();
+52
View File
@@ -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