Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31797cdabb | ||
|
|
771c438257 | ||
|
|
5379be6c11 | ||
|
|
c6fe895334 | ||
|
|
7c202600cb | ||
|
|
7c37868cd1 | ||
|
|
d63b6b36d4 | ||
|
|
06e88ced19 | ||
|
|
fbf5f3b93f | ||
|
|
9bd4b360cd |
@@ -42,12 +42,18 @@ jobs:
|
||||
path: Playtube-${{ github.ref_name }}-win64.zip
|
||||
|
||||
- name: Patch-Paket packen (nur Playtube.exe, fuer schnelle Updates)
|
||||
run: Compress-Archive -Path dist\Playtube\Playtube.exe -DestinationPath Playtube-${{ github.ref_name }}-win64-patch.zip
|
||||
# .play statt .zip: eigene Dateiendung, die Playtube nach dem ersten Start als
|
||||
# Windows-Dateizuordnung registriert (siehe shortcuts.py) - ein manuell
|
||||
# heruntergeladenes Patch laesst sich so auch per Doppelklick installieren.
|
||||
# Technisch bleibt es ein ganz normales ZIP-Archiv.
|
||||
run: |
|
||||
Compress-Archive -Path dist\Playtube\Playtube.exe -DestinationPath Playtube-${{ github.ref_name }}-win64-patch.zip
|
||||
Rename-Item -Path Playtube-${{ github.ref_name }}-win64-patch.zip -NewName Playtube-${{ github.ref_name }}-win64-patch.play
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-patch
|
||||
path: Playtube-${{ github.ref_name }}-win64-patch.zip
|
||||
path: Playtube-${{ github.ref_name }}-win64-patch.play
|
||||
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -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,22 @@ 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.
|
||||
|
||||
### Patch-Dateien manuell installieren (`.play`)
|
||||
|
||||
Windows-Patch-Pakete tragen die eigene Dateiendung `.play` statt `.zip` (technisch
|
||||
weiterhin ein ganz normales ZIP-Archiv). Playtube registriert `.play` beim ersten Start
|
||||
automatisch als Windows-Dateizuordnung - eine manuell heruntergeladene
|
||||
`Playtube-vX.Y.Z-win64-patch.play` (z.B. von der
|
||||
[Releases-Seite](https://github.com/fojadrachi/Playtube/releases)) laesst sich also
|
||||
einfach per Doppelklick installieren, ohne dass Playtube selbst etwas herunterladen
|
||||
muss.
|
||||
|
||||
### Eine neue Version veroeffentlichen
|
||||
|
||||
```powershell
|
||||
|
||||
@@ -10,8 +10,12 @@ 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 ( # noqa: E402
|
||||
ensure_play_file_association,
|
||||
ensure_start_menu_shortcut,
|
||||
)
|
||||
|
||||
app_id.set_app_user_model_id()
|
||||
app_id.configure_webengine_process_path()
|
||||
@@ -32,6 +36,15 @@ from PySide6.QtWidgets import QApplication # noqa: E402
|
||||
from playtube.mainwindow import MainWindow # noqa: E402
|
||||
|
||||
|
||||
def _pending_local_patch() -> str | None:
|
||||
"""Falls Playtube per Doppelklick auf eine ".play"-Patchdatei gestartet wurde
|
||||
(siehe shortcuts.ensure_play_file_association), liefert den Pfad dazu."""
|
||||
for arg in sys.argv[1:]:
|
||||
if arg.lower().endswith(".play") and Path(arg).is_file():
|
||||
return str(Path(arg).resolve())
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
QApplication.setAttribute(Qt.ApplicationAttribute.AA_ShareOpenGLContexts, True)
|
||||
app = QApplication(sys.argv)
|
||||
@@ -45,9 +58,22 @@ 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)
|
||||
ensure_play_file_association(APP_NAME)
|
||||
|
||||
window = MainWindow(config)
|
||||
window.show()
|
||||
|
||||
# Playtube wurde per Doppelklick auf eine heruntergeladene .play-Patchdatei
|
||||
# gestartet -> direkt installieren statt selbst etwas herunterzuladen.
|
||||
local_patch = _pending_local_patch()
|
||||
if local_patch:
|
||||
window.install_local_patch(local_patch)
|
||||
|
||||
return app.exec()
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Playtube - ein eigenstaendiger YouTube- & YouTube-Music-Player mit Discord Rich Presence."""
|
||||
|
||||
__app_name__ = "Playtube"
|
||||
__version__ = "2.1.3"
|
||||
__version__ = "2.3.0"
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -48,6 +49,14 @@ def _app_data_dir() -> Path:
|
||||
return d
|
||||
|
||||
|
||||
def app_data_dir() -> Path:
|
||||
"""Oeffentlicher Zugriff auf den App-Datenordner, z.B. fuer Debug-Logs - die
|
||||
gepackte .exe laeuft ohne Konsolenfenster (console=False), print()-Debugging ist
|
||||
dort also unsichtbar; ein Log-File ist die einzige Moeglichkeit, dort etwas
|
||||
nachtraeglich einzusehen."""
|
||||
return _app_data_dir()
|
||||
|
||||
|
||||
def _config_path() -> Path:
|
||||
# Im Entwicklungsmodus liegt config.json direkt im Projektordner (leicht editierbar),
|
||||
# im gepackten Build im APPDATA-Ordner.
|
||||
@@ -93,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
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Sehr einfaches, immer aktives Datei-Log fuer schwer reproduzierbare Bugs.
|
||||
|
||||
Die gepackte .exe laeuft ohne Konsolenfenster (console=False in packaging/playtube.spec),
|
||||
also sind print()-Debugausgaben dort unsichtbar - selbst mit PLAYTUBE_DEBUG=1 sieht der
|
||||
Nutzer nichts. Dieses Modul schreibt stattdessen in eine kleine, automatisch gekappte
|
||||
Log-Datei im App-Datenordner, die sich jederzeit nachtraeglich auslesen laesst.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
from .config import app_data_dir
|
||||
|
||||
_MAX_LINES = 500
|
||||
|
||||
|
||||
def _log_path() -> Path:
|
||||
d = app_data_dir() / "logs"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d / "discord_rpc.log"
|
||||
|
||||
|
||||
def log_line(msg: str) -> None:
|
||||
"""Haengt eine Zeile mit Zeitstempel an - haelt die Datei klein, indem bei
|
||||
Ueberlaenge nur die letzten _MAX_LINES Zeilen behalten werden."""
|
||||
try:
|
||||
path = _log_path()
|
||||
timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
line = f"[{timestamp}] {msg}\n"
|
||||
with open(path, "a", encoding="utf-8") as f:
|
||||
f.write(line)
|
||||
|
||||
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
if len(lines) > _MAX_LINES:
|
||||
path.write_text("\n".join(lines[-_MAX_LINES:]) + "\n", encoding="utf-8")
|
||||
except Exception:
|
||||
# Logging darf niemals die App zum Absturz bringen.
|
||||
pass
|
||||
|
||||
|
||||
def log_exception(context: str, exc: BaseException) -> None:
|
||||
log_line(f"{context}: {exc!r}\n{''.join(traceback.format_exception(exc))}")
|
||||
@@ -16,6 +16,7 @@ from typing import Any
|
||||
from PySide6.QtCore import QThread
|
||||
|
||||
from .config import APP_NAME
|
||||
from .debug_log import log_line
|
||||
|
||||
try:
|
||||
from pypresence.types import ActivityType
|
||||
@@ -199,6 +200,7 @@ class DiscordRPCWorker(QThread):
|
||||
self._presence = Presence(self._client_id)
|
||||
self._presence.connect()
|
||||
self._connected = True
|
||||
log_line("[connect] verbunden")
|
||||
if os.environ.get("PLAYTUBE_DEBUG"):
|
||||
print("[discord-rpc] verbunden", flush=True)
|
||||
except Exception as exc:
|
||||
@@ -224,6 +226,7 @@ class DiscordRPCWorker(QThread):
|
||||
now = time.time()
|
||||
|
||||
if key != self._track_key:
|
||||
old_key = self._track_key
|
||||
self._track_key = key
|
||||
# current_time nur als grobe Anfangs-Schaetzung verwenden (z.B. Programm
|
||||
# startet waehrend ein Titel schon laeuft) - plausibilisiert, damit ein
|
||||
@@ -231,6 +234,7 @@ class DiscordRPCWorker(QThread):
|
||||
# rein ueber die Systemzeit weiterlaeuft statt ueber currentTime.
|
||||
offset = current_time if (duration <= 0 or 0 <= current_time <= duration) else 0
|
||||
self._track_start_ts = int(now - offset)
|
||||
log_line(f"[track-change] alt={old_key!r} neu={key!r} offset={offset:.1f}s")
|
||||
|
||||
start_ts = self._track_start_ts if self._track_start_ts is not None else int(now)
|
||||
end_ts = start_ts + int(duration) if duration and duration > 0 else None
|
||||
@@ -254,11 +258,23 @@ class DiscordRPCWorker(QThread):
|
||||
start_ts, end_ts = self._track_timestamps(item)
|
||||
payload = build_presence_payload(item, self._session_start, start_ts, end_ts)
|
||||
self._presence.update(**payload)
|
||||
log_line(
|
||||
"[send] title=%r url=%r thumbnail=%r large_image=%r start=%s end=%s"
|
||||
% (
|
||||
item.get("title"),
|
||||
item.get("url"),
|
||||
item.get("thumbnail"),
|
||||
payload.get("large_image"),
|
||||
start_ts,
|
||||
end_ts,
|
||||
)
|
||||
)
|
||||
if os.environ.get("PLAYTUBE_DEBUG"):
|
||||
print(f"[discord-rpc] gesendet: {payload!r}", flush=True)
|
||||
except Exception as exc:
|
||||
# Discord evtl. geschlossen worden -> beim naechsten Mal neu verbinden.
|
||||
self._connected = False
|
||||
log_line(f"[send-error] {exc!r}")
|
||||
if os.environ.get("PLAYTUBE_DEBUG"):
|
||||
print(f"[discord-rpc] Senden fehlgeschlagen: {exc!r}", flush=True)
|
||||
|
||||
|
||||
+26
-6
@@ -311,20 +311,33 @@ class MainWindow(QMainWindow):
|
||||
if answer == QMessageBox.StandardButton.Yes:
|
||||
self._start_update_install(download_url)
|
||||
|
||||
def _start_update_install(self, download_url: str) -> None:
|
||||
if not download_url:
|
||||
def _start_update_install(self, download_url: str = "", local_path: str | None = None) -> None:
|
||||
if not download_url and not local_path:
|
||||
return
|
||||
self._settings_tab.hide_install_button()
|
||||
self._settings_tab.set_update_status("Lade Update herunter …")
|
||||
self._settings_tab.set_download_progress(0)
|
||||
if local_path:
|
||||
self._settings_tab.set_update_status("Installiere lokale Patch-Datei …")
|
||||
self._settings_tab.set_download_progress(-1)
|
||||
else:
|
||||
self._settings_tab.set_update_status("Lade Update herunter …")
|
||||
self._settings_tab.set_download_progress(0)
|
||||
self._tray.setToolTip(f"{APP_NAME} – Update wird installiert …")
|
||||
self._update_installer = UpdateInstaller(download_url, self)
|
||||
self._update_installer = UpdateInstaller(
|
||||
download_url, local_archive_path=local_path, parent=self
|
||||
)
|
||||
self._update_installer.progress.connect(self._on_install_progress_text)
|
||||
self._update_installer.progress_percent.connect(self._settings_tab.set_download_progress)
|
||||
self._update_installer.finished_ok.connect(self._on_update_finished)
|
||||
self._update_installer.failed.connect(self._on_update_failed)
|
||||
self._update_installer.start()
|
||||
|
||||
def install_local_patch(self, path: str) -> None:
|
||||
"""Wird von main.py aufgerufen, wenn Playtube per Doppelklick auf eine
|
||||
heruntergeladene .play-Patchdatei gestartet wurde (siehe
|
||||
shortcuts.ensure_play_file_association)."""
|
||||
self._tabs.setCurrentWidget(self._settings_tab)
|
||||
self._start_update_install(local_path=path)
|
||||
|
||||
def _on_install_progress_text(self, msg: str) -> None:
|
||||
self._tray.setToolTip(f"{APP_NAME} – {msg}")
|
||||
self._settings_tab.set_update_status(msg)
|
||||
@@ -333,7 +346,14 @@ class MainWindow(QMainWindow):
|
||||
self._settings_tab.set_download_progress(100)
|
||||
if getattr(sys, "frozen", False):
|
||||
# Ein Hintergrund-Skript wartet bereits darauf, dass dieser Prozess
|
||||
# beendet wird, tauscht dann die Dateien aus und startet die App neu.
|
||||
# beendet wird, tauscht dann die Dateien aus und startet die App neu -
|
||||
# es zeigt dabei selbst ein kleines Fortschrittsfenster an (siehe
|
||||
# updater.py), damit der Nutzer zwischen "App schliesst sich" und
|
||||
# "neue App startet" nicht denkt, etwas sei abgestuerzt.
|
||||
self._settings_tab.set_update_status(
|
||||
"Installation abgeschlossen. Playtube wird neu gestartet …"
|
||||
)
|
||||
self._tray.setToolTip(f"{APP_NAME} – wird neu gestartet …")
|
||||
self._quit()
|
||||
else:
|
||||
QMessageBox.information(
|
||||
|
||||
+64
-4
@@ -28,16 +28,72 @@ 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;
|
||||
var videoId = videoIdFromUrl(location.href);
|
||||
var title = null, subtitle = null, thumbnail = null;
|
||||
|
||||
// WICHTIG: die Video-ID aus der URL ist die zuverlaessigste Thumbnail-Quelle - sie
|
||||
// wechselt garantiert synchron mit dem Titel. Das <img> in der YT-Music-Playerleiste
|
||||
// (fruehere Praeferenz) wird von YouTube per Crossfade/Shadow-DOM animiert und
|
||||
// aktualisiert sein src-Attribut dabei nachweislich NICHT zuverlaessig pro Titel -
|
||||
// empirisch bestaetigt: dieselbe Thumbnail-URL blieb ueber mehrere komplett
|
||||
// unterschiedliche Songs hinweg stehen, obwohl Titel/Zeit schon laengst gewechselt
|
||||
// hatten. Das DOM-<img> dient nur noch als Fallback, falls keine Video-ID in der
|
||||
// URL steckt.
|
||||
if (isMusic) {
|
||||
title = txt('.title.ytmusic-player-bar') || txt('ytmusic-player-bar .title');
|
||||
subtitle = txt('.byline.ytmusic-player-bar') || txt('ytmusic-player-bar .byline');
|
||||
thumbnail = imgSrc('ytmusic-player-bar img, .image.ytmusic-player-bar img') || ytThumbnailUrl(videoId);
|
||||
thumbnail = ytThumbnailUrl(videoId) || imgSrc('ytmusic-player-bar img, .image.ytmusic-player-bar img');
|
||||
} else {
|
||||
var t = document.title.replace(/ - YouTube$/, '');
|
||||
title = t || null;
|
||||
@@ -46,9 +102,13 @@ MEDIA_PROBE_JS = r"""
|
||||
|| txt('#channel-name a')
|
||||
|| txt('ytd-channel-name#channel-name a');
|
||||
var imgY = document.querySelector('link[rel="image_src"]');
|
||||
thumbnail = (imgY ? imgY.href : null) || ytThumbnailUrl(videoId);
|
||||
thumbnail = ytThumbnailUrl(videoId) || (imgY ? imgY.href : null);
|
||||
}
|
||||
|
||||
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 +121,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
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Erstellt bei Bedarf eine Windows-Startmenue-Verknuepfung sowie die Dateizuordnung
|
||||
fuer die eigene ".play"-Patchdateiendung.
|
||||
|
||||
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
|
||||
|
||||
|
||||
def ensure_play_file_association(app_name: str) -> None:
|
||||
"""Registriert ".play" (unsere eigene Endung fuer Patch-Pakete, siehe updater.py)
|
||||
als Windows-Dateizuordnung fuer Playtube - ein manuell heruntergeladenes Patch kann
|
||||
danach per Doppelklick installiert werden (Playtube startet dann mit dem Dateipfad
|
||||
als Kommandozeilenargument, siehe main.py). Nur unter HKEY_CURRENT_USER, damit keine
|
||||
Admin-Rechte noetig sind. Wird bei jedem Start erneut geschrieben (billig, idempotent
|
||||
und heilt sich selbst, falls die .exe z.B. nach einem Update an einem neuen Pfad
|
||||
liegt)."""
|
||||
if sys.platform != "win32" or not getattr(sys, "frozen", False):
|
||||
return
|
||||
try:
|
||||
import winreg
|
||||
|
||||
exe_path = str(Path(sys.executable).resolve())
|
||||
prog_id = f"{app_name}.PatchFile"
|
||||
icon_path = _find_icon(Path(exe_path).parent) or Path(exe_path)
|
||||
|
||||
with winreg.CreateKey(winreg.HKEY_CURRENT_USER, r"Software\Classes\.play") as key:
|
||||
winreg.SetValueEx(key, "", 0, winreg.REG_SZ, prog_id)
|
||||
with winreg.CreateKey(winreg.HKEY_CURRENT_USER, rf"Software\Classes\{prog_id}") as key:
|
||||
winreg.SetValueEx(key, "", 0, winreg.REG_SZ, f"{app_name}-Patchdatei")
|
||||
with winreg.CreateKey(
|
||||
winreg.HKEY_CURRENT_USER, rf"Software\Classes\{prog_id}\DefaultIcon"
|
||||
) as key:
|
||||
winreg.SetValueEx(key, "", 0, winreg.REG_SZ, str(icon_path))
|
||||
with winreg.CreateKey(
|
||||
winreg.HKEY_CURRENT_USER, rf"Software\Classes\{prog_id}\shell\open\command"
|
||||
) as key:
|
||||
winreg.SetValueEx(key, "", 0, winreg.REG_SZ, f'"{exe_path}" "%1"')
|
||||
|
||||
# Explorer informieren, damit die neue Zuordnung sofort (ohne Neustart) greift.
|
||||
import ctypes
|
||||
|
||||
SHCNE_ASSOCCHANGED = 0x08000000
|
||||
SHCNF_IDLIST = 0x0000
|
||||
ctypes.windll.shell32.SHChangeNotify(SHCNE_ASSOCCHANGED, SHCNF_IDLIST, None, None)
|
||||
except Exception:
|
||||
# Keine Dateizuordnung ist kein Grund, den App-Start scheitern zu lassen.
|
||||
pass
|
||||
+151
-44
@@ -17,6 +17,14 @@ Ablauf:
|
||||
PowerShell unter Windows, ein Shell-Skript unter Linux.
|
||||
- Entwicklungsmodus (python main.py): fuehrt 'git pull' + 'pip install -r
|
||||
requirements.txt' aus, die App startet sich danach selbst neu (os.execv).
|
||||
|
||||
Windows-Patch-Pakete tragen die eigene Dateiendung ".play" statt ".zip" (technisch
|
||||
weiterhin ein ganz normales ZIP-Archiv - Windows/Python schauen beim Entpacken auf die
|
||||
Magic Bytes, nicht auf die Endung). shortcuts.py registriert ".play" beim ersten Start
|
||||
als Windows-Dateizuordnung fuer Playtube - ein manuell heruntergeladenes Patch kann so
|
||||
auch per Doppelklick installiert werden, ohne dass Playtube selbst etwas herunterladen
|
||||
muss (siehe main.py: wird eine .play-Datei als Kommandozeilenargument uebergeben,
|
||||
installiert MainWindow.install_local_patch() sie direkt ueber UpdateInstaller).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -72,16 +80,21 @@ def fetch_latest_release() -> dict[str, Any] | None:
|
||||
def _find_platform_asset(release: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Sucht das zur laufenden Plattform passende Release-Paket. Bevorzugt das kleine
|
||||
"-patch"-Paket (nur die .exe/Binary) gegenueber dem vollen Release-Paket - siehe
|
||||
Moduldoku. Windows -> *.zip mit "win" im Namen, Linux -> *.tar.gz mit "linux"."""
|
||||
Moduldoku. Windows: volles Paket -> *.zip, Patch -> *.play (unsere eigene
|
||||
Dateiendung, technisch ein ganz normales .zip - siehe Moduldoku), beide mit "win" im
|
||||
Namen. Linux -> *.tar.gz mit "linux"."""
|
||||
assets = release.get("assets", [])
|
||||
if sys.platform == "win32":
|
||||
hints, exts = ("win",), (".zip",)
|
||||
hints = ("win",)
|
||||
patch_exts, full_exts = (".play",), (".zip",)
|
||||
elif sys.platform.startswith("linux"):
|
||||
hints, exts = ("linux",), (".tar.gz", ".tgz")
|
||||
hints = ("linux",)
|
||||
patch_exts = full_exts = (".tar.gz", ".tgz")
|
||||
else:
|
||||
return None
|
||||
|
||||
def find(want_patch: bool, require_hint: bool) -> dict[str, Any] | None:
|
||||
exts = patch_exts if want_patch else full_exts
|
||||
for asset in assets:
|
||||
name = asset.get("name", "").lower()
|
||||
if not name.endswith(exts):
|
||||
@@ -133,9 +146,18 @@ class UpdateInstaller(QThread):
|
||||
finished_ok = Signal()
|
||||
failed = Signal(str)
|
||||
|
||||
def __init__(self, download_url: str, parent=None):
|
||||
def __init__(
|
||||
self,
|
||||
download_url: str = "",
|
||||
local_archive_path: str | None = None,
|
||||
parent=None,
|
||||
):
|
||||
super().__init__(parent)
|
||||
self._download_url = download_url
|
||||
# Gesetzt, wenn der Nutzer eine bereits heruntergeladene .play-Patchdatei per
|
||||
# Doppelklick geoeffnet hat (siehe shortcuts.py-Dateiverknuepfung) - dann wird
|
||||
# nichts heruntergeladen, sondern direkt diese lokale Datei installiert.
|
||||
self._local_archive_path = local_archive_path
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
@@ -149,39 +171,49 @@ class UpdateInstaller(QThread):
|
||||
# ---------------------------------------------------------- gepackter Modus
|
||||
|
||||
def _run_packaged_update(self) -> None:
|
||||
if not self._download_url:
|
||||
self.failed.emit(
|
||||
"Kein passendes Release-Paket fuer dieses Betriebssystem gefunden."
|
||||
)
|
||||
return
|
||||
|
||||
self.progress.emit("Lade Update herunter …")
|
||||
self.progress_percent.emit(0)
|
||||
install_dir = Path(sys.executable).resolve().parent
|
||||
staging = Path(tempfile.mkdtemp(prefix="playtube_update_"))
|
||||
archive_name = self._download_url.rsplit("/", 1)[-1]
|
||||
archive_path = staging / archive_name
|
||||
extract_dir = staging / "extracted"
|
||||
|
||||
req = urllib.request.Request(self._download_url, headers={"User-Agent": _USER_AGENT})
|
||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||
total = int(resp.headers.get("Content-Length") or 0)
|
||||
downloaded = 0
|
||||
last_emitted = -1
|
||||
with open(archive_path, "wb") as out:
|
||||
while True:
|
||||
chunk = resp.read(256 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
out.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
if total:
|
||||
percent = int(downloaded * 100 / total)
|
||||
if percent != last_emitted:
|
||||
self.progress_percent.emit(percent)
|
||||
last_emitted = percent
|
||||
if self._local_archive_path:
|
||||
archive_path = Path(self._local_archive_path)
|
||||
if not archive_path.exists():
|
||||
self.failed.emit(f"Patch-Datei nicht gefunden: {archive_path}")
|
||||
return
|
||||
archive_name = archive_path.name
|
||||
self.progress.emit(f"Verwende lokale Patch-Datei {archive_name} …")
|
||||
self.progress_percent.emit(-1)
|
||||
else:
|
||||
if not self._download_url:
|
||||
self.failed.emit(
|
||||
"Kein passendes Release-Paket fuer dieses Betriebssystem gefunden."
|
||||
)
|
||||
return
|
||||
|
||||
self.progress.emit("Lade Update herunter …")
|
||||
self.progress_percent.emit(0)
|
||||
archive_name = self._download_url.rsplit("/", 1)[-1]
|
||||
archive_path = staging / archive_name
|
||||
|
||||
req = urllib.request.Request(self._download_url, headers={"User-Agent": _USER_AGENT})
|
||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||
total = int(resp.headers.get("Content-Length") or 0)
|
||||
downloaded = 0
|
||||
last_emitted = -1
|
||||
with open(archive_path, "wb") as out:
|
||||
while True:
|
||||
chunk = resp.read(256 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
out.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
if total:
|
||||
percent = int(downloaded * 100 / total)
|
||||
if percent != last_emitted:
|
||||
self.progress_percent.emit(percent)
|
||||
last_emitted = percent
|
||||
self.progress_percent.emit(100)
|
||||
|
||||
self.progress_percent.emit(100)
|
||||
self.progress.emit("Entpacke Update …")
|
||||
# Unbestimmter Fortschritt waehrend Entpacken/Installieren - die UI zeigt
|
||||
# dafuer einen "laufenden" Balken statt einer Prozentzahl.
|
||||
@@ -190,10 +222,13 @@ class UpdateInstaller(QThread):
|
||||
with tarfile.open(archive_path) as tf:
|
||||
tf.extractall(extract_dir)
|
||||
else:
|
||||
# .zip UND .play (unsere eigene Dateiendung fuer per Doppelklick startbare
|
||||
# Patchdateien - technisch ein ganz normales .zip, siehe Moduldoku) werden
|
||||
# beide als ZIP entpackt.
|
||||
with zipfile.ZipFile(archive_path) as zf:
|
||||
zf.extractall(extract_dir)
|
||||
|
||||
is_patch = "patch" in archive_name.lower()
|
||||
is_patch = "patch" in archive_name.lower() or archive_name.lower().endswith(".play")
|
||||
self.progress.emit("Bereite Installation vor …")
|
||||
|
||||
if is_patch:
|
||||
@@ -222,32 +257,88 @@ class UpdateInstaller(QThread):
|
||||
|
||||
self.finished_ok.emit()
|
||||
|
||||
# Kleines, immer sichtbares Fortschrittsfenster fuer den Windows-Installationsschritt.
|
||||
# Der Hauptprozess ist zu diesem Zeitpunkt schon beendet (Datei-Sperren!), daher
|
||||
# laeuft das komplett im separaten PowerShell-Skript - ohne dieses Fenster wuerde
|
||||
# der Nutzer nach dem Schliessen der App fuer die Dauer der Installation (bei
|
||||
# einem vollen Paket ggf. mehrere Sekunden) gar nichts sehen, was wie ein Absturz
|
||||
# oder Haenger wirkt.
|
||||
_WINDOWS_PROGRESS_FORM_PS = """
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
$form = New-Object System.Windows.Forms.Form
|
||||
$form.Text = "Playtube-Update"
|
||||
$form.Size = New-Object System.Drawing.Size(380,130)
|
||||
$form.StartPosition = "CenterScreen"
|
||||
$form.FormBorderStyle = "FixedDialog"
|
||||
$form.ControlBox = $false
|
||||
$form.TopMost = $true
|
||||
$label = New-Object System.Windows.Forms.Label
|
||||
$label.Text = "Playtube wird aktualisiert – bitte warten …"
|
||||
$label.AutoSize = $false
|
||||
$label.Size = New-Object System.Drawing.Size(340,20)
|
||||
$label.Location = New-Object System.Drawing.Point(20,15)
|
||||
$form.Controls.Add($label)
|
||||
$bar = New-Object System.Windows.Forms.ProgressBar
|
||||
$bar.Style = "Marquee"
|
||||
$bar.MarqueeAnimationSpeed = 30
|
||||
$bar.Size = New-Object System.Drawing.Size(340,20)
|
||||
$bar.Location = New-Object System.Drawing.Point(20,50)
|
||||
$form.Controls.Add($bar)
|
||||
$form.Show()
|
||||
$form.Refresh()
|
||||
"""
|
||||
|
||||
# -- Patch (nur .exe/Binary tauschen, _internal/ bleibt unangetastet) --
|
||||
|
||||
def _install_patch_windows(self, new_exe: Path, install_dir: Path, staging: Path) -> None:
|
||||
exe_path = install_dir / _WINDOWS_BINARY_NAME
|
||||
script = f"""
|
||||
script = self._WINDOWS_PROGRESS_FORM_PS + f"""
|
||||
$ErrorActionPreference = "SilentlyContinue"
|
||||
Start-Sleep -Seconds 1
|
||||
$targetPid = {os.getpid()}
|
||||
while (Get-Process -Id $targetPid -ErrorAction SilentlyContinue) {{
|
||||
Start-Sleep -Milliseconds 500
|
||||
Start-Sleep -Milliseconds 300
|
||||
[System.Windows.Forms.Application]::DoEvents()
|
||||
}}
|
||||
$label.Text = "Kopiere aktualisierte Datei …"
|
||||
$form.Refresh()
|
||||
[System.Windows.Forms.Application]::DoEvents()
|
||||
Copy-Item -Path "{new_exe}" -Destination "{exe_path}" -Force
|
||||
$label.Text = "Fertig – Playtube wird neu gestartet …"
|
||||
$form.Refresh()
|
||||
[System.Windows.Forms.Application]::DoEvents()
|
||||
Start-Process -FilePath "{exe_path}"
|
||||
Start-Sleep -Seconds 2
|
||||
Start-Sleep -Milliseconds 800
|
||||
$form.Close()
|
||||
Remove-Item -Recurse -Force "{staging}" -ErrorAction SilentlyContinue
|
||||
"""
|
||||
self._spawn_windows_script(script, staging)
|
||||
|
||||
# Falls zenity installiert ist (auf den meisten Desktop-Distros vorhanden), waehrend
|
||||
# Wartezeit/Installation ein pulsierendes Fortschrittsfenster zeigen - rein optisch,
|
||||
# das Update funktioniert auch ohne (dann passiert der Neustart einfach unsichtbar).
|
||||
_POSIX_PROGRESS_HEADER = """#!/bin/sh
|
||||
ZPID=""
|
||||
if command -v zenity >/dev/null 2>&1; then
|
||||
tail -f /dev/null | zenity --progress --title="Playtube-Update" \
|
||||
--text="Playtube wird aktualisiert - bitte warten ..." --pulsate --no-cancel \
|
||||
>/dev/null 2>&1 &
|
||||
ZPID=$!
|
||||
fi
|
||||
"""
|
||||
_POSIX_PROGRESS_FOOTER = """
|
||||
[ -n "$ZPID" ] && kill "$ZPID" 2>/dev/null
|
||||
"""
|
||||
|
||||
def _install_patch_posix(self, new_binary: Path, install_dir: Path, staging: Path) -> None:
|
||||
exe_path = install_dir / _LINUX_BINARY_NAME
|
||||
script = f"""#!/bin/sh
|
||||
script = self._POSIX_PROGRESS_HEADER + f"""
|
||||
while kill -0 {os.getpid()} 2>/dev/null; do
|
||||
sleep 0.5
|
||||
done
|
||||
cp -f "{new_binary}" "{exe_path}"
|
||||
chmod +x "{exe_path}"
|
||||
""" + self._POSIX_PROGRESS_FOOTER + f"""
|
||||
nohup "{exe_path}" >/dev/null 2>&1 &
|
||||
rm -rf "{staging}"
|
||||
"""
|
||||
@@ -257,29 +348,45 @@ rm -rf "{staging}"
|
||||
|
||||
def _install_full_windows(self, source_dir: Path, install_dir: Path, staging: Path) -> None:
|
||||
exe_path = install_dir / _WINDOWS_BINARY_NAME
|
||||
script = f"""
|
||||
script = self._WINDOWS_PROGRESS_FORM_PS + f"""
|
||||
$ErrorActionPreference = "SilentlyContinue"
|
||||
Start-Sleep -Seconds 1
|
||||
$targetPid = {os.getpid()}
|
||||
while (Get-Process -Id $targetPid -ErrorAction SilentlyContinue) {{
|
||||
Start-Sleep -Milliseconds 500
|
||||
Start-Sleep -Milliseconds 300
|
||||
[System.Windows.Forms.Application]::DoEvents()
|
||||
}}
|
||||
robocopy "{source_dir}" "{install_dir}" /MIR /NFL /NDL /NJH /NJS /NC /NS /NP | Out-Null
|
||||
$label.Text = "Kopiere Programmdateien … (kann etwas dauern)"
|
||||
$form.Refresh()
|
||||
[System.Windows.Forms.Application]::DoEvents()
|
||||
# Ueber Start-Process (statt direktem Aufruf) gestartet und per Polling statt -Wait
|
||||
# abgewartet, damit die Fensternachrichtenschleife per DoEvents() weiterlaeuft -
|
||||
# sonst wuerde Windows das Fenster waehrend robocopy als "Keine Rueckmeldung" anzeigen.
|
||||
$roboArgs = @("{source_dir}", "{install_dir}", "/MIR", "/NFL", "/NDL", "/NJH", "/NJS", "/NC", "/NS", "/NP")
|
||||
$roboProc = Start-Process -FilePath "robocopy" -ArgumentList $roboArgs -WindowStyle Hidden -PassThru
|
||||
while (-not $roboProc.HasExited) {{
|
||||
Start-Sleep -Milliseconds 200
|
||||
[System.Windows.Forms.Application]::DoEvents()
|
||||
}}
|
||||
$label.Text = "Fertig – Playtube wird neu gestartet …"
|
||||
$form.Refresh()
|
||||
[System.Windows.Forms.Application]::DoEvents()
|
||||
Start-Process -FilePath "{exe_path}"
|
||||
Start-Sleep -Seconds 2
|
||||
Start-Sleep -Milliseconds 800
|
||||
$form.Close()
|
||||
Remove-Item -Recurse -Force "{staging}" -ErrorAction SilentlyContinue
|
||||
"""
|
||||
self._spawn_windows_script(script, staging)
|
||||
|
||||
def _install_full_posix(self, source_dir: Path, install_dir: Path, staging: Path) -> None:
|
||||
exe_path = install_dir / _LINUX_BINARY_NAME
|
||||
script = f"""#!/bin/sh
|
||||
script = self._POSIX_PROGRESS_HEADER + f"""
|
||||
while kill -0 {os.getpid()} 2>/dev/null; do
|
||||
sleep 0.5
|
||||
done
|
||||
rm -rf "{install_dir}"/*
|
||||
cp -a "{source_dir}"/. "{install_dir}"/
|
||||
chmod +x "{exe_path}"
|
||||
""" + self._POSIX_PROGRESS_FOOTER + f"""
|
||||
nohup "{exe_path}" >/dev/null 2>&1 &
|
||||
rm -rf "{staging}"
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user