From 578cda499a8a81e5d9d9dd7b3329b0501f7a0208 Mon Sep 17 00:00:00 2001 From: fojadrachi Date: Fri, 11 Sep 2026 19:13:13 +0200 Subject: [PATCH] Playtube: eigenstaendiger YouTube/YouTube-Music-Player mit Discord RPC und Auto-Update - PySide6/QtWebEngine mit persistentem Login-Profil (YouTube + YouTube Music Tabs) - Google-Login-Fix per Sec-CH-UA-Headern + Chrome-JS-Shim - Discord Rich Presence (eigener Thread, Titel/Fortschritt/Buttons) - Eigenes Branding (Taskmanager/Taskleiste) per AppUserModelID + PyInstaller-Packaging - Auto-Update ueber GitHub Releases (fojadrachi/Playtube) Co-Authored-By: Claude Sonnet 5 --- .gitignore | 7 + README.md | 113 +++++++++++++++ assets/icon.ico | Bin 0 -> 24164 bytes assets/icon.png | Bin 0 -> 19096 bytes config.json | 20 +++ main.py | 55 +++++++ packaging/build.ps1 | 61 ++++++++ packaging/playtube.spec | 51 +++++++ packaging/release.ps1 | 77 ++++++++++ packaging/version_info.txt | 32 +++++ playtube/__init__.py | 4 + playtube/app_id.py | 57 ++++++++ playtube/browser.py | 151 ++++++++++++++++++++ playtube/chrome_shim.py | 122 ++++++++++++++++ playtube/config.py | 86 +++++++++++ playtube/discord_rpc.py | 191 +++++++++++++++++++++++++ playtube/mainwindow.py | 285 +++++++++++++++++++++++++++++++++++++ playtube/media_probe.py | 63 ++++++++ playtube/updater.py | 197 +++++++++++++++++++++++++ requirements.txt | 2 + tools/generate_icon.py | 66 +++++++++ 21 files changed, 1640 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 assets/icon.ico create mode 100644 assets/icon.png create mode 100644 config.json create mode 100644 main.py create mode 100644 packaging/build.ps1 create mode 100644 packaging/playtube.spec create mode 100644 packaging/release.ps1 create mode 100644 packaging/version_info.txt create mode 100644 playtube/__init__.py create mode 100644 playtube/app_id.py create mode 100644 playtube/browser.py create mode 100644 playtube/chrome_shim.py create mode 100644 playtube/config.py create mode 100644 playtube/discord_rpc.py create mode 100644 playtube/mainwindow.py create mode 100644 playtube/media_probe.py create mode 100644 playtube/updater.py create mode 100644 requirements.txt create mode 100644 tools/generate_icon.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..734c424 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.venv/ +__pycache__/ +*.pyc +build/ +dist/ +run*.log +test_login*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..4efb78f --- /dev/null +++ b/README.md @@ -0,0 +1,113 @@ +# Playtube + +Eigenstaendige Desktop-App fuer YouTube & YouTube Music (kein Browser-Fenster, keine +Erweiterung) mit: + +- **Zwei Tabs** – YouTube und YouTube Music laufen parallel, Musik spielt im + Hintergrund weiter wenn du zu Videos wechselst. +- **Login/Premium** – eigenes, persistentes Profil (`%APPDATA%\Playtube`), einmal bei + Google anmelden reicht fuer beide Dienste. +- **Discord Rich Presence** – zeigt Titel, Kanal/Interpret, Fortschrittsbalken und + einen Link-Button in deinem Discord-Profil, sobald etwas laeuft. +- **System-Tray** – Fenster schliessen minimiert nur (Musik laeuft weiter), Rechtsklick + aufs Tray-Icon zum Beenden, Play/Pause/Skip direkt aus dem Menue. +- **Eigener Name** – erscheint als "Playtube" im Taskmanager, Fenstertitel, Alt-Tab + und (nach dem Packaging, siehe unten) im Lautstaerkemixer statt als "python". + +## Schnellstart (Entwicklung) + +```powershell +python -m venv .venv +.venv\Scripts\pip install -r requirements.txt +.venv\Scripts\python main.py +``` + +Beim ersten Start meldest du dich einmal in einem der beiden Tabs mit deinem +Google-Konto an (Symbol oben rechts auf youtube.com bzw. music.youtube.com) - der +Login bleibt danach dauerhaft gespeichert. + +## Discord Rich Presence einrichten + +Die Client-ID ist bereits in `config.json` eingetragen (`discord.client_id`). Falls du +sie aendern oder eine eigene Anwendung nutzen willst: + +1. Auf https://discord.com/developers/applications eine neue Application anlegen. +2. Die **Application ID** in `config.json` unter `discord.client_id` eintragen. +3. Optional, fuer eigene Icons: unter *Rich Presence → Art Assets* zwei Bilder mit den + exakten Schluesseln `youtube_logo` und `music_logo` hochladen (Playtube nutzt genau + diese Keys automatisch). Ohne hochgeladene Assets funktioniert die Presence trotzdem, + nur ohne Bild. +4. Discord muss auf demselben Rechner laufen, damit die Presence angezeigt wird. + +In `config.json` lassen sich zudem `update_interval_seconds` (Mindestabstand zwischen +Updates) und `show_idle_presence` (Status anzeigen, wenn gerade nichts laeuft) anpassen. + +## App als eigenstaendige Playtube.exe packen + +Fuer die volle Taskmanager-/Audiomixer-Markierung wird die App als eigene .exe gebaut +(ein via `python main.py` gestarteter Prozess heisst in Windows immer "python.exe" - +nur eine kompilierte .exe mit eigenem Namen und eigener Versionsinfo kann das aendern): + +```powershell +.venv\Scripts\pip install pyinstaller +powershell -ExecutionPolicy Bypass -File packaging\build.ps1 +``` + +Ergebnis liegt danach unter `dist\Playtube\Playtube.exe`. 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 +Hilfsprozesses (relevant fuer den Lautstaerkemixer) zusaetzlich +[rcedit](https://github.com/electron/rcedit/releases) als `packaging\rcedit.exe` +ablegen - das Skript nutzt es automatisch, wenn vorhanden. Ohne rcedit funktioniert +alles genauso, nur zeigt der Lautstaerkemixer fuer den Ton-Unterprozess je nach +Windows-Version eventuell weiterhin "QtWebEngineProcess" statt "Playtube" (rein +kosmetisch - Namensgebung von Chromium-Hilfsprozessen ist ein bekanntes, +Windows-versionsabhaengiges Verhalten, das selbst grosse Electron-Apps nur mit rcedit +o.ae. umgehen). + +## Automatische Updates + +Playtube prueft beim Start und danach alle `updates.check_interval_hours` Stunden +(Standard 6, in `config.json` einstellbar) die [GitHub Releases](https://github.com/fojadrachi/Playtube/releases) +des Projekts. Gibt es eine neuere Version als die laufende, fragt ein Dialog, ob sie +installiert werden soll: + +- **Gepackte `Playtube.exe`**: laedt das `.zip`-Asset des Release herunter und ersetzt + nach dem Beenden automatisch alle Dateien im Installationsordner, startet die App + danach selbst neu. +- **Entwicklungsmodus** (`python main.py`): fuehrt `git pull` + `pip install -r + requirements.txt` aus und startet den Python-Prozess neu. + +Auto-Update laesst sich in `config.json` unter `updates.enabled` deaktivieren. + +### Eine neue Version veroeffentlichen + +```powershell +powershell -ExecutionPolicy Bypass -File packaging\release.ps1 -Version 1.1.0 +``` + +Das Skript setzt die Versionsnummer, baut die `.exe`, packt sie als `.zip`, erstellt +Git-Tag `v1.1.0`, pusht zu `origin` (dein Repo unter +https://github.com/fojadrachi/Playtube) und legt automatisch ein GitHub Release mit +dem `.zip` als Anhang an - vorausgesetzt, entweder ist die +[GitHub CLI](https://cli.github.com/) installiert (`gh auth login` einmalig) oder die +Umgebungsvariable `GITHUB_TOKEN` enthaelt ein Personal Access Token mit `repo`-Rechten. +Ohne beides wird nur getaggt/gepusht und du legst das Release manuell unter +https://github.com/fojadrachi/Playtube/releases/new an (Zip-Datei aus `dist\` anhaengen). + +Alle Nutzer mit einer laufenden Playtube-Installation bekommen die neue Version dann +automatisch angeboten. + +## Hinweise + +- **"Dieser Browser ist unter Umstaenden nicht sicher" beim Google-Login:** Playtube + setzt einen echten Chrome-User-Agent inkl. passender `Sec-CH-UA`-Header, damit Google + das eingebettete Chromium (QtWebEngine) nicht als unsicheres WebView erkennt. Sollte + die Meldung dennoch erscheinen: alle Playtube-Fenster/-Prozesse schliessen und neu + starten (die Header greifen erst ab dem naechsten Prozessstart), notfalls einmal den + Profilordner `%APPDATA%\Playtube\webprofile` loeschen und neu anmelden. +- 4K/Premium-Videoqualitaet kann eingeschraenkt sein, da die Open-Source-Variante von + QtWebEngine kein Widevine-DRM mitbringt (Standard-Qualitaeten funktionieren normal). +- Icon/Branding-Bilder liegen unter `assets/` und wurden mit `tools/generate_icon.py` + erzeugt (Pillow) - bei Bedarf einfach eigenes `icon.ico`/`icon.png` dort ersetzen. diff --git a/assets/icon.ico b/assets/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..b9245f220e944d639fe4a20451ad0614f5a87254 GIT binary patch literal 24164 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 CVwh-p$X`q=y6T6s=y zegpdUk@=fn*ZLoD#>r{H|E;!t{EozNhAt{Br}`5MUu@O?BhMWIB34-RSH(wb2TyC` zyzIq8oyUs7;^)AQn@k!}teg->)42`4Zf2TE99z;H{kq!1;M<|@W-3WRXBd28*k@8+ zr(vi6iv#*kYRUay2c!plj0FIYk^kp_X7YC2j7JE^`t58lwy2X=gc+DMKH)8VKfz{3 zQ=y^zV#NXui?i3Ju+YJ+!U+Wg%LT_pME(1q;4clNg@gOq()cT->m(sxG+sImSz0{2 z5+8XFfXbho)VodXd#G)95zu*RLVmd=;P>vd&hCCTpq~(pc`_2WTu)jl_CuMvkAjj!XzU}lqLOdnR?&>{%{%i;Y#l{0&f!ip0P!m`$K&qz z5{A7+?8I-*~jQEmk8CIbe=%%>1`gI~<$CSc^x1nimgcth@i!4(hiS6uZB<5rfpE=y3 zi`JAS3_UuEn1E5`rhj8G@kK3hc(5v|965+T9oCaw_mo$lo}wW){!z6Z{x5;}Gz$U}u9f z9Y+uj$g!Ky@K=t~$8qe>3r&S@&z^NRDl?$_YmGIhZ4g)a zvRJ6tsQ*0;I*WXUM>5@v&yRIOsEI7B9@T#g?^v?R_>)GIzhA7^E_$(uw1pC{XY}iu7yVl3bxnL$LMr@&RdHaw zbyHIgq&Bn7l5f#L+1M+O4L!S6rU^%_K_THLwD4DSf9MzcwulDvAc9k`bb?QfDhXVy0LlOEyo_Rkj z(k@a1X8o@UZ6jLYzE6F(p6{_n>KUjlE&S&f&N`|g=Gmdif+ur3AbC>f{EVl*K@1@z&7`j5tOg8%5sI|K-&AYii9wUxq_l$TMn3d)J@#xZ@Qd`TLe!H>IUVpPlQy z6~}HoOAWnr{RSV#rMuw#tUF3eIdl8|(m0=v8&5K3ocfVIwWC#ct$c2BR?Lwg)27ZV z4aDn|(~G{D^>a@)+w3&BurWdcYOZd&skE7ZF~z01$+-7zzYu(lF$>pkIJ@mzgjQ*U z6%y#p+*T{sgG(Ew#@SBK{v}yq_gC-d2=EV<9aIVvYnBYo)(JV=e-by!*HI)sA2R+2 z`Ls}{7^C6?PZ= zD!EF2-;@5M!HqL7O%9z6*|Eu`EF??;&HbzpUB(ULnz?&YmB&!nb8(fv>@U314CvU3 z%0JG}-)wI_7rw0dN9PuyQ2+;xRiNreI~`@i>$OXR9;8$)IKqemES0r%j($NSmWz*H zh|e+C%&&E_UeBbwh#iDfK`+xjY=Z~;961+X%&^G)w_;A~HyV;mU#?a!Urq(tFO}Yz zwm(XtkcM;CjyFjEU*hL~`lbIHKdT%(wgCWCh5yCRgZxlug0Z*w>E`or5!BX5tH_Ls zNX$^0rjpr$$`9Iil+ELJ&|Dz;UL~Xasf<)KLFV?JK8ABJ(;=KKBtU@+ab%p(O*74pPoHCnhE4{+u5f7 z!(w2+fNc*vW^ba~dX_ClD*C-1h%�(26jRGhZ$t6RG@MJm}kB^kF~fyV};1r<6tT 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 literal 0 HcmV?d00001 diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..9b8d2a92d07bdb1a38245b788a2a4759687c4f6b GIT binary patch literal 19096 zcmd6PX;@R&7IqRQ5s@M)3RSEVT8mVfv`Yl}5Bw5mACg z0r6r)8Hx;vBT~hHfG7wFf{bD!V<6$2Z|##X)!}~6^ZohmpW8=H_SwVQYrX4T?>fiV zJJ@NC)f-EpP&8NBuh>YT(BMC5lrgICe_a9P{S?Y-#;O(n*&J}8{bJpfeEwQ9BT>bjyAerxhYX^VOf}Y zpzFf(r!RYxT+4!!G@5dLNK|q8qwG<^Ll2YV?wkAHtxH_s;^Ed+&7s$yIq!7i+r3x% z>wAe^*el-_IJx}R6-gkB!9v>Ul*3G~3*QBwfx+$VOLMyl1A(MwWCu)DM zFR}C7+oGFW(q3k}cqyzi2*!;5l3SgEFOh_MY`+Qda;8Map(pIA8Hm-Ir9 zB-5fFsP0tlFZ-TKgp7K9Z%Ok-EkQtc;NQJTU+h+u_3x~27`23lMcgB{nOYJ3s`}y5 zTFR4fjKO1kGh=DrvVku;*Lh7lW0e-m#I#r*21)$|VTJnPxiVv1R3Ih}d4%6D;zq!4 z+pQ`}8XFAKHniw;vZ66Hc(y83%Rfi^#VdV@nN3_F{$8)bDnq*2W^n(N@M)&0p>Dku z0}QPDXl~u>#&{lQY88*}oLCici0$E~q}U!FIk|SL>TFKU9owDKZv&`UruAzRB7o&( z@K8lx+}n5Q(2SP+ldoO+%Um%N?0rLKj+%IZ>I6e#I{DIv6nJP$*bO6bcIY$Da^ED5 zX20o9lkZ?++r^EJoKl)ek^9e#J(;ohO}`FxGNCGdcb$KkQxJsBa~a>QO%ZR?oGmoq zoNmAO_f91^!1D3410C^Xy@8jvxYJwrgcqj>w~d?afLoHQVo||NuE|Jyit^hpy{5EW z)1FYKNEWEUv=lQ(=jfsK$36#qliA%eX1P8^NOu6k>UH~Bdde^(9Luxq=2M6_ru{x0 z>v%4+(VC(Lg55StOWx@#dGQ3>`W!QPf~E*(AM8y9Ke^uhwjqVbuspS-uh8>n>9-4C z1iTe>+O%6Q?%6|}wH1W+Y-@>Ey5G!KODuZ&*_Cde4Io*0xl_F<5q0HV5DJ3ZS)0OEnm!~eI3sOHi`Y! zDJGn9mR2B~2mHq@rly2JBW%)g)PLBkM3EJ0cst+GgcD{C=x+BtVA>#gU1 z&7;qO*Y}(&6xhferH^Ri9eT%n>ql_Z*HkBfnLO&*^%GnK2oiTxWfgS>`RdIb+DxYz z`#MCIHniL1`i_IYGTx}^M-7CA{mgIQv07sH+`PYwqg!>@0+UZ*mQ?Rr@$4jd08^aP z`RcwpBC*NXf1@L%{PV}Rj4rb{8F<^5a?+6ql3$>Yd-aM}(=~AV?f3I}6|ifZ$@a^8 z{0KZMS(^qKcQo+7}6y^!dwg+4*W7@hqA0l_Q18eQ_6V zdzZ}bkB~cMMesWjs|>LToO^V2OmSyjXO#bYW160Q0s?U(oc@EgG(ZSsjEo{1pA0(FSG9f@lXIv`mgAp&+d{AS=Y^DNsrEd~O$m!4eZ4+Jwae#LDdI~` ze%p6ZumxwW2X{#k%|S&+Gs9jbT$ZHQ5t?+F^joQ z+!!;QcOdvJzaHdc8hBiergi-Pc2?Uzs$kLcUF6RED@PyE?Wa1YSG~hDtZhDH*w@>ZZ-{N^&!(9EkD+2$Rewo) zqvb6e5%DLQ3}UTp{~b-opIpucA9dZCa(V9O7w~9pe?ze@HM{Eb1Gb!5wSC9u4X*3S z<+P;FTU(Ip#%^uqZ~uA-z?bRbk+c7CX7rcFwD0ga=!QM8ZVgQw;WP89*GZzMSCy*nthF9i0N*-Q08v$FaO4=s zIdRZwA&51{bg=~hvjQg9E-|PslT4ioJvS5M#0FdB(b=4TKGqcB9sq&CK@^x3tz6;boC49>GW|Y4}nL$*l9LvSo&51tgtgI|+D*htm zt>KMhPqC;MiVz#{R*pv$cdO{Kg)xnWce;$JmCcMyd8Zw>YVE&+L)6vNPIJA(i4ZEd z#qPVFy(}7;Tl*{gWR_uX=6^Omx%|?C+7ZiznR#ggY`Lm3I?3M)RzCRnTS&j= zmF(^O24QS+;U9bB?zz)Ut9qt3o(Nx|(!syQTl=wi3itwo-!_lfgLx;j-rxIa(Wx8O z1%U{ceFPTy*J{ZM%fSmfm{mO-f8c@jYg*s+GnPY;qE=iG+08OB{zR2v^llGqF+l9v9SB++V2=Bn8m-I`UmPk?GlIOs6Kb^nN7M=gWEd)}P35AT38na(0z= zTXVxoYm4!JIwy=`v4*3G{7}HY=dtDzlX+W|uIPRWw+?Q={Qc9s4=8^C@tfor>4MC+ z!flz;=u~J3Qbbqt=-(%{+mC`p8-}1n1Dww@w`HqGVU(fBx;#fyHp{H)052Fh<)Cn8uIr-SX+>a~lV^TgH9*8Dxpf9B}D69yjoo_9IT|B$F$Q z!6XBih&a1N(dNg6#Jf>%&=b?I`^Pb@hDbj-LkZ|S?q{)}a_e?*d^)eI_sBXQrs_3vA;lOO*bqzJO=jYy`JSk{^i2QswhdSpqIT{#<;E&jV%}+VT`l;JW)DZV?d&s#qQCv223(w0&a2F6bIbF9Ysz`CV#<6U-pX0vW`=|vS&7s7-zY_Zf-j&%> zB#x8Bf@=w|c1(q3x?k!BlvYzQ1@iN~5yYji-U>R_f8eJJGRL+8IqXpTqxd3^sH?F9 z_*Xj)tgCb$Sn0jFLb|1t!CFX?S#mmzRfxw5u^wNl*c-dX#2h4i0(b6SX6UG7tL|;p zIZSZcYv50HsnduFcZ_9^EUgqlmNlNJUuT`KyxcgikP#&(N7~iJ28>xXuYB^xG)rPA7<=k&3*sVMwxvM#mlQ!@xA6)EYYY7Il0Mhp z&}kQPl_k0Gq)3f6g2z8ss&P}htkd;ZY@pla^c&5#Qe-U!hN?!t1#@7}1P7-rNbiU2hrr*IRyi)u55hmBr$!Vkj}LYcnd_)yH~X|qK}3ztrO*x-Ym~%gf_PDFENRI zRN^WPU*5xB0Q`xA=K!!UoGXZobDu3Ss0`Dg$f7imU8&*mPvQf$OWe~ScVw(V5W6fz zOfS!NhJhS+gfwFFtUB4wx`hw22YgH2j!V5`MJvz2PW#3sj58O~&1K18{lt2>s7zvW zO{Xgk1ui(SWIx8vdJ~MXucI=>H@`n8nJCI0NPAt4N4##J=P!Jxjjrfhje}F|9cBze zN%yWoJboR|Lg1zR-`AY}VqL6)4>$TgqdV6jFkp5UiSQS2fnf#BrVEOhMh~J)M{ZI+FAkU#K)AhQ=of2n{ z>BX-aHL%lq-U!2R$V!-z+q4Z@S`4i3WioxwujcGNJ5K<-i0t-0TXJ$Js>n&R9Bs*~ zoLYCXK#9m9tl)ZI#J~k#+LvUh2eGJJ|7pH7vJ~*#-XwczAHO1#NEOwGl;t+|O@A|N zm8gXE#)69V%S02%vM9+B(Iv}jm4C3v4*pYj$Sx5#UwM_2FMCyHfYv78IgthtYe7AU zYFG>MOB&OOsx9VJ4>-C+NFuzaOM8DK3Z_qjWg0_;Q>Qac`rTi7-I+0Lte=hb$b5CK zEArDQ7XXHHguZ#|>&>80BQLxv;eT{jpl739r;%xSdY~y#I>stkr!Tb`$};l0s;cab zr-x2L_DM3ty;y!8AZX05giDZ8!sOwM$+6GW+IQ42nnWX4B|&?{BR>Kp*ybE|>7|k^ z^PO2SU`>cicMwf7R18+aTRgey=c>Tg(t_8S0G4wSvt^^7u&H=I7aT&3Cz?s+udoB% zir>R+=&VfW_o5Z_%6!jH7ssOOTMLM4p}Bnwzac>T)J^M=S%m?&K-Qr_TA_h1l#6Ha zxN5TAf$bN`1CW!pf-RwaZ=@B(c*R8*7}1DOOAqoiQ@Uy_WclB1cnohOWE|FbJXLT> z@&pUo@uhCmfMZ+#D|5(wy4ElW8*@2L%or$$zPuI51W4y!M=;R7&rhV$w%#n#BeNwje(Dmjk5ux5@qiz1` zqyBEkuuJ*1ztPA7^4V3n83^tIbS7!KKuN8w(xehSk<7DS zChbF2vh6F#l8$5A7LhsCkBaSu-JBz-Hl{i4Klczg9Ce9?8!$iYzD>#9R~*CMw>Vne zcz=}6Hpn>;JS(JwUcN{AJ;DkI?p4s0WLLLNGpAzsYvaj3+VL2_bZW^s&r-s)*LNFG ziB;OVz*azwcvEV(r=6qHYS0Tf+Fd`VE@7mV=ITO{1NB7Q-F8_tFS6CS%YoxFW06xQFxI(-xnNwJzM;Ef?F z6EEP1CQGtazBA*!i+$IWj$L~mIB`{te{twHH;kuCHNxJ;;ECEsl4X#n-22v$UO&}- z9ZC8^b(WraaNx0HyyZab*Fr6sh>A-ESQc<+WvX2zP^0v=e<-LhtdBlV9 z-f{l{Xf@YbNcXnZf@}2Rt}0%B@i_qcy2x$5xN%4|mdYf2rbsN{vZ1#fXt6%TU$X2G z>CTf=A|e=p;7|z3a>(&(cHAS{0d-W)7>>?v2=0r zU#&5Y@v1+C63N)*;v($NXyTIVZY7>8P;e9?kahjXH9YpS8s)rdn32uC4!#Bv(Fyzw z0=az8gT+oXeDhfrFEW{+8xmWuTCy56FaYy3d=srg1Cr^4XmpE2!PH+%O3jm zTQCo9t1IBlu!6H`_vTs~K`ldzW&5bzf|yJZXRDmoeZSc&J?af{9|m~%Ny%!)N{ z_0=ADCHm)<-~S8na_}ch+xiN*U0ulwtvcxwqXwk=8-OtCRW&aVH+9+=p}Upnqg?(j zQ2RPt`@*}Eh>tJrChXqU8%itz7H)v z|K*q;9u#2(OZxbgv&05EUXk>kH7W47=hw8XJn$y+PK9{Od}-zCkSX%$yQgjVb2lJQ zD3;55Xa2)qQmY&g7QRr4icvukPzSHo6AE+0hl;F?hPTndS_{($wx7e%>_o2<{tB>7NT5J0P0H69ADTL}?tQ0-`(wt10$8wq7!mE@ogyAY8Qu0nDJtlGi=DTAF{C zZLGd=%@M|X=f{{^eS?7())dKn_@msZBVJtXgcD>Jx0TRs$aKUbJeN1hg-eqmS-+}% zhZDfru_*cnp938tHR6^9l|~#0ubTO_z-=37z~-m^$Ro}|k@IUq0`fA}ts~zIiImoF z{yDVcj^kEkkWK`mdsDNw)AV}X#g=_PhYuHsCr(nwTe=SwykinhdKHz;i_2_(pT+Y0 zX>r-1%_AObw>r@Qc;t3ez+3uQ8PGuqq}a`--SlRyF3zO;Es#{V*|$4CW>;U3#T-Rg z>{Kv$ahCMSB?5LEV#2xkCz(Y5$k!|@tGB0(tiw4X7?NM5*u`I5Cqc2 zT!vVrA&J~O+YtLvqDYpZk|hO;eKVvA)bh@K-01$(p5w7ut)o`kG;l#C;xM^BU}KdL z#7iAYCh1ECYnzCTma1^APz^x#D85=gOi}FgEh>Z83F-IHng-BaW_&ZN?+IN~CJ0jo zHbVoXJZ=mg`@FG`Yde=pYycY%+N?k|^1={=(=Zg`l0fDikR|UNMABFh*IxfH6?#!;$?0DX~C+cG@<|Q)^TIL6ue$K9GY* zCc-364elLa*S2Mj2)7;li)08ucJn&&k$+mZP296ocAjL#w;3uT0!;u4OwtK)?%yZ2 z&VpbP&}sO?iq1+W`l)$oH2lXqa|4LS?it4EvEx|NE2kuNz4>CEPy7Ox)ktSZB@PNJ z$9dn8DnwGH1^1DrH3NVMNN@gH`7;sUt#teujq3ucA3~w}L397zmaNC zpnycq4G9WSJ?61z*BDtf?V{e!-W0WGmR3DY%V?fQ;Iy%V4o}3h=(Vr$O42Z^>iH6u zHF*rM{kLrmh4hV7>px^Q2W<#T z&(e1Mrv8T$9{C#qwW<>}V21`7wQHN0woyE;QAyrZq#YtA&{L+su7G2&M7lF{{W49@ z%bJ)=`?k-lP0|r>D>>Hj+)j$6@n34lME@bxVQ!4wCMq7L?<*;RZ$&16mf9{SX}>WN z^7mkfJ~503gutE(C?K7f6Sm%4%=jy@_BcQ!x!(1ubZK9khiM-$bVs>cp>`?e-q%Xi z#Mc~D#(NV;;VH6|@7%i@!IXx^H`~S&`X2%m+s zqKP?r8=AUHBV#JP@v-7W3-QZ)%z4FJ_|iwoN>yQ1r!VZSFjEeN1{XA4g9dA_W?w9 z9+6QFG)!;#BdFZ97*8hQPTo0(iJ9^4uC%S zf=uf{k-%AhR(M*dQF^R-TXE;PuYh8Zhaof#zt(%Y#x@rANL6=Kpt!X?W)Jsn501YT z1$=GD8{`1)o9bmkP*+1W^yIb;OGl5vBclu;lhh@B)t-BP%Ia%e8l;o((AqaxaxF|Q z6$8(SO+&63$(eL1XsFB{oS?&zw?r<`1R?-aRe;QhAnVAg@4)bWc?17%-Wo#BvL z(hI-}+9o(Q&`gjk<(%$5=d9u27}pOC?*9!O$q;Bs`?I5eU$j*>}=V<-f?B{{MXKOBb%*$#=%vg3laOZG!aMlO{`_G-*9C}-T}MygC;E6zC?G;Nr>MAW7&i8S&Oib@{!+-WN6Kzpq#d*L`9 zQFjsNeD%`;R|{lotM@4*(Q2fk%37COds9WWW?u&)ic8%X;Qyn~CwKL^1bu0f0PdQ5 zOi}PJXfx8nL-%!{tPrpuS>cSX25HfsR>azXha1Zp5`%N(#7K_dDnTriHKk!`2G6iWrK2q9m?$r5bfh2ai->CD_wum;6 zozR=FI%03bvB?s8o^*`0w>ARhLuwS1AY=x6E0avu?gHQMWts0I6*J5f5DjMW{?k!*JFKA)hEQpg{?$$`~}GQ;Wiifkgh@H+9W}P zr0+MBWsY|^f1jMFG$@z$ujQNu4tasBH?!i06(_)jBeB~LcP$_amS#K$_W=ztWETs$ z91wzfd|04T>ZCT|0CI8~a3xZ8Nq(LLkJIELfg|ljq28ySKe=3lcm`zQXfXCc zGR_ay%~ACyro7o}f00{2oih6}`YvSUo2rvLWG@aFY)U{^|p$}CkkQfXXr5HAe z*^rOY6(Xl6RxPC59nnkJ=0WB3>R1wMDFjpex7TpkwgTHe=%(R9I|QdNQ2TYKbXfT0@!hJmW6AhU7-Ln;HA)d)F~y$J-& zVE{=AdU7F2tuY;~MndVcLUt$DRt4qM#Jg(9RD8n*t7(m;|DM`>mp|+h6GfC?I)-FM z$lQk-VC3Sbg1}lYpauB9e{z9YSUjSNO!t02sLKAxFdvm6b-Miqw|5wpu4PR$Y0+b$ zjTkL0da?Y2A zORV%!Gg;|&u;T-*RO5zmvxh~nNRF1d_JosNG^Qa;{nyJ)QG;0?YV87{q>!Ej4#nHWqKmwuv-_=LrlEl4wpOOY~D+fdYpzzdnk5!dlVp2CgN+2Z) zB=~viVsT`~TBQ*J>`@Yy-U343m$qCs)2IxDn`HaY2V$EwuZZfkpOG20g3JisK0=&3 z=q@3OhZBGF66Q9aRzbxS5ZSGt8ND7Cd_7wAK1w2$CMB9zT012nQ6}n3Liyn+9av=^ zA%HsgW7AJg@c&tgR41S{n|wibvMBnIMs5IitjrZIATWp!kOw>1MyZ)ri+$lbodpaf z@2&b^4HZz6{Okjh>gXU=-%g5V0O@{c3)_wo1zFsIeiQux5_MS&Di9W(>L=UTMmOw; zYE4h%3c~vD2?Ti{v%QJ*g9`P|NY}B66ceGEs&}*+{?MONt_%t9NDuk3{-lgmV~A)? z=K24NL3MCDeTrE)AX^{p!Q$)3n;^aaxJ!tGk#2t16hPJ~&&^!P$c>zmqPvhuRFSym zw_QxGO`_AbTYT{ysLQC~Kbj83LV0L(Y7C15l>#JusI_d7^mosLv7k#P+jzo2W$K`4 zRxv-^OPtZO^roQpUCRMi9#%!!e!nG5Xpnds<@O);K_wN>5EECmh@G1($zsk6z^h8c z6xOis9BwbGb{Quz*w)EbYE0+Od2OV3G49)!u0JI{Ub`Foe3r0yBosp(#~ChFswngzFoy9EFCg` zMaROB>^5tvE^YxfpQ0vN1`?>jJ{;%?8*H~C)SSJdi*i1byfJ!2hs3ekj z+WUJ&(*xiR#f)HpaYT_OFWVW*>R(DivTeZQxQ^~0Q7USD>VPM+3BE#5AA05EZEP*I zLfzZ$|W7Q7H0?6Q_Q(FRsP8j%d*$e(9hW zG;^p`y3kM}Q(#R0=KA-R66z0gWT?F*hBeK!?vuQzF>xEMT{CEv3~v zPLc-VKExXiI~08}`x{XUktCutlNSxpFp>$#+u;YTiiz3p$e>F4i~Olwq+n#2j2q!a zc!K|~aUd+LsOwP&;iqO^ayvk2eLO9NRLOkeZMPO$4hUq^D6G#+TeMMEG-Z5uG9Pz| z@f{8XgIw^)jo?2wOC}PyDyQ~-{<#RZi3Lr=USoH0Vy3 zt_f}A{-Y{<>?%;B;1M*utnD>u7v1*`^{PM%eOl>+WMUPkE$Bbmh;1Ncb070ySB2bG0qq7UZwdm84r*}t1jfkb zn6f+A8?I_V0hm+4vyV0tvnSx!ps(5jNu)k8#$Cp4C9#jrjI0|EP(b*|rDE&hi}jhI z0}mfFys$;TDAZfwpwx^UZXZYDqVd>OO>0k`1UV-&l5YX}1z6IXCu?;Mb6=_M1g#$V z3`u^Wc3e`qYenDR)H7FIHphYj!i#p&twUFV{G$e5j!Z(L2gKzceqHf1WW95imR|}J z%Zv}5C$|QnEd%)nH^>i``1|<&afi{P&bt&g(6Wl@G)N@LS;H(t9u~s7er|rzcR`L* zaIoLdf&RzF@#d1^J`QNFyFa714qG*vC>6M)9= zjE}IszpiuJG9!{iL{%v=hk=i1K(loOz$^c<@BSHsXE;($wyiN{d*56*6B^1IqKINPuP4%cU zn-Jw+oVY!Wv#1sN>pmLAh?=_?!)736>2(83Y-w;Z70O>+TP9azb=yL|7_~pz;JO?M zC)V5GF&$pYP*!2X^#?v~Tk6g>3n;m&dvw(%2n{-BF|YPl#PIp!l%eX$eQ{iqB-H_v z0;3ZK-xe|Jdgsy#T>(&70P&FqNbTNluJ(r->V&f9O$K;f%`fkL@W|l8hP_l1}|0!7A*1 zX_vp+gts^C6cmKwTN005pE>TkD{}UtY)y6ieH~SdZR05vr!nXwAKv!1OZ$|hekU}U zZuuea>)!6TPfCx1O{*&N!!;$w>I54X_wFh_;yi$@Tgo!LcLN$f;D4)DI;^<<{m#Sx E2P0t)w*UYD literal 0 HcmV?d00001 diff --git a/config.json b/config.json new file mode 100644 index 0000000..140852f --- /dev/null +++ b/config.json @@ -0,0 +1,20 @@ +{ + "app_name": "Playtube", + "discord": { + "enabled": true, + "client_id": "1544058029190938774", + "update_interval_seconds": 15, + "show_idle_presence": true + }, + "updates": { + "enabled": true, + "check_interval_hours": 6 + }, + "start_tab": "youtube", + "home_youtube": "https://www.youtube.com/", + "home_music": "https://music.youtube.com/", + "window": { + "width": 1366, + "height": 860 + } +} diff --git a/main.py b/main.py new file mode 100644 index 0000000..754891c --- /dev/null +++ b/main.py @@ -0,0 +1,55 @@ +"""Playtube - eigenstaendiger YouTube- & YouTube-Music-Player mit Discord Rich Presence. + +Start: python main.py (oder die gepackte Playtube.exe, siehe packaging/README) +""" +from __future__ import annotations + +import os +import sys +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 + +app_id.set_app_user_model_id() +app_id.configure_webengine_process_path() + +# High-DPI, sauberes GPU-Verhalten und (in Kombination mit den Sec-CH-UA-Headern in +# playtube/browser.py) ein moeglichst "normales" Chrome-Fingerprint, damit Google-Login +# das eingebettete QtWebEngine nicht als Embedded-WebView blockiert. +os.environ.setdefault( + "QTWEBENGINE_CHROMIUM_FLAGS", + "--disable-features=WinRetrieveSuggestionsOnlyOnDemand " + "--disable-blink-features=AutomationControlled", +) + +from PySide6.QtCore import Qt # noqa: E402 +from PySide6.QtGui import QIcon # noqa: E402 +from PySide6.QtWidgets import QApplication # noqa: E402 + +from playtube.mainwindow import MainWindow # noqa: E402 + + +def main() -> int: + QApplication.setAttribute(Qt.ApplicationAttribute.AA_ShareOpenGLContexts, True) + app = QApplication(sys.argv) + app.setApplicationName(APP_NAME) + app.setApplicationDisplayName(APP_NAME) + app.setOrganizationName(APP_NAME) + app.setQuitOnLastWindowClosed(False) # Tray haelt die App am Leben + + icon_path = Path(__file__).resolve().parent / "assets" / "icon.ico" + if icon_path.exists(): + app.setWindowIcon(QIcon(str(icon_path))) + + config = load_config() + window = MainWindow(config) + window.show() + + return app.exec() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/packaging/build.ps1 b/packaging/build.ps1 new file mode 100644 index 0000000..71d7d83 --- /dev/null +++ b/packaging/build.ps1 @@ -0,0 +1,61 @@ +# Baut Playtube.exe mit PyInstaller und sorgt dafuer, dass auch der QtWebEngine- +# Hilfsprozess (der eigentlich den Ton ausgibt) den Namen "Playtube" traegt, damit er +# im Taskmanager und - so weit von der jeweiligen Windows-Version respektiert - im +# Lautstaerkemixer nicht als "QtWebEngineProcess" auftaucht. +# +# Aufruf (im Projekt-Root, mit aktivierter venv): +# powershell -ExecutionPolicy Bypass -File packaging\build.ps1 +# +# Optional: rcedit (https://github.com/electron/rcedit) im PATH oder unter +# packaging\rcedit.exe ablegen, dann werden Icon + Versionsinfo des umbenannten +# Hilfsprozesses ebenfalls auf "Playtube" gesetzt (sonst bleibt intern "QtWebEngineProcess" +# stehen, nur der Dateiname aendert sich - Windows zeigt dann meist trotzdem den +# Dateinamen "PlaytubeHelper" an). + +$ErrorActionPreference = "Stop" +$root = Split-Path -Parent $PSScriptRoot +Set-Location $root + +Write-Host "==> Baue Playtube.exe mit PyInstaller ..." -ForegroundColor Cyan +pyinstaller packaging\playtube.spec --noconfirm --distpath dist --workpath build +if ($LASTEXITCODE -ne 0) { throw "PyInstaller-Build fehlgeschlagen." } + +$distDir = Join-Path $root "dist\Playtube" +$internalDir = Join-Path $distDir "_internal" +$searchRoot = if (Test-Path $internalDir) { $internalDir } else { $distDir } + +$engineProcess = Get-ChildItem -Path $searchRoot -Recurse -Filter "QtWebEngineProcess.exe" -ErrorAction SilentlyContinue | Select-Object -First 1 +if (-not $engineProcess) { + Write-Warning "QtWebEngineProcess.exe wurde im Build nicht gefunden - Audio-/Taskmanager-Branding des Hilfsprozesses wird uebersprungen." +} else { + $helperPath = Join-Path $engineProcess.Directory.FullName "PlaytubeHelper.exe" + Copy-Item $engineProcess.FullName $helperPath -Force + Write-Host "==> Hilfsprozess kopiert nach $helperPath" -ForegroundColor Cyan + + $rcedit = Get-Command rcedit -ErrorAction SilentlyContinue + $rceditLocal = Join-Path $PSScriptRoot "rcedit.exe" + if ($rcedit) { + $rceditExe = $rcedit.Source + } elseif (Test-Path $rceditLocal) { + $rceditExe = $rceditLocal + } else { + $rceditExe = $null + } + + if ($rceditExe) { + Write-Host "==> Patche Icon/Versionsinfo des Hilfsprozesses mit rcedit ..." -ForegroundColor Cyan + & $rceditExe $helperPath --set-icon "$root\assets\icon.ico" + & $rceditExe $helperPath --set-version-string "FileDescription" "Playtube" + & $rceditExe $helperPath --set-version-string "ProductName" "Playtube" + & $rceditExe $helperPath --set-version-string "CompanyName" "Playtube" + & $rceditExe $helperPath --set-version-string "OriginalFilename" "PlaytubeHelper.exe" + } else { + Write-Host "Hinweis: rcedit nicht gefunden - Hilfsprozess heisst jetzt 'PlaytubeHelper.exe'," -ForegroundColor Yellow + Write-Host "seine interne Versionsinfo/Icon bleibt aber 'QtWebEngineProcess'. Fuer volles" -ForegroundColor Yellow + Write-Host "Branding: rcedit von https://github.com/electron/rcedit/releases laden und" -ForegroundColor Yellow + Write-Host "als packaging\rcedit.exe ablegen, dann dieses Skript erneut ausfuehren." -ForegroundColor Yellow + } +} + +Write-Host "" +Write-Host "Fertig! Die App liegt unter dist\Playtube\Playtube.exe" -ForegroundColor Green diff --git a/packaging/playtube.spec b/packaging/playtube.spec new file mode 100644 index 0000000..3ad77dd --- /dev/null +++ b/packaging/playtube.spec @@ -0,0 +1,51 @@ +# PyInstaller-Spec fuer Playtube. +# Bauen mit: pyinstaller packaging/playtube.spec --noconfirm +# (aus dem Projekt-Root ausfuehren, oder packaging/build.ps1 benutzen) +import sys +from pathlib import Path + +block_cipher = None + +ROOT = Path(SPECPATH).resolve().parent + +a = Analysis( + [str(ROOT / "main.py")], + pathex=[str(ROOT)], + binaries=[], + datas=[ + (str(ROOT / "assets"), "assets"), + (str(ROOT / "config.json"), "."), + ], + hiddenimports=["pypresence"], + hookspath=[], + runtime_hooks=[], + excludes=[], + noarchive=False, +) + +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name="Playtube", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + console=False, + icon=str(ROOT / "assets" / "icon.ico"), + version=str(ROOT / "packaging" / "version_info.txt"), +) + +coll = COLLECT( + exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=False, + name="Playtube", +) diff --git a/packaging/release.ps1 b/packaging/release.ps1 new file mode 100644 index 0000000..a7654a4 --- /dev/null +++ b/packaging/release.ps1 @@ -0,0 +1,77 @@ +# Baut eine neue Playtube-Version, taggt und veroeffentlicht sie als GitHub Release +# unter https://github.com/fojadrachi/Playtube - Playtube.exe erkennt neue Releases +# danach automatisch (siehe playtube/updater.py) und bietet dem Nutzer die Installation +# per Update-Dialog an. +# +# Aufruf (im Projekt-Root, mit aktivierter venv): +# powershell -ExecutionPolicy Bypass -File packaging\release.ps1 -Version 1.1.0 +# +# Voraussetzungen: +# - git remote "origin" zeigt auf https://github.com/fojadrachi/Playtube +# - Entweder die GitHub-CLI "gh" ist installiert und eingeloggt (gh auth login), +# oder die Umgebungsvariable GITHUB_TOKEN enthaelt ein Personal Access Token mit +# "repo"-Rechten (dann wird die GitHub REST API direkt per curl angesprochen). + +param( + [Parameter(Mandatory = $true)] + [string]$Version, + [string]$Notes = "" +) + +$ErrorActionPreference = "Stop" +$root = Split-Path -Parent $PSScriptRoot +Set-Location $root + +if ($Version -notmatch '^\d+\.\d+\.\d+$') { + throw "Version muss im Format X.Y.Z angegeben werden, z.B. 1.1.0" +} +$tag = "v$Version" + +Write-Host "==> Setze Version auf $Version in playtube/__init__.py" -ForegroundColor Cyan +$initFile = Join-Path $root "playtube\__init__.py" +(Get-Content $initFile) -replace '__version__ = ".*"', "__version__ = `"$Version`"" | Set-Content $initFile -Encoding utf8 + +Write-Host "==> Baue Playtube.exe ..." -ForegroundColor Cyan +& (Join-Path $PSScriptRoot "build.ps1") + +$distDir = Join-Path $root "dist\Playtube" +if (-not (Test-Path $distDir)) { throw "Build fehlgeschlagen: $distDir nicht gefunden." } + +$zipName = "Playtube-$tag-win64.zip" +$zipPath = Join-Path $root "dist\$zipName" +if (Test-Path $zipPath) { Remove-Item $zipPath -Force } + +Write-Host "==> Packe $zipName ..." -ForegroundColor Cyan +Compress-Archive -Path $distDir -DestinationPath $zipPath + +Write-Host "==> Commit + Tag $tag ..." -ForegroundColor Cyan +git add -A +git commit -m "Release $tag" --allow-empty +git tag -a $tag -m "Playtube $tag" + +Write-Host "==> Push zu origin ..." -ForegroundColor Cyan +git push origin HEAD +git push origin $tag + +$gh = Get-Command gh -ErrorAction SilentlyContinue +if ($gh) { + Write-Host "==> Erstelle GitHub Release mit gh CLI ..." -ForegroundColor Cyan + if ([string]::IsNullOrWhiteSpace($Notes)) { $Notes = "Playtube $tag" } + gh release create $tag $zipPath --title "Playtube $Version" --notes $Notes +} elseif ($env:GITHUB_TOKEN) { + Write-Host "==> Erstelle GitHub Release ueber die REST API ..." -ForegroundColor Cyan + $repo = "fojadrachi/Playtube" + $body = @{ tag_name = $tag; name = "Playtube $Version"; body = $(if ($Notes) { $Notes } else { "Playtube $tag" }) } | ConvertTo-Json + $headers = @{ Authorization = "Bearer $($env:GITHUB_TOKEN)"; Accept = "application/vnd.github+json" } + $release = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/releases" -Method Post -Headers $headers -Body $body -ContentType "application/json" + $uploadUrl = $release.upload_url -replace '\{\?name,label\}', "?name=$zipName" + Invoke-RestMethod -Uri $uploadUrl -Method Post -Headers ($headers + @{ "Content-Type" = "application/zip" }) -InFile $zipPath | Out-Null + Write-Host "Release veroeffentlicht: $($release.html_url)" -ForegroundColor Green +} else { + Write-Warning "Weder 'gh' CLI noch GITHUB_TOKEN gefunden - Tag wurde gepusht, aber kein GitHub Release erstellt." + Write-Warning "Entweder 'winget install GitHub.cli' + 'gh auth login' ausfuehren und dieses Skript erneut starten," + Write-Warning "oder manuell unter https://github.com/fojadrachi/Playtube/releases/new ein Release fuer Tag '$tag' anlegen und '$zipPath' als Asset anhaengen." +} + +Write-Host "" +Write-Host "Fertig." -ForegroundColor Green diff --git a/packaging/version_info.txt b/packaging/version_info.txt new file mode 100644 index 0000000..eddd93a --- /dev/null +++ b/packaging/version_info.txt @@ -0,0 +1,32 @@ +# UTF-8 +# Windows-Versionsressource fuer PyInstaller (--version-file). +# Legt fest, wie die gebaute Playtube.exe im Explorer, Taskmanager und (soweit von +# Windows respektiert) im Lautstaerkemixer benannt wird: "Playtube" statt "python". +VSVersionInfo( + ffi=FixedFileInfo( + filevers=(1, 0, 0, 0), + prodvers=(1, 0, 0, 0), + mask=0x3F, + flags=0x0, + OS=0x40004, + fileType=0x1, + subtype=0x0, + date=(0, 0) + ), + kids=[ + StringFileInfo( + [ + StringTable( + u'040704B0', + [StringStruct(u'CompanyName', u'Playtube'), + StringStruct(u'FileDescription', u'Playtube'), + StringStruct(u'FileVersion', u'1.0.0.0'), + StringStruct(u'InternalName', u'Playtube'), + StringStruct(u'LegalCopyright', u''), + StringStruct(u'OriginalFilename', u'Playtube.exe'), + StringStruct(u'ProductName', u'Playtube'), + StringStruct(u'ProductVersion', u'1.0.0.0')]) + ]), + VarFileInfo([VarStruct(u'Translation', [1031, 1200])]) + ] +) diff --git a/playtube/__init__.py b/playtube/__init__.py new file mode 100644 index 0000000..76f1451 --- /dev/null +++ b/playtube/__init__.py @@ -0,0 +1,4 @@ +"""Playtube - ein eigenstaendiger YouTube- & YouTube-Music-Player mit Discord Rich Presence.""" + +__app_name__ = "Playtube" +__version__ = "1.0.0" diff --git a/playtube/app_id.py b/playtube/app_id.py new file mode 100644 index 0000000..36196ca --- /dev/null +++ b/playtube/app_id.py @@ -0,0 +1,57 @@ +"""Branding-Helfer: eigener Prozessname/Icon im Taskmanager, in der Taskleiste und +(so weit von Windows respektiert) im Lautstaerkemixer. + +Wichtig: Diese Funktionen muessen VOR dem Import von QtWebEngineWidgets/QtWebEngineCore +aufgerufen werden, weil QtWebEngine seinen Sub-Prozess (QtWebEngineProcess.exe) beim +Import-Zeitpunkt vorbereitet. +""" +from __future__ import annotations + +import os +import sys +from pathlib import Path + +from .config import APP_AUMID + + +def set_app_user_model_id() -> None: + """Setzt die Windows AppUserModelID fuer diesen Prozess. + + Das sorgt dafuer, dass Windows die App in Taskleiste/Alt-Tab/Benachrichtigungen + als eigenstaendige Anwendung fuehrt (eigenes Icon, eigene Gruppierung) statt als + generisches "Python". + """ + if sys.platform != "win32": + return + try: + import ctypes + + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(APP_AUMID) + except Exception: + # Auf aelteren Windows-Versionen oder ohne shell32 einfach ignorieren. + pass + + +def configure_webengine_process_path() -> None: + """Wenn ein umbenannter QtWebEngineProcess (siehe packaging/build.ps1) neben der + .exe liegt, wird QtWebEngine angewiesen diesen zu benutzen statt des Standard- + "QtWebEngineProcess.exe". Dadurch zeigt auch der Sub-Prozess, der tatsaechlich den + Ton wiedergibt, im Taskmanager/Lautstaerkemixer den Playtube-Namen statt + "QtWebEngineProcess". + + Nur relevant fuer gepackte (PyInstaller-)Builds. Im Entwicklungsmodus (python + main.py) wird der Standardprozess von PySide6 benutzt - das ist voellig normal + und hat keinen Einfluss auf die Funktion der App. + """ + if sys.platform != "win32" or not getattr(sys, "frozen", False): + return + + exe_dir = Path(sys.executable).resolve().parent + candidates = [ + exe_dir / "PlaytubeHelper.exe", + exe_dir / "_internal" / "PlaytubeHelper.exe", + ] + for candidate in candidates: + if candidate.exists(): + os.environ["QTWEBENGINEPROCESS_PATH"] = str(candidate) + return diff --git a/playtube/browser.py b/playtube/browser.py new file mode 100644 index 0000000..faa24e5 --- /dev/null +++ b/playtube/browser.py @@ -0,0 +1,151 @@ +"""Eingebetteter Browser-Tab fuer YouTube bzw. YouTube Music, mit persistentem Login- +Profil (eigener Datenordner, kein System-Browser-Profil) und periodischem Auslesen +der aktuellen Wiedergabe fuer Discord Rich Presence.""" +from __future__ import annotations + +from PySide6.QtCore import QTimer, Signal, QUrl +from PySide6.QtWebEngineCore import ( + QWebEnginePage, + QWebEngineProfile, + QWebEngineScript, + QWebEngineSettings, + QWebEngineUrlRequestInterceptor, +) +from PySide6.QtWebEngineWidgets import QWebEngineView + +from .chrome_shim import CHROME_FULL, CHROME_MAJOR, CHROME_SHIM_JS +from .config import profile_dir +from .media_probe import MEDIA_PROBE_JS, NEXT_TRACK_JS, PREV_TRACK_JS, TOGGLE_PLAYBACK_JS + +# Chrome-Versionsnummer, die exakt zur tatsaechlich in QtWebEngine eingebetteten +# Chromium-Version passt (siehe QWebEngineCore.qWebEngineChromiumVersion()). Legacy- +# User-Agent, die "Sec-CH-UA" Client-Hints (Header) UND navigator.userAgentData (JS, +# siehe chrome_shim.py) muessen konsistent dieselbe Version + Marke ("Google Chrome") +# melden - sonst erkennt Google-Login den Browser als nicht vertrauenswuerdiges +# Embedded-WebView und blockiert die Anmeldung mit "Dieser Browser oder diese App ist +# unter Umstaenden nicht sicher". +_CHROME_VERSION = CHROME_FULL +_CHROME_MAJOR = CHROME_MAJOR + +_USER_AGENT = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + f"(KHTML, like Gecko) Chrome/{_CHROME_VERSION} Safari/537.36" +) + +_SEC_CH_UA = ( + f'"Not(A:Brand";v="99", "Google Chrome";v="{_CHROME_MAJOR}", ' + f'"Chromium";v="{_CHROME_MAJOR}"' +).encode("ascii") +_SEC_CH_UA_FULL_VERSION_LIST = ( + f'"Not(A:Brand";v="99.0.0.0", "Google Chrome";v="{_CHROME_VERSION}", ' + f'"Chromium";v="{_CHROME_VERSION}"' +).encode("ascii") + + +class _ChromeBrandingInterceptor(QWebEngineUrlRequestInterceptor): + """Ergaenzt/korrigiert die Sec-CH-UA Client-Hint-Header auf jeder Anfrage, damit + sie zum gesetzten User-Agent passen (echtes QtWebEngine meldet dort normalerweise + nur "Chromium", ohne die Marke "Google Chrome" - genau daran erkennt Googles + Login-Seite ein Embedded-WebView und blockiert die Anmeldung).""" + + def interceptRequest(self, info) -> None: # noqa: N802 (Qt-Override) + info.setHttpHeader(b"sec-ch-ua", _SEC_CH_UA) + info.setHttpHeader(b"sec-ch-ua-full-version-list", _SEC_CH_UA_FULL_VERSION_LIST) + info.setHttpHeader(b"sec-ch-ua-mobile", b"?0") + info.setHttpHeader(b"sec-ch-ua-platform", b'"Windows"') + info.setHttpHeader(b"sec-ch-ua-platform-version", b'"15.0.0"') + + +_shared_profile: QWebEngineProfile | None = None +_shared_interceptor: _ChromeBrandingInterceptor | None = None + + +def get_shared_profile() -> QWebEngineProfile: + """Ein gemeinsames, persistentes Profil fuer alle Tabs, damit ein einmaliges + Google-Login fuer YouTube und YouTube Music gleichermassen gilt.""" + global _shared_profile, _shared_interceptor + if _shared_profile is None: + _shared_profile = QWebEngineProfile("playtube-profile") + _shared_profile.setPersistentStoragePath(str(profile_dir() / "storage")) + _shared_profile.setCachePath(str(profile_dir() / "cache")) + _shared_profile.setPersistentCookiesPolicy( + QWebEngineProfile.PersistentCookiesPolicy.ForcePersistentCookies + ) + _shared_profile.setHttpUserAgent(_USER_AGENT) + _shared_profile.setHttpAcceptLanguage("de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7") + + # Referenz muss gehalten werden, sonst sammelt Python das Objekt vorzeitig ein. + _shared_interceptor = _ChromeBrandingInterceptor() + _shared_profile.setUrlRequestInterceptor(_shared_interceptor) + + # JS-seitiger "Chrome-Shim" (window.chrome, navigator.userAgentData, Plugins) - + # muss vor jedem Seiten-JS laufen, daher DocumentCreation + MainWorld. + shim_script = QWebEngineScript() + shim_script.setName("playtube-chrome-shim") + shim_script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentCreation) + shim_script.setWorldId(QWebEngineScript.ScriptWorldId.MainWorld) + shim_script.setRunsOnSubFrames(True) + shim_script.setSourceCode(CHROME_SHIM_JS) + _shared_profile.scripts().insert(shim_script) + return _shared_profile + + +class BrowserTab(QWebEngineView): + """Ein WebEngine-Tab mit Medien-Ueberwachung fuer Discord Rich Presence.""" + + mediaInfoChanged = Signal(dict) + titleUpdated = Signal(str) + + def __init__(self, home_url: str, parent=None): + super().__init__(parent) + self._home_url = home_url + + page = QWebEnginePage(get_shared_profile(), self) + self.setPage(page) + + settings = page.settings() + settings.setAttribute(QWebEngineSettings.WebAttribute.JavascriptEnabled, True) + settings.setAttribute(QWebEngineSettings.WebAttribute.PlaybackRequiresUserGesture, False) + settings.setAttribute(QWebEngineSettings.WebAttribute.FullScreenSupportEnabled, True) + settings.setAttribute(QWebEngineSettings.WebAttribute.LocalStorageEnabled, True) + settings.setAttribute(QWebEngineSettings.WebAttribute.ScreenCaptureEnabled, True) + settings.setAttribute(QWebEngineSettings.WebAttribute.JavascriptCanOpenWindows, True) + + page.fullScreenRequested.connect(self._on_full_screen_requested) + + self.load(QUrl(home_url)) + + self._poll_timer = QTimer(self) + self._poll_timer.setInterval(2000) + self._poll_timer.timeout.connect(self._poll_media_state) + self._poll_timer.start() + + self.titleChanged.connect(self.titleUpdated.emit) + + def go_home(self) -> None: + self.load(QUrl(self._home_url)) + + def toggle_playback(self) -> None: + self.page().runJavaScript(TOGGLE_PLAYBACK_JS) + + def next_track(self) -> None: + self.page().runJavaScript(NEXT_TRACK_JS) + + def previous_track(self) -> None: + self.page().runJavaScript(PREV_TRACK_JS) + + def _on_full_screen_requested(self, request) -> None: + # Erlaubt echtes Fullscreen-Video (z.B. per YouTube-Fullscreen-Button). + request.accept() + window = self.window() + if request.toggleOn(): + window.showFullScreen() + else: + window.showNormal() + + def _poll_media_state(self) -> None: + self.page().runJavaScript(MEDIA_PROBE_JS, self._on_media_probe_result) + + def _on_media_probe_result(self, result) -> None: + if isinstance(result, dict): + self.mediaInfoChanged.emit(result) diff --git a/playtube/chrome_shim.py b/playtube/chrome_shim.py new file mode 100644 index 0000000..6b6c7f6 --- /dev/null +++ b/playtube/chrome_shim.py @@ -0,0 +1,122 @@ +"""JavaScript-"Shim", der QtWebEngine (echtes, aber unbranded Chromium) fuer +JS-seitige Fingerprint-Checks wie echtes Google Chrome aussehen laesst. + +Hintergrund: Google blockiert Logins aus WebViews/Embedded-Browsern serverseitig +("Dieser Browser oder diese App ist unter Umstaenden nicht sicher"). Die dazu +verwendeten Signale sind u.a.: + - Fehlendes `window.chrome` Objekt (QtWebEngine ist ein Open-Source-Chromium-Build + ohne Google-Branding, hat das normalerweise nicht). + - `navigator.userAgentData` (JS Client Hints), das bei QtWebEngine "Chromium" statt + "Google Chrome" als Marke meldet. + - Fehlende/leere `navigator.plugins` (echtes Chrome hat u.a. immer den eingebauten + PDF-Viewer als Plugin gelistet). + +Die HTTP-Header-Variante (Sec-CH-UA) wird zusaetzlich in browser.py gesetzt - Google +prueft offenbar beides, JS-seitig und Header-seitig, daher muessen beide konsistent +"Google Chrome" melden. + +Dies aendert nur, WIE sich der eingebettete Browser bei Feature-Detection meldet, es +manipuliert keine fremden Konten und automatisiert keine Logins - es ermoeglicht dem +Nutzer lediglich, sich in der eigenen App mit dem eigenen Google-Konto anzumelden, +so wie es in vielen anderen Desktop-Clients (z.B. Mail-/Chat-Sammler-Apps) ueblich ist. +""" + +CHROME_MAJOR = "140" +CHROME_FULL = "140.0.0.0" + +CHROME_SHIM_JS = f""" +(function() {{ + try {{ + Object.defineProperty(navigator, 'webdriver', {{ get: () => undefined, configurable: true }}); + }} catch (e) {{}} + + try {{ + if (typeof window.chrome === 'undefined') {{ window.chrome = {{}}; }} + window.chrome.runtime = window.chrome.runtime || {{}}; + window.chrome.loadTimes = window.chrome.loadTimes || function() {{ + var now = Date.now() / 1000; + return {{ + commitLoadTime: now, connectionInfo: 'h2', + finishDocumentLoadTime: now, finishLoadTime: now, + firstPaintAfterLoadTime: 0, firstPaintTime: now, + navigationType: 'Other', npnNegotiatedProtocol: 'h2', + requestTime: now - 1, startLoadTime: now - 1, + wasAlternateProtocolAvailable: false, + wasFetchedViaSpdy: true, wasNpnNegotiated: true + }}; + }}; + window.chrome.csi = window.chrome.csi || function() {{ + return {{ onloadT: Date.now(), pageT: Date.now(), startE: Date.now(), tran: 15 }}; + }}; + window.chrome.app = window.chrome.app || {{ + isInstalled: false, + InstallState: {{ DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' }}, + RunningState: {{ CANNOT_RUN: 'cannot_run', READY_TO_RUN: 'ready_to_run', RUNNING: 'running' }} + }}; + }} catch (e) {{}} + + try {{ + var brands = [ + {{ brand: 'Not(A:Brand', version: '99' }}, + {{ brand: 'Google Chrome', version: '{CHROME_MAJOR}' }}, + {{ brand: 'Chromium', version: '{CHROME_MAJOR}' }} + ]; + var fullBrands = [ + {{ brand: 'Not(A:Brand', version: '99.0.0.0' }}, + {{ brand: 'Google Chrome', version: '{CHROME_FULL}' }}, + {{ brand: 'Chromium', version: '{CHROME_FULL}' }} + ]; + var uaData = {{ + brands: brands, + mobile: false, + platform: 'Windows', + getHighEntropyValues: function(hints) {{ + var full = {{ + architecture: 'x86', bitness: '64', brands: brands, + fullVersionList: fullBrands, mobile: false, model: '', + platform: 'Windows', platformVersion: '15.0.0', + uaFullVersion: '{CHROME_FULL}', wow64: false + }}; + var result = {{}}; + (hints || Object.keys(full)).forEach(function(k) {{ + if (k in full) {{ result[k] = full[k]; }} + }}); + return Promise.resolve(result); + }}, + toJSON: function() {{ return {{ brands: brands, mobile: false, platform: 'Windows' }}; }} + }}; + Object.defineProperty(navigator, 'userAgentData', {{ get: () => uaData, configurable: true }}); + }} catch (e) {{}} + + try {{ + var fakePlugin = {{ + name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer', + description: 'Portable Document Format', length: 1 + }}; + var fakePlugins = [fakePlugin]; + fakePlugins.item = function(i) {{ return fakePlugins[i]; }}; + fakePlugins.namedItem = function() {{ return fakePlugin; }}; + fakePlugins.refresh = function() {{}}; + Object.defineProperty(navigator, 'plugins', {{ get: () => fakePlugins, configurable: true }}); + Object.defineProperty(navigator, 'mimeTypes', {{ get: () => [], configurable: true }}); + }} catch (e) {{}} + + try {{ + Object.defineProperty(navigator, 'languages', {{ + get: () => ['de-DE', 'de', 'en-US', 'en'], configurable: true + }}); + }} catch (e) {{}} + + try {{ + var origQuery = window.navigator.permissions && window.navigator.permissions.query; + if (origQuery) {{ + window.navigator.permissions.query = function(params) {{ + if (params && params.name === 'notifications') {{ + return Promise.resolve({{ state: Notification.permission }}); + }} + return origQuery(params); + }}; + }} + }} catch (e) {{}} +}})(); +""" diff --git a/playtube/config.py b/playtube/config.py new file mode 100644 index 0000000..c40a459 --- /dev/null +++ b/playtube/config.py @@ -0,0 +1,86 @@ +"""Laden/Speichern der Benutzerkonfiguration (config.json im Projekt- bzw. App-Datenordner).""" +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path +from typing import Any + +APP_NAME = "Playtube" +APP_AUMID = "Playtube.DesktopClient" # Windows AppUserModelID + +DEFAULT_CONFIG: dict[str, Any] = { + "app_name": APP_NAME, + "discord": { + "enabled": True, + # Deine Discord Application Client-ID (discord.com/developers/applications). + "client_id": "1544058029190938774", + "update_interval_seconds": 15, + # Wenn nichts laeuft: Idle-Status anzeigen statt Presence komplett zu leeren. + "show_idle_presence": True, + }, + "updates": { + "enabled": True, + "check_interval_hours": 6, + }, + "start_tab": "youtube", # "youtube" oder "music" + "home_youtube": "https://www.youtube.com/", + "home_music": "https://music.youtube.com/", + "window": {"width": 1366, "height": 860}, +} + + +def _app_data_dir() -> Path: + """Ordner fuer persistente Daten (Login-Profil, Config) - auch im gepackten .exe stabil.""" + base = os.environ.get("APPDATA") or str(Path.home()) + d = Path(base) / APP_NAME + d.mkdir(parents=True, exist_ok=True) + return d + + +def _config_path() -> Path: + # Im Entwicklungsmodus liegt config.json direkt im Projektordner (leicht editierbar), + # im gepackten Build im APPDATA-Ordner. + if getattr(sys, "frozen", False): + return _app_data_dir() / "config.json" + return Path(__file__).resolve().parent.parent / "config.json" + + +def _deep_merge(base: dict, override: dict) -> dict: + result = dict(base) + for key, value in override.items(): + if isinstance(value, dict) and isinstance(result.get(key), dict): + result[key] = _deep_merge(result[key], value) + else: + result[key] = value + return result + + +def load_config() -> dict[str, Any]: + path = _config_path() + if path.exists(): + try: + with open(path, "r", encoding="utf-8") as f: + user_cfg = json.load(f) + return _deep_merge(DEFAULT_CONFIG, user_cfg) + except (json.JSONDecodeError, OSError): + pass + save_config(DEFAULT_CONFIG) + return dict(DEFAULT_CONFIG) + + +def save_config(cfg: dict[str, Any]) -> None: + path = _config_path() + try: + with open(path, "w", encoding="utf-8") as f: + json.dump(cfg, f, indent=2, ensure_ascii=False) + except OSError: + pass + + +def profile_dir() -> Path: + """Ordner fuer das persistente Browser-Profil (Login-Session, Cookies, Local Storage).""" + d = _app_data_dir() / "webprofile" + d.mkdir(parents=True, exist_ok=True) + return d diff --git a/playtube/discord_rpc.py b/playtube/discord_rpc.py new file mode 100644 index 0000000..9b5a624 --- /dev/null +++ b/playtube/discord_rpc.py @@ -0,0 +1,191 @@ +"""Discord Rich Presence Integration. + +Laeuft in einem eigenen Thread (pypresence macht blockierende IPC-Aufrufe), damit die +Oberflaeche nie haengt, egal ob Discord laeuft, gerade startet oder gar nicht +installiert ist. Verbindungsfehler werden abgefangen und in Intervallen erneut +versucht, ohne die App zu beeintraechtigen. +""" +from __future__ import annotations + +import queue +import time +from typing import Any + +from PySide6.QtCore import QThread + +from .config import APP_NAME + +# Asset-Keys, die (optional) unter discord.com/developers/applications -> Rich +# Presence -> Art Assets hochgeladen werden koennen. Fehlen sie, zeigt Discord +# einfach kein Bild an - es gibt keinen Fehler. +ASSET_YOUTUBE = "youtube_logo" +ASSET_MUSIC = "music_logo" +ASSET_DEFAULT = "logo" +ASSET_PLAY = "play_icon" +ASSET_PAUSE = "pause_icon" + +_RECONNECT_DELAY_SECONDS = 10 + + +def _truncate(text: str | None, limit: int = 128, fallback: str = "") -> str: + text = (text or fallback).strip() + if not text: + text = fallback + if len(text) > limit: + text = text[: limit - 1].rstrip() + "…" + return text + + +def build_presence_payload(info: dict[str, Any], session_start: int) -> dict[str, Any]: + """Baut das update()-Payload fuer pypresence aus den vom Browser-Tab gelieferten + Medien-Informationen.""" + is_music = bool(info.get("isMusic")) + playing = bool(info.get("playing")) + title = _truncate(info.get("title"), fallback="Unbekannter Titel") + default_state = "YouTube Music" if is_music else "YouTube" + state = _truncate(info.get("subtitle"), fallback=default_state) + + payload: dict[str, Any] = { + "details": title, + "state": state, + "large_image": ASSET_MUSIC if is_music else ASSET_YOUTUBE, + "large_text": "YouTube Music" if is_music else "YouTube", + "small_image": ASSET_PLAY if playing else ASSET_PAUSE, + "small_text": "Spielt" if playing else "Pausiert", + } + + duration = info.get("duration") or 0 + current_time = info.get("currentTime") or 0 + if playing: + start_ts = int(time.time() - current_time) + payload["start"] = start_ts + if duration and duration > 0: + payload["end"] = start_ts + int(duration) + + url = info.get("url") + if url and isinstance(url, str) and url.startswith("http"): + label = "In YouTube Music öffnen" if is_music else "Auf YouTube ansehen" + payload["buttons"] = [{"label": label, "url": url}] + + return payload + + +def build_idle_payload(session_start: int) -> dict[str, Any]: + return { + "details": f"Bei {APP_NAME}", + "state": "Stöbert gerade", + "large_image": ASSET_DEFAULT, + "large_text": APP_NAME, + "start": session_start, + } + + +class DiscordRPCWorker(QThread): + """Eigener Thread, der die Verbindung zu Discord haelt und Presence-Updates + debounced (max. 1 Update pro `interval` Sekunden) versendet.""" + + def __init__(self, client_id: str, interval: float, show_idle: bool, parent=None): + super().__init__(parent) + self._client_id = client_id + self._interval = max(5.0, float(interval)) + self._show_idle = show_idle + self._queue: "queue.Queue[dict | None | object]" = queue.Queue(maxsize=1) + self._running = True + self._connected = False + self._presence = None + self._session_start = int(time.time()) + + def submit_media_info(self, info: dict[str, Any] | None) -> None: + """Neuester bekannter Zustand (None = nichts spielt / idle).""" + self._replace_queue(info if info is not None else _IDLE_SENTINEL) + + def _replace_queue(self, item) -> None: + try: + self._queue.get_nowait() + except queue.Empty: + pass + try: + self._queue.put_nowait(item) + except queue.Full: + pass + + def stop(self) -> None: + self._running = False + self._replace_queue(_STOP_SENTINEL) + + def run(self) -> None: + last_sent = 0.0 + while self._running: + try: + item = self._queue.get() + except Exception: + break + + if item is _STOP_SENTINEL or not self._running: + break + + wait = self._interval - (time.time() - last_sent) + if wait > 0: + time.sleep(wait) + # Waehrend des Wartens evtl. neuere Daten uebernehmen (debounce). + try: + while True: + item = self._queue.get_nowait() + except queue.Empty: + pass + + if item is _STOP_SENTINEL or not self._running: + break + + self._ensure_connected() + if self._connected: + self._send(item) + last_sent = time.time() + else: + # Nicht verbunden -> nicht sinnlos oft versuchen. + time.sleep(_RECONNECT_DELAY_SECONDS) + + self._cleanup() + + def _ensure_connected(self) -> None: + if self._connected: + return + try: + from pypresence import Presence + + self._presence = Presence(self._client_id) + self._presence.connect() + self._connected = True + except Exception: + self._connected = False + + def _send(self, item) -> None: + if self._presence is None: + return + try: + if item is _IDLE_SENTINEL: + if self._show_idle: + self._presence.update(**build_idle_payload(self._session_start)) + else: + self._presence.clear() + else: + self._presence.update(**build_presence_payload(item, self._session_start)) + except Exception: + # Discord evtl. geschlossen worden -> beim naechsten Mal neu verbinden. + self._connected = False + + def _cleanup(self) -> None: + if self._presence is not None: + try: + self._presence.clear() + self._presence.close() + except Exception: + pass + + +class _Sentinel: + __slots__ = () + + +_IDLE_SENTINEL = _Sentinel() +_STOP_SENTINEL = _Sentinel() diff --git a/playtube/mainwindow.py b/playtube/mainwindow.py new file mode 100644 index 0000000..da4f07f --- /dev/null +++ b/playtube/mainwindow.py @@ -0,0 +1,285 @@ +from __future__ import annotations + +import os +import sys +from pathlib import Path +from typing import Any + +from PySide6.QtCore import QTimer, QUrl, Qt +from PySide6.QtGui import QAction, QIcon +from PySide6.QtWidgets import ( + QLineEdit, + QMainWindow, + QMenu, + QMessageBox, + QSystemTrayIcon, + QTabWidget, + QToolBar, + QWidget, +) + +from . import __version__ as APP_VERSION +from .browser import BrowserTab +from .config import APP_NAME +from .discord_rpc import DiscordRPCWorker +from .updater import UpdateChecker, UpdateInstaller + +ASSETS_DIR = Path(__file__).resolve().parent.parent / "assets" + + +class MainWindow(QMainWindow): + def __init__(self, config: dict[str, Any]): + super().__init__() + self._config = config + self.setWindowTitle(APP_NAME) + self.resize(config["window"]["width"], config["window"]["height"]) + + icon_path = ASSETS_DIR / "icon.ico" + self._app_icon = QIcon(str(icon_path)) if icon_path.exists() else QIcon() + if not self._app_icon.isNull(): + self.setWindowIcon(self._app_icon) + + self._tabs = QTabWidget(self) + self._tabs.setDocumentMode(True) + self.setCentralWidget(self._tabs) + + self._youtube_tab = BrowserTab(config["home_youtube"], self) + self._music_tab = BrowserTab(config["home_music"], self) + self._tabs.addTab(self._youtube_tab, "YouTube") + self._tabs.addTab(self._music_tab, "YouTube Music") + self._tabs.setCurrentIndex(0 if config.get("start_tab") != "music" else 1) + + self._youtube_tab.mediaInfoChanged.connect(self._on_media_info) + self._music_tab.mediaInfoChanged.connect(self._on_media_info) + + self._latest_media: dict[int, dict] = {} + + self._build_toolbar() + self._build_tray() + + self._rpc_worker: DiscordRPCWorker | None = None + discord_cfg = config.get("discord", {}) + if discord_cfg.get("enabled") and discord_cfg.get("client_id"): + self._rpc_worker = DiscordRPCWorker( + client_id=str(discord_cfg["client_id"]), + interval=discord_cfg.get("update_interval_seconds", 15), + show_idle=discord_cfg.get("show_idle_presence", True), + ) + self._rpc_worker.start() + + self._update_checker: UpdateChecker | None = None + self._update_installer: UpdateInstaller | None = None + self._pending_update_version: str | None = None + self._setup_auto_update() + + # ------------------------------------------------------------------ UI + + def _build_toolbar(self) -> None: + toolbar = QToolBar("Navigation", self) + toolbar.setMovable(False) + self.addToolBar(toolbar) + + back_action = QAction("◀", self) + back_action.triggered.connect(lambda: self._current_tab().back()) + toolbar.addAction(back_action) + + forward_action = QAction("▶", self) + forward_action.triggered.connect(lambda: self._current_tab().forward()) + toolbar.addAction(forward_action) + + reload_action = QAction("⟳", self) + reload_action.triggered.connect(lambda: self._current_tab().reload()) + toolbar.addAction(reload_action) + + home_action = QAction("⌂", self) + home_action.triggered.connect(lambda: self._current_tab().go_home()) + toolbar.addAction(home_action) + + toolbar.addSeparator() + + self._url_bar = QLineEdit(self) + self._url_bar.setPlaceholderText("Suche oder URL eingeben …") + self._url_bar.returnPressed.connect(self._navigate_to_url_bar) + toolbar.addWidget(self._url_bar) + + self._tabs.currentChanged.connect(self._on_tab_changed) + self._youtube_tab.urlChanged.connect(lambda u: self._sync_url_bar(self._youtube_tab, u)) + self._music_tab.urlChanged.connect(lambda u: self._sync_url_bar(self._music_tab, u)) + + def _build_tray(self) -> None: + self._tray = QSystemTrayIcon(self) + if not self._app_icon.isNull(): + self._tray.setIcon(self._app_icon) + self._tray.setToolTip(APP_NAME) + + menu = QMenu() + show_action = menu.addAction("Anzeigen") + show_action.triggered.connect(self._show_and_raise) + + play_pause_action = menu.addAction("Wiedergabe umschalten") + play_pause_action.triggered.connect(lambda: self._current_tab().toggle_playback()) + + next_action = menu.addAction("Nächster Titel") + next_action.triggered.connect(lambda: self._current_tab().next_track()) + + prev_action = menu.addAction("Vorheriger Titel") + prev_action.triggered.connect(lambda: self._current_tab().previous_track()) + + menu.addSeparator() + quit_action = menu.addAction("Beenden") + quit_action.triggered.connect(self._quit) + + self._tray.setContextMenu(menu) + self._tray.activated.connect(self._on_tray_activated) + self._tray.show() + + # --------------------------------------------------------------- Slots + + def _current_tab(self) -> BrowserTab: + return self._tabs.currentWidget() + + def _navigate_to_url_bar(self) -> None: + text = self._url_bar.text().strip() + if not text: + return + if " " in text or ("." not in text and "://" not in text): + url = QUrl("https://www.google.com/search?q=" + QUrl.toPercentEncoding(text).data().decode()) + else: + url = QUrl.fromUserInput(text) + self._current_tab().setUrl(url) + + def _sync_url_bar(self, tab: BrowserTab, url: QUrl) -> None: + if self._current_tab() is tab: + self._url_bar.setText(url.toString()) + + def _on_tab_changed(self, index: int) -> None: + self._url_bar.setText(self._current_tab().url().toString()) + + def _on_media_info(self, info: dict) -> None: + sender = self.sender() + self._latest_media[id(sender)] = info + + # Update Fenstertitel + Discord: bevorzugt der Tab, der gerade wirklich + # abspielt, sonst der aktuell sichtbare Tab. + playing_info = next((i for i in self._latest_media.values() if i.get("playing")), None) + active_info = playing_info or self._latest_media.get(id(self._current_tab())) + + if active_info and active_info.get("title"): + self.setWindowTitle(f"{active_info['title']} – {APP_NAME}") + else: + self.setWindowTitle(APP_NAME) + + if self._rpc_worker is not None: + if active_info and active_info.get("hasVideo"): + self._rpc_worker.submit_media_info(active_info) + else: + self._rpc_worker.submit_media_info(None) + + def _on_tray_activated(self, reason) -> None: + if reason == QSystemTrayIcon.ActivationReason.Trigger: + self._show_and_raise() + + def _show_and_raise(self) -> None: + self.showNormal() + self.raise_() + self.activateWindow() + + def _quit(self) -> None: + if self._rpc_worker is not None: + self._rpc_worker.stop() + self._rpc_worker.wait(2000) + self._tray.hide() + from PySide6.QtWidgets import QApplication + + QApplication.quit() + + # -------------------------------------------------------------- Events + + def closeEvent(self, event) -> None: + # Beim Schliessen in den Tray minimieren statt beenden (Musik laeuft weiter). + event.ignore() + self.hide() + self._tray.showMessage( + APP_NAME, + f"{APP_NAME} läuft im Hintergrund weiter. Rechtsklick auf das Tray-Icon zum Beenden.", + self._app_icon if not self._app_icon.isNull() else QSystemTrayIcon.MessageIcon.Information, + 3000, + ) + + # -------------------------------------------------------------- Auto-Update + + def _setup_auto_update(self) -> None: + updates_cfg = self._config.get("updates", {}) + if not updates_cfg.get("enabled", True): + return + + self._check_for_updates() + + interval_ms = max(1, int(updates_cfg.get("check_interval_hours", 6))) * 60 * 60 * 1000 + self._update_timer = QTimer(self) + self._update_timer.setInterval(interval_ms) + self._update_timer.timeout.connect(self._check_for_updates) + self._update_timer.start() + + def _check_for_updates(self) -> None: + if self._update_checker is not None and self._update_checker.isRunning(): + return + self._update_checker = UpdateChecker(self) + self._update_checker.updateAvailable.connect(self._on_update_available) + self._update_checker.start() + + def _on_update_available(self, version: str, notes: str, download_url: str) -> None: + if self._pending_update_version == version: + return # Nutzer hat diese Version schon abgelehnt/wird schon gefragt + self._pending_update_version = version + + self._show_and_raise() + notes_preview = (notes[:400] + "…") if len(notes) > 400 else notes + text = f"Eine neue Version von {APP_NAME} ist verfügbar: {version}\n(aktuell installiert: {APP_VERSION})" + if notes_preview: + text += f"\n\n{notes_preview}" + text += "\n\nJetzt installieren?" + + answer = QMessageBox.question( + self, + f"{APP_NAME}-Update verfügbar", + text, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.Yes, + ) + if answer == QMessageBox.StandardButton.Yes: + self._start_update_install(download_url) + + def _start_update_install(self, download_url: str) -> None: + self._tray.setToolTip(f"{APP_NAME} – Update wird installiert …") + self._update_installer = UpdateInstaller(download_url, self) + self._update_installer.progress.connect( + lambda msg: self._tray.setToolTip(f"{APP_NAME} – {msg}") + ) + self._update_installer.finished_ok.connect(self._on_update_finished) + self._update_installer.failed.connect(self._on_update_failed) + self._update_installer.start() + + def _on_update_finished(self) -> None: + 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. + self._quit() + else: + QMessageBox.information( + self, + APP_NAME, + "Update installiert. Die App wird jetzt neu gestartet.", + ) + self._restart_dev_process() + + def _on_update_failed(self, error: str) -> None: + self._tray.setToolTip(APP_NAME) + QMessageBox.warning(self, f"{APP_NAME}-Update fehlgeschlagen", error) + + def _restart_dev_process(self) -> None: + if self._rpc_worker is not None: + self._rpc_worker.stop() + self._rpc_worker.wait(2000) + python = sys.executable + os.execv(python, [python] + sys.argv) diff --git a/playtube/media_probe.py b/playtube/media_probe.py new file mode 100644 index 0000000..bce2d99 --- /dev/null +++ b/playtube/media_probe.py @@ -0,0 +1,63 @@ +"""JavaScript, das periodisch in die YouTube- bzw. YouTube-Music-Seite injiziert wird, +um Titel/Interpret/Wiedergabestatus fuer die Discord-Rich-Presence auszulesen.""" + +MEDIA_PROBE_JS = r""" +(function() { + function txt(sel) { + var el = document.querySelector(sel); + return el ? el.textContent.trim() : null; + } + var video = document.querySelector('video'); + var isMusic = location.hostname.indexOf('music.youtube.com') !== -1; + var title = null, subtitle = null, thumbnail = null; + + if (isMusic) { + title = txt('.title.ytmusic-player-bar') || txt('ytmusic-player-bar .title'); + subtitle = txt('.byline.ytmusic-player-bar') || txt('ytmusic-player-bar .byline'); + var imgM = document.querySelector('ytmusic-player-bar img, .image.ytmusic-player-bar img'); + thumbnail = imgM ? imgM.src : null; + } else { + var t = document.title.replace(/ - YouTube$/, ''); + title = t || null; + subtitle = txt('ytd-video-owner-renderer #channel-name a') + || txt('#owner #channel-name a') + || txt('#channel-name a') + || txt('ytd-channel-name#channel-name a'); + var imgY = document.querySelector('link[rel="image_src"]'); + thumbnail = imgY ? imgY.href : null; + } + + return { + isMusic: isMusic, + title: title, + subtitle: subtitle, + 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, + hasVideo: !!video + }; +})(); +""" + +TOGGLE_PLAYBACK_JS = r""" +(function() { + var video = document.querySelector('video'); + if (video) { video.paused ? video.play() : video.pause(); } +})(); +""" + +NEXT_TRACK_JS = r""" +(function() { + var btn = document.querySelector('.next-button, ytmusic-player-bar #next-button button, tp-yt-paper-icon-button.next-button'); + if (btn) { btn.click(); } +})(); +""" + +PREV_TRACK_JS = r""" +(function() { + var btn = document.querySelector('.previous-button, ytmusic-player-bar #previous-button button, tp-yt-paper-icon-button.previous-button'); + if (btn) { btn.click(); } +})(); +""" diff --git a/playtube/updater.py b/playtube/updater.py new file mode 100644 index 0000000..8eeb28c --- /dev/null +++ b/playtube/updater.py @@ -0,0 +1,197 @@ +"""Auto-Update ueber GitHub Releases (https://github.com/fojadrachi/Playtube). + +Ablauf: + 1. UpdateChecker prueft im Hintergrund die GitHub-Releases-API auf eine neuere + Version als die aktuell laufende (playtube.__version__). + 2. Bei Fund fragt die UI (siehe mainwindow.py) nach Bestaetigung. + 3. UpdateInstaller installiert das Update: + - Gepackte Playtube.exe: laedt das Windows-Release-Zip herunter, entpackt es + und laesst ein kurzes PowerShell-Skript (nach Prozessende) den Installations- + ordner per robocopy /MIR ersetzen und die App neu starten. + - Entwicklungsmodus (python main.py): fuehrt 'git pull' + 'pip install -r + requirements.txt' aus, die App startet sich danach selbst neu (os.execv). +""" +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +import tempfile +import urllib.error +import urllib.request +import zipfile +from pathlib import Path +from typing import Any + +from PySide6.QtCore import QThread, Signal + +from . import __version__ as CURRENT_VERSION + +GITHUB_REPO = "fojadrachi/Playtube" +_API_URL = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest" +_USER_AGENT = "Playtube-Updater" + + +def _parse_version(v: str) -> tuple[int, ...]: + v = v.strip().lstrip("vV") + parts: list[int] = [] + for p in v.split("."): + digits = "".join(ch for ch in p if ch.isdigit()) + parts.append(int(digits) if digits else 0) + return tuple(parts) or (0,) + + +def is_newer(remote: str, local: str = CURRENT_VERSION) -> bool: + return _parse_version(remote) > _parse_version(local) + + +def fetch_latest_release() -> dict[str, Any] | None: + req = urllib.request.Request( + _API_URL, headers={"User-Agent": _USER_AGENT, "Accept": "application/vnd.github+json"} + ) + try: + with urllib.request.urlopen(req, timeout=10) as resp: + return json.loads(resp.read().decode("utf-8")) + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError, ValueError): + return None + + +def _find_windows_zip_asset(release: dict[str, Any]) -> dict[str, Any] | None: + assets = release.get("assets", []) + for asset in assets: + name = asset.get("name", "").lower() + if name.endswith(".zip") and ("win" in name or "windows" in name): + return asset + for asset in assets: + if asset.get("name", "").lower().endswith(".zip"): + return asset + return None + + +class UpdateChecker(QThread): + """Prueft einmalig im Hintergrund auf eine neue Version.""" + + updateAvailable = Signal(str, str, str) # version, release_notes, zip_download_url + checkFailed = Signal() + upToDate = Signal() + + def run(self) -> None: + release = fetch_latest_release() + if not release: + self.checkFailed.emit() + return + tag = release.get("tag_name") or release.get("name") or "" + if not tag or not is_newer(tag): + self.upToDate.emit() + return + asset = _find_windows_zip_asset(release) + download_url = asset["browser_download_url"] if asset else "" + notes = (release.get("body") or "").strip() + self.updateAvailable.emit(tag, notes, download_url) + + +class UpdateInstaller(QThread): + """Laedt ein Release herunter und installiert es (siehe Moduldoku).""" + + progress = Signal(str) + finished_ok = Signal() + failed = Signal(str) + + def __init__(self, download_url: str, parent=None): + super().__init__(parent) + self._download_url = download_url + + def run(self) -> None: + try: + if getattr(sys, "frozen", False): + self._run_packaged_update() + else: + self._run_dev_update() + except Exception as exc: # noqa: BLE001 - Fehler soll in der UI landen, nicht crashen + self.failed.emit(str(exc)) + + # ---------------------------------------------------------- gepackter Modus + + def _run_packaged_update(self) -> None: + if not self._download_url: + self.failed.emit( + "Kein Windows-Release-Paket (.zip) im neuesten Release gefunden." + ) + return + + self.progress.emit("Lade Update herunter …") + install_dir = Path(sys.executable).resolve().parent + staging = Path(tempfile.mkdtemp(prefix="playtube_update_")) + zip_path = staging / "update.zip" + 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, open(zip_path, "wb") as out: + shutil.copyfileobj(resp, out) + + self.progress.emit("Entpacke Update …") + with zipfile.ZipFile(zip_path) as zf: + zf.extractall(extract_dir) + + # Manche Release-Zips enthalten einen einzelnen Unterordner (z.B. "Playtube/"). + entries = list(extract_dir.iterdir()) + source_dir = entries[0] if len(entries) == 1 and entries[0].is_dir() else extract_dir + + self.progress.emit("Bereite Installation vor …") + script_path = self._write_apply_script(source_dir, install_dir, staging) + subprocess.Popen( + ["powershell", "-WindowStyle", "Hidden", "-ExecutionPolicy", "Bypass", "-File", str(script_path)], + creationflags=subprocess.CREATE_NO_WINDOW, + ) + self.finished_ok.emit() + + def _write_apply_script(self, source_dir: Path, install_dir: Path, staging: Path) -> Path: + exe_path = install_dir / "Playtube.exe" + script = f""" +$ErrorActionPreference = "SilentlyContinue" +Start-Sleep -Seconds 1 +$targetPid = {os.getpid()} +while (Get-Process -Id $targetPid -ErrorAction SilentlyContinue) {{ + Start-Sleep -Milliseconds 500 +}} +robocopy "{source_dir}" "{install_dir}" /MIR /NFL /NDL /NJH /NJS /NC /NS /NP | Out-Null +Start-Process -FilePath "{exe_path}" +Start-Sleep -Seconds 2 +Remove-Item -Recurse -Force "{staging}" -ErrorAction SilentlyContinue +""" + script_path = staging / "apply_update.ps1" + script_path.write_text(script, encoding="utf-8") + return script_path + + # ------------------------------------------------------------- Entwicklungsmodus + + def _run_dev_update(self) -> None: + project_root = Path(__file__).resolve().parent.parent + if not (project_root / ".git").exists(): + self.failed.emit( + "Kein Git-Repository gefunden - im Entwicklungsmodus bitte manuell " + "'git pull' im Projektordner ausfuehren." + ) + return + + self.progress.emit("Lade Aenderungen per git pull …") + result = subprocess.run( + ["git", "pull", "--ff-only"], cwd=project_root, capture_output=True, text=True + ) + if result.returncode != 0: + self.failed.emit(f"git pull fehlgeschlagen:\n{result.stderr.strip()}") + return + + self.progress.emit("Installiere Abhaengigkeiten …") + pip_result = subprocess.run( + [sys.executable, "-m", "pip", "install", "-q", "-r", str(project_root / "requirements.txt")], + capture_output=True, + text=True, + ) + if pip_result.returncode != 0: + self.failed.emit(f"pip install fehlgeschlagen:\n{pip_result.stderr.strip()}") + return + + self.finished_ok.emit() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..faa020b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +PySide6>=6.10 +pypresence>=4.3 diff --git a/tools/generate_icon.py b/tools/generate_icon.py new file mode 100644 index 0000000..a2066f9 --- /dev/null +++ b/tools/generate_icon.py @@ -0,0 +1,66 @@ +"""Erzeugt assets/icon.ico (Play-Button-Logo) und assets/icon.png fuer Playtube. + +Nur ein Build-Hilfsskript - Pillow wird dafuer benoetigt (nicht Laufzeit-Abhaengigkeit +der App selbst): pip install Pillow +Aufruf: python tools/generate_icon.py +""" +from __future__ import annotations + +from pathlib import Path + +from PIL import Image, ImageDraw + +ROOT = Path(__file__).resolve().parent.parent +ASSETS = ROOT / "assets" +ASSETS.mkdir(exist_ok=True) + +SIZE = 512 +BG_COLOR = (124, 58, 237, 255) # violett - eigenstaendige Playtube-Marke +BG_COLOR_2 = (236, 72, 153, 255) # pink, fuer diagonalen Verlauf +PLAY_COLOR = (255, 255, 255, 255) + + +def make_base_image() -> Image.Image: + img = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0)) + px = img.load() + for y in range(SIZE): + for x in range(SIZE): + t = (x + y) / (2 * SIZE) + r = int(BG_COLOR[0] + (BG_COLOR_2[0] - BG_COLOR[0]) * t) + g = int(BG_COLOR[1] + (BG_COLOR_2[1] - BG_COLOR[1]) * t) + b = int(BG_COLOR[2] + (BG_COLOR_2[2] - BG_COLOR[2]) * t) + px[x, y] = (r, g, b, 255) + + # Abgerundetes Quadrat als Maske (App-Icon-Look). + mask = Image.new("L", (SIZE, SIZE), 0) + mdraw = ImageDraw.Draw(mask) + radius = int(SIZE * 0.22) + mdraw.rounded_rectangle([0, 0, SIZE - 1, SIZE - 1], radius=radius, fill=255) + + out = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0)) + out.paste(img, (0, 0), mask) + + draw = ImageDraw.Draw(out) + # Play-Dreieck mittig. + w = SIZE * 0.34 + h = SIZE * 0.4 + cx, cy = SIZE / 2 + SIZE * 0.03, SIZE / 2 + points = [ + (cx - w / 2, cy - h / 2), + (cx - w / 2, cy + h / 2), + (cx + w / 2, cy), + ] + draw.polygon(points, fill=PLAY_COLOR) + return out + + +def main() -> None: + base = make_base_image() + base.save(ASSETS / "icon.png") + sizes = [16, 24, 32, 48, 64, 128, 256] + base.save(ASSETS / "icon.ico", sizes=[(s, s) for s in sizes]) + print(f"Icon geschrieben nach {ASSETS / 'icon.ico'}") + + +if __name__ == "__main__": + main()