From 04e03c132dfd978dfb2095c6976dc95d298bb506 Mon Sep 17 00:00:00 2001 From: wei Date: Tue, 25 Aug 2026 17:46:48 +0800 Subject: [PATCH] Add download video and save playlist support. --- .gitignore | 6 + PlaylistSaver.spec | 87 ++ PlaylistSaverHelper/background.js | 155 ++- PlaylistSaverHelper/icon.png | Bin 0 -> 5204 bytes PlaylistSaverHelper/manifest.json | 19 +- README.md | 29 +- app.py | 390 +++++++ build.ps1 | 40 + constant.py | 5 + main_ctk.py => deprecated/main_ctk.py | 2 +- dialog.py | 615 +++++++++++ main.py | 1358 +------------------------ pl_lib/messagebox.py | 108 ++ pl_lib/progress_dialog.py | 171 ++++ pyproject.toml | 46 + utils.py | 116 ++- widgets.py | 252 +++++ window.py | 332 ++++++ yt_lib.py | 169 +++ 19 files changed, 2514 insertions(+), 1386 deletions(-) create mode 100644 PlaylistSaver.spec create mode 100644 PlaylistSaverHelper/icon.png create mode 100644 app.py create mode 100644 build.ps1 create mode 100644 constant.py rename main_ctk.py => deprecated/main_ctk.py (99%) create mode 100644 dialog.py create mode 100644 pl_lib/messagebox.py create mode 100644 pl_lib/progress_dialog.py create mode 100644 pyproject.toml create mode 100644 widgets.py create mode 100644 window.py create mode 100644 yt_lib.py diff --git a/.gitignore b/.gitignore index 0d52b33..3e2168d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,9 @@ /temp/ /main.build/ /main.dist/ +/build/ +/dist/ +/*.egg-info/ +/.idea/ +__pycache__/ +*.py[cod] diff --git a/PlaylistSaver.spec b/PlaylistSaver.spec new file mode 100644 index 0000000..7889d7f --- /dev/null +++ b/PlaylistSaver.spec @@ -0,0 +1,87 @@ +from pathlib import Path +import platform + + +project_dir = Path(SPEC).resolve().parent + + +def data_tree(source: Path, destination: str): + """Return PyInstaller data tuples while retaining the directory tree.""" + return [ + (str(path), str(Path(destination) / path.relative_to(source).parent)) + for path in source.rglob("*") + if path.is_file() + ] + + +def bundled_mpv_dir() -> Path: + system = platform.system().lower() + machine = platform.machine().lower() + + if system == "windows": + os_dir = "nt" + elif system in {"darwin", "linux"}: + os_dir = system + else: + raise SystemExit(f"Unsupported build platform: {platform.system()}") + + if machine in {"amd64", "x86_64"}: + arch_dir = "x64" + elif machine in {"arm64", "aarch64"}: + arch_dir = "arm" + elif machine in {"x86", "i386", "i686"}: + arch_dir = "x86" + else: + raise SystemExit(f"Unsupported build architecture: {platform.machine()}") + + return project_dir / "bin" / "mpv" / os_dir / arch_dir + + +mpv_dir = bundled_mpv_dir() +if not mpv_dir.is_dir(): + raise SystemExit(f"Bundled mpv directory does not exist: {mpv_dir}") + +datas = data_tree(project_dir / "assets", "assets") +datas += data_tree( + mpv_dir, + str(Path("bin") / "mpv" / mpv_dir.parent.name / mpv_dir.name), +) + +a = Analysis( + [str(project_dir / "main.py")], + pathex=[str(project_dir)], + binaries=[], + datas=datas, + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=["customtkinter", "PIL"], + noarchive=False, + optimize=1, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name="PlaylistSaver", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + console=False, + icon=str(project_dir / "assets" / "icon.ico"), +) + +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=False, + name="PlaylistSaver", +) + diff --git a/PlaylistSaverHelper/background.js b/PlaylistSaverHelper/background.js index 1368d98..518bd89 100644 --- a/PlaylistSaverHelper/background.js +++ b/PlaylistSaverHelper/background.js @@ -1,34 +1,127 @@ +function bytesToBase64Url(bytes) { + let binary = ""; + for (let offset = 0; offset < bytes.length; offset += 8192) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + 8192)); + } + return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, ""); +} + +function base64UrlToBytes(value) { + const padded = value.replaceAll("-", "+").replaceAll("_", "/") + + "=".repeat((4 - value.length % 4) % 4); + return Uint8Array.from(atob(padded), character => character.charCodeAt(0)); +} + +async function hmacSha256(keyBytes, data) { + const key = await crypto.subtle.importKey( + "raw", keyBytes, { name: "HMAC", hash: "SHA-256" }, false, ["sign"] + ); + return new Uint8Array(await crypto.subtle.sign("HMAC", key, data)); +} + +function joinBytes(...arrays) { + const result = new Uint8Array(arrays.reduce((size, array) => size + array.length, 0)); + let offset = 0; + for (const array of arrays) { + result.set(array, offset); + offset += array.length; + } + return result; +} + +async function encryptCookies(plaintext, secretKey) { + const masterKey = base64UrlToBytes(secretKey); + if (masterKey.length !== 32) { + throw new Error("The Helper secret key is invalid."); + } + + const encoder = new TextEncoder(); + const encryptionKey = await hmacSha256(masterKey, encoder.encode("PlaylistSaver cookie encryption")); + const authenticationKey = await hmacSha256(masterKey, encoder.encode("PlaylistSaver cookie authentication")); + const nonce = crypto.getRandomValues(new Uint8Array(16)); + const plaintextBytes = encoder.encode(plaintext); + const ciphertext = new Uint8Array(plaintextBytes.length); + + for (let offset = 0; offset < plaintextBytes.length; offset += 32) { + const counter = new Uint8Array(4); + new DataView(counter.buffer).setUint32(0, offset / 32, false); + const stream = await hmacSha256(encryptionKey, joinBytes(nonce, counter)); + const blockLength = Math.min(32, plaintextBytes.length - offset); + for (let index = 0; index < blockLength; index++) { + ciphertext[offset + index] = plaintextBytes[offset + index] ^ stream[index]; + } + } + + const signedData = joinBytes(Uint8Array.of(1), nonce, ciphertext); + const tag = await hmacSha256(authenticationKey, signedData); + return bytesToBase64Url(joinBytes(signedData, tag)); +} + +async function launchPlaylistSaver() { + const { secretKey } = await chrome.storage.local.get("secretKey"); + if (!secretKey) { + await chrome.storage.local.set({ showMissingKeyAlert: true }); + await chrome.runtime.openOptionsPage(); + return; + } + + const cookies = await chrome.cookies.getAll({ domain: ".youtube.com" }); + let output = "# Netscape HTTP Cookie File\n"; + for (const cookie of cookies) { + const rawDomain = cookie.domain; + const domain = cookie.httpOnly ? `#HttpOnly_${rawDomain}` : rawDomain; + output += [ + domain, + rawDomain.startsWith(".") ? "TRUE" : "FALSE", + cookie.path, + cookie.secure ? "TRUE" : "FALSE", + cookie.expirationDate ? Math.floor(cookie.expirationDate) : 0, + cookie.name, + cookie.value + ].join("\t") + "\n"; + } + + const encrypted = await encryptCookies(output, secretKey.trim()); + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!tabs[0]?.id) { + throw new Error("No active tab is available to launch PlaylistSaver."); + } + await chrome.tabs.update(tabs[0].id, { + url: `playlistsaver://open?encryptedcookies=${encrypted}` + }); +} + +async function openHelperSettings() { + await chrome.runtime.openOptionsPage(); +} + chrome.commands.onCommand.addListener((command) => { if (command === "openPLKey") { - - chrome.cookies.getAll({ domain: ".youtube.com" }, (cookies) => { - let output = "# Netscape HTTP Cookie File\n"; - - cookies.forEach(c => { - const domain = c.domain; - const flag = domain.startsWith('.') ? "TRUE" : "FALSE"; - const path = c.path; - const secure = c.secure ? "TRUE" : "FALSE"; - const expiration = c.expirationDate ? Math.floor(c.expirationDate) : 0; - - output += [ - domain, - flag, - path, - secure, - expiration, - c.name, - c.value - ].join("\t") + "\n"; - }); - - const encoded = btoa(output); - - chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { - chrome.tabs.update(tabs[0].id, { - url: `PlaylistSaver://open?base64cookies=${encoded}` - }); - }); - }); + launchPlaylistSaver().catch(error => console.error("Unable to launch PlaylistSaver:", error)); + } else if (command === "openPLSettingPageKey") { + openHelperSettings().catch(error => console.error("Unable to open Helper settings:", error)); } -}); \ No newline at end of file +}); + +chrome.runtime.onInstalled.addListener(() => { + chrome.contextMenus.removeAll(() => { + chrome.contextMenus.create({ + id: "openPlaylistSaver", + title: "Open PlaylistSaver", + contexts: ["page"] + }); + chrome.contextMenus.create({ + id: "openHelperSettings", + title: "PlaylistSaver Helper Settings", + contexts: ["page"] + }); + }); +}); + +chrome.contextMenus.onClicked.addListener((info) => { + if (info.menuItemId === "openPlaylistSaver") { + launchPlaylistSaver().catch(error => console.error("Unable to launch PlaylistSaver:", error)); + } else if (info.menuItemId === "openHelperSettings") { + openHelperSettings().catch(error => console.error("Unable to open Helper settings:", error)); + } +}); diff --git a/PlaylistSaverHelper/icon.png b/PlaylistSaverHelper/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..0257f8b9b7b44cfe6fb51e65e0315c7181584fb5 GIT binary patch literal 5204 zcmZ`-2T+qiuudTfhK}^AbdVB|j#O#V5$U}*DGEx51P}v6x&jK)n@ACmCMAdlr8kjI z0D;g!kS@IV-kUe`X5O2-`*&`4zPY{Iy?=Kv-pD|U0?q;l000#CwAJquDe~VSg%ZEr zN{)R*a>H5szCHjD%17kK0sv?KZ$ zv2lE*J~s@=(02rk=P=7!dKJ0{Y@JR+IdbQh7HgBsJWnr}&U2Xa3|Xdr@Aq%j|CA4_ zX`h((CWC0!y@RvCbadkEzPZE(hf~&)TBIFVk=v~PjZiY8%m9|C^oIp85MZH51H7a^ z8v!s6Oga?<_wc5#b8*Pr?NH8z&XiVHi4a6tuG0pBHM>*Qojja1uuNnYXm|+3pSPB+ z9K?_xEec$^-fReVK}x=3Dnm-=)5wyyJrly+Q@6pWw#fj;KQ@reJz?Z+fTtYBCW+rz z3VJAbCTUM$0}E#=*T~x$74&sdme-L4!jKVIRTO2>Pv|t|qG_Cwpo&B)Q*bDh#Z8Yq zRmVZfpL@^v28wdou@L3~w1Y)bwV}j!Z>B-wX3euQZc4&a-dBv+_hZMcpzlePDBi<1 zkW;En0U3eddk&0ywEm=~DP1%LEph~DuYhP@Y|5$a^L{u!6n`*3)Ym;c_{a$vglfl% zq1u@OK&dDiM@wvxssno%PbwDez#Ic<_8N`=H8Wd)`zadSe;dL*0F9teBC?)MT!lbG5bD=go>%vVo^_ z=>uR|JPD3TorK#dQQR9@17bynn1rr%1wezDgTJ69(c(!9GA42?7AE_=e&*&>7aWN_ zz;|D>uAm&S4(#D|rFq3DVDRi_qF0R{vLHe_TkniC<$W~{P&lzmjyJ*D66$<)=xPD} zC>bOSm8+7spQH1nsw(~LYx+9?c#ushuo}<-nN45`r&$Z2WvA!VES|HcHC)X=2*QVL ztabU)yYAmL#C)^Dhn1(g8&1#B!6?0W=J|13;#{~VwoCi6Qu_KKS9NW-R7|*3`|JE| z7#$e)T=Bm7ZjaBa2p-TzfFYp0(KYpnt=GXFmS3j8qzC4eLv|!&LYTon_GKp%hb9{E zlL~B%Xb!bA0$X=y8WLiHbm0PC(i(C`uZ> zu2?!O)xtEFEbiht_Np-Q0{y+H3+ra>(z$#N=gTKqHbdIP7)q)xU!h}_O(g+AEV8o3 z-TpQKfhJt2{CP#c!6>BmiOF{sJP@_=mfWNFLpSbo7JYt815Kl7b;DVH|#$Z7vCND=wUobwE1W)UO^Cj z0ly!zY`Uxeq9>dD_1)=}t7Xi7rg%8ETsw5}p17vb@y^fqoI8&BBrm^aDo&Aq^OD5q z0Oya?T0OXWtj3!-TY|c~ZScO_brVA}Y(7d|<(a7Y;m43YGW@dY!lN&c5Q=6B56&t- zd?7sLc3D5*64sn9P|Rw^HqLoqn@=UW!^83j?qSnP=S=>}C&umftPalw!nsJ|EW>q% z$(bk!{33%X2k<7vpz-nsQeK{#*>@J*Z~A9$%KqR|i(kf^40Q_;uj`FUN>0{h@W5iC z0J#}Bh>1qQ+wyMV$2ExyQoN7F-u)&&KC4|uS_c1mrh42=nD~?`= zAC61LO+Ux&*zSI%=LUQ<)q-FB^`FW0`g6^-CO_p888G9eNR zIm&r?IZ6~Z!SuChDFc7aESV?zv)63v+=ko+^<|7t)AMTgW)}GDOs-GCx&su2W^R%T zA)nfiSA7o%8LndgtZn#_bpLB&c2p1hgUgei{`M6HCwlr+uAHG2Y?(OUs~Itd;8z8t zc2d=U=$!O9`kZj4<{t>37FI@K9Qj*uthH_f(T|Gn(F=m!YZcR`0(*vAOt4-JEaqKY zB_$aV7t1zhMVuc9=L=QiWGv`@O3jaWG}= z-4`;CSL69*KSe38ZcVKdl(hlo+@o>J4#xMF80|We_Fmc#+>&sHx^7MwpAO%BFdHz0 zEk_J&#Yo`gF&_j3nGqny!e^OfkPiv))w#!HFR2v2MAt-6gzYb_yK#myiGAubNAi07 z*j|qz<<-rsb2xAl{kuq&9WbVE5wN!5VRVsTl-L`7gP|5A(xS9YJ>xd9@uT0SCGD;9 z1!^o=QS<7JpVQfS;*^p_Z0|))W6&t>rCvn)U!$A1-6g`hp>FSO?j7$&YHdup-;WVRBatZTu zt(=ez!H-lD*-l2W5G<`d5Jzx&V`mYtidegy=ZJe}>cx5IfSkFLM|aVHY~`1LO2fdp z)34ZEce?xld0$xe|ifu>xHt$ghMEFPb73T<^<&N=#A6lv|&QMRgq>(oHu| z!x&}=;twHZ9je^CyhQCP`*3rg)@K`EbaY0=!pIX~f&{bwuWu3V7`Y@S_vb@sH55`)t{(@@R z1Cq%=J-(+Jkbuq;4Yz75jWsf{;g8O+LFKCo#%@Q>469!cDdV$9)CDRkofmC`j9XmE z0;GMvyjD~YZd4V1uxu>O-8ewB&Pv+(ZrPH;LYL4jvS+jwD$)c^J^uHW^;z|gib^YC zc?qvR?hUmjXGyqB2ygZ3L`Pk&H|-@$5gDUZRfn8PTb!lLJc`o8*KBA5bxXl~Z&tQ8 zYXUY>wX|hqo;%)~^aPj1_-c$Sw;sw!j}jHM?ffe;_-QxJl{t7Cngp9maKeRLMYIO( z@cbD!DYmxmUL}|J1+xeLaE$iV7hgO&8YNo2xY4&{*DtF3@J|K4n>!pFIEhtIi4zdy zQFf`%nd{6Ozdba3uu<7ukwJO9NYiEr4eo&^6_lB1!Vxr>n-PZswhB*&PcIH)L>Kn1 z&GS>R9nj~eb4@XDkW87B5vps}`j?Z_4fx1-1&hO8vcyOVe^VtNstitN=IxHeYrzfp@RdBbs(XXtmbjRE^t-=B9qc?h`iNS3& zCqwzx4@g~-*o!v5GOo{d=Q=;+#FreNVOJac$9o(7Gzdoxz}%n+2rgKM?bWkE8hg|NxzXx%7f z>Bj9cK6Vl-Bo%b-a|KS8(*5ef{r*hj$a#4XXFf;kY$LI1Uh#kAB z#}DzhJxgXQ=o^~cCJ4B7owtb!d3()Fk3$hi$X{rrVHRaVmU_-|&wAvk>wQC|ito&e z@G%31Iqa^!0Sg|BqOpsdW>rb!5@lX{t5~@+Q)Rub5a{9m(_o*B^FgEEugfl|#l+|m zr`#n6*C%A9VBG~Eop=^_WSpmc7{&47`Z{8kY8~Xjh--rxt*nZ-4GuD$oF$k4O&0I%;S96E5gN{Je%nJ0q28->&r@-^wI>dD zxtHbX_Y=-O(?n%PK$B3l9?m3)^VbS1GD;AW2cw^(g*}PyRGV3Uyf%bJz?yezXUgj; zGIqL-=bE0em*$cmfj&h9J+&R&qhibgZM=RXm!?e}lP-EE;VrkMSnX;eK1W`ov_1C( z24&>q%PkFBP3%`OdUMcrx5@775yHe%$U3q&}U_ZZV`3DfG=_a!oQ*LRDV+OVmMap&{rn@)z&rU$QF$3qf#H% ze|cdOh-%3-RXz4~IyQzlV{z7=IYM9EDFg{Chtkn>Jz+!UmW$v?BA(N9FXc;2ugg<% zeg0jfr4`;?-g95f^A{Y?sNNFexFUs@&W|W|RL(T6%AK#S#+84aqnw3FCa*@&nCRU0 z=j7VPG0jixU1~7D1YLxAS5ngBZs4|njrqVhw0{by5iK5ufU3GmvuJKB?q4Uxpn-1z zkqN%yNu;kNg%bJi{8xVbJ;LngXmsVr?NAMKNGBJ`CCU>+cF~)zQ4^NRyYP&u{z{S~ z+1Z*wGzYK)c}g-zHkQSXZ5Pd{6-f*Z--2~w`ie#~v>h<2>WI))d^ff=Kg{{HF;%#3 z{z#_AHd9_?mdesv>V_8yf(y-;ddXSKN3hH0`_HmL0Ox>obPquH z9>%)WIMSr+#-IkoQ)2*VjP(fu2tjuJ0`)LGO#O?^B@EMv%Z-DBrn%19#jv-{igs;& z)0)Z^+NoWEGr`AT*6jRf4RU-TbU_iy@>f~(P5$1^tF}T}8It5N zZUvt(J5aCE(xHSnxAB!rl3{Oa2Fsu9KkxhT@bT6c66+j2zL*lvZ%ZXFBdD6+()_08 z22iZ;Kz6P>A2HD^w~Fou=e#a0ZHt;JE}h_sST-6<`_BRsE}YZ9E8<{KxPS4S#*^Rh z)upyxsIO^VtCenQ<&Xn(PjFn?Lcc+0@6m{4HEiU|8>MB9KRDV+W~y{iWb1?(IoN8} zed;Ixn~8)C_y1ToP?%=4|9w_kQ(dBQ{xIzsm5B}bMmFnR>JMMDq_E~F3X+yn9;U(k zFi{S1E(#~i^6`KR<>{Kl%KFC_x|#QvWL-r1j#)EUDR}0RSmGfc7$1QGi!|qpRN9%w zJk^QU!E>g=gVMg^nsBFlwly$ zDir_Zxvz`z3VA9#kmscB_~199As~mTL88YlFzV6{BZmsrMO`!p@T+;qOIt3V#zTWX zWB_lCus}9Z@^JT1f+m^7wwmq^+h^sxk%f{OwxqV{VF5s}=28pAZN4~d-qwf2_$QEZ zBb;78Y?upEQ?zn4kIg#1czvhkSn8$b{8!SKpkESr_1-CfkdYT&LK3(l-;iS~Qj4w- zd$N#hHs`D6y}NU_vg#>%!iH`12ysm@b67}4^gEkE7joCK!KnMuwOWwkzKa}7ybU0u zDD~S3$WtEbJ?!s-)%;uEh(VK?$j@b)ZcU1+5_;*58N2ZSrGUU=} zt|t%Cuv5JH)y7a%gyV8hddK8SK+_z|=W65;I35g_jg)-?E1KnG_6Zc zN)zLnYS2j5Nt8`&pYe>f*%k3gjW1fr5D0k$!f)5t{(k{{{9HWULjHdMN8Q9S5g_nCD+GG_xCR6|`uP7J7`gviBviY_ Qnur11(=br4R&$8{4@?rN*Z=?k literal 0 HcmV?d00001 diff --git a/PlaylistSaverHelper/manifest.json b/PlaylistSaverHelper/manifest.json index 1833900..e1738fa 100644 --- a/PlaylistSaverHelper/manifest.json +++ b/PlaylistSaverHelper/manifest.json @@ -4,9 +4,10 @@ "version": "1.0", "permissions": [ - "scripting", "activeTab", - "cookies" + "contextMenus", + "cookies", + "storage" ], "host_permissions": [ "*://*.youtube.com/*" @@ -16,12 +17,22 @@ "service_worker": "background.js" }, + "options_page": "options.html", + "icons": { + "128": "icon.png" + }, "commands": { - "openPLKey": { + "openPLSettingPageKey": { "suggested_key": { "default": "Alt+Z" }, + "description": "Open settings page" + }, + "openPLKey": { + "suggested_key": { + "default": "Alt+Y" + }, "description": "Launch PlaylistSaver" } } -} \ No newline at end of file +} diff --git a/README.md b/README.md index ffb387f..79722e0 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,30 @@ # PlaylistSaver -A tool to save your youtube playlist. **VERY UNSTABLE** \ No newline at end of file +A tool to save your youtube playlist. **VERY UNSTABLE** + +## Development + +Create a virtual environment and install the project with its build tools: + +```powershell +python -m venv .venv +.\.venv\Scripts\python.exe -m pip install -e ".[build]" +``` + +Run from source: + +```powershell +.\.venv\Scripts\playlist-saver.exe +``` + +## Build + +Build the application with PyInstaller: + +```powershell +.\build.ps1 -Clean +``` + +The application is written to `dist\PlaylistSaver`. The build includes only the +bundled mpv files matching the operating system and CPU architecture on which the +build is performed. Build each target platform on that platform. diff --git a/app.py b/app.py new file mode 100644 index 0000000..c940404 --- /dev/null +++ b/app.py @@ -0,0 +1,390 @@ +# Qt +import logging +import os +import secrets +import threading +import webbrowser +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +from PySide6.QtWidgets import QApplication, QMessageBox +from PySide6.QtCore import QSize + +from yt_dlp import YoutubeDL + +from constant import PROGRAM_DIR +from window import PlaylistMainWindow +from yt_lib import sanitize_filename, fetch_playlists_data, PlaylistFetchException, resolve_video_page_url +from utils import save_json, read_json +from pl_lib import messagebox + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + +class PlaylistSaver(QApplication): + DEFAULT_PROFILE_NAME = "default" + THUMBNAIL_SIZE = QSize(144, 81) + mpv_processes = {} + + def __init__(self, work_dir: Path, url_schema=None, cookie_file: str | Path | None = None): + super().__init__() + self.setApplicationName("PlaylistSaver") + + self.url_schema = url_schema + self.cookie_file = Path(cookie_file) if cookie_file else None + + # Path + self.work_dir = work_dir + self.save_path = Path( self.work_dir, "save") + self.temp_path = Path( self.work_dir, "temp") + self.profiles_meta_path = Path(self.save_path, "profiles.json") + self.profiles_path = Path(self.save_path, "profiles") + self.cookie_path = Path(self.profiles_path, self.DEFAULT_PROFILE_NAME, "cookies.txt") + self.secret_key_path = Path(self.save_path, "helper-secret.key") + self.secret_key = self.load_or_create_secret_key() + + # Profile + self.active_profile_name = self.DEFAULT_PROFILE_NAME + + # Data + self.playlists: dict[str, list] = { + "entries": [] + } + + self.main_window = PlaylistMainWindow( + self + ) + + # Lock + self.fetch_playlist_lock = threading.Lock() + self.download_pool = ThreadPoolExecutor(max_workers=4, thread_name_prefix="video-download") + self.download_tasks = set() + self.download_tasks_lock = threading.Lock() + self.download_cancel_event = threading.Event() + self.aboutToQuit.connect(lambda: self.download_pool.shutdown(wait=False, cancel_futures=False)) + + # flags + self.use_custom_cookie_file_path = self.cookie_file is not None + + if self.use_custom_cookie_file_path: + cookie_path = self.cookie_file + + if not cookie_path.exists() or not cookie_path.is_file(): + self.main_window.signals.error.emit( + "Cookie file does not exist or is not a file." + ) + else: + self.cookie_path = cookie_path + + self.init() + self.main_window.show() + + def init(self): + self.initialize_profiles() + # Start background thread after root is ready for UI callbacks. + self.reload_playlists() + + def reload_playlists(self): + self.main_window.loading_label.setText("Loading playlists...") + + threading.Thread( + target=fetch_playlists_data, + args=(self.url_schema, + self.get_profile_cookie_path(self.active_profile_name), + self.get_ydl_opts(), + self.on_data_ready, + self.work_dir, + self.fetch_playlist_lock, + PROGRAM_DIR / "main.py", + self.secret_key, + ), + daemon=True, + ).start() + + # + # Profile + # + def load_or_create_secret_key(self): + try: + if self.secret_key_path.exists(): + key = self.secret_key_path.read_text(encoding="utf-8").strip() + if key: + return key + + self.secret_key_path.parent.mkdir(parents=True, exist_ok=True) + key = secrets.token_urlsafe(32) + self.secret_key_path.write_text(key, encoding="utf-8") + return key + except OSError as e: + logger.error("Unable to load or create Helper secret key: %s", e) + return None + + def initialize_profiles(self): + try: + self.profiles_path.parent.mkdir(parents=True, exist_ok=True) + except Exception as e: + logger.error("Unable to create profiles folder:", e) + QMessageBox.critical(self.main_window, "Error", + "Unable to create profiles folder.\n" + f"At path: {self.profiles_path.as_posix()}\n" + f"Reason: {e}\n" + f"Solution: Try to remove profiles folder manually and try again.") + + active_name = self.get_active_profile_name() + self.ensure_profile_exists(active_name) + self.set_active_profile(active_name) + + def ensure_profile_exists(self, profile_name: str): + meta = self.load_profiles_meta() + profiles = meta["profiles"] + + if profile_name not in profiles: + profiles.append(profile_name) + meta["profiles"] = profiles + self.save_profiles_meta(meta) + + self.get_profile_dir(profile_name).parent.mkdir(parents=True, exist_ok=True) + + def get_active_profile_name(self): + meta = self.load_profiles_meta() + self.active_profile_name = meta.get("active_profile", None) + return self.active_profile_name + + def load_profiles_meta(self): + meta = read_json(self.profiles_meta_path) + if not meta: + return { + "active_profile": self.DEFAULT_PROFILE_NAME, + "profiles": [self.DEFAULT_PROFILE_NAME], + } + + profiles = meta.get("profiles") or [self.DEFAULT_PROFILE_NAME] + active_profile = meta.get("active_profile") or profiles[0] + + if active_profile not in profiles: + profiles.insert(0, active_profile) + + return { + "active_profile": active_profile, + "profiles": profiles, + } + + def set_active_profile(self, profile_name: str): + meta = self.load_profiles_meta() + profiles = meta["profiles"] + + if profile_name not in profiles: + profiles.append(profile_name) + + meta["profiles"] = profiles + meta["active_profile"] = profile_name + self.save_profiles_meta(meta) + + self.active_profile_name = profile_name + self.cookie_path = self.get_profile_cookie_path(profile_name) + self.cookie_path.parent.mkdir(parents=True, exist_ok=True) + + def save_profiles_meta(self, meta: dict): + save_json(meta, self.profiles_meta_path) + + def delete_profile(self, profile_name: str): + meta = self.load_profiles_meta() + + if not profile_name in meta["profiles"]: + return + + meta["profiles"].remove(profile_name) + + self.save_profiles_meta(meta) + + # + # Path + # + def get_profile_cookie_path(self, profile_name: str): + if self.use_custom_cookie_file_path: + return self.cookie_file + + return Path(self.get_profile_dir(profile_name), "cookies.txt") + + def get_profile_dir(self, profile_name: str): + return Path(self.profiles_path, sanitize_filename(profile_name)) + + def get_profile_temp_dir(self, profile_name: str | None = None): + profile_name = profile_name or self.active_profile_name + return self.temp_path / "profiles" / sanitize_filename(profile_name) + + def get_playlist_cache_path(self, playlist_id: str): + safe_id = sanitize_filename(playlist_id or "unknown") + return self.get_profile_temp_dir() / "playlists" / f"{safe_id}.json" + + # + # PlaylistData + # + def on_data_ready(self, data, error): + if isinstance(error, PlaylistFetchException): + self.main_window.signals.playlist_fetch_error.emit(error) + if data is None: + data = {"entries": []} + + self.playlists["entries"] = data.get("entries", []) + self.main_window.signals.playlists_ready.emit(self.playlists) + + def load_playlist_videos(self, playlist_id: str): + cache_path = self.get_playlist_cache_path(playlist_id) + if os.path.exists(cache_path): + return read_json(cache_path) + return None + + def fetch_playlist_videos(self, playlist: dict): + playlist_id = playlist.get("id") + playlist_url = playlist.get("url") or playlist.get("webpage_url") + + if not playlist_url and playlist_id and not str(playlist_id).startswith("VL"): + playlist_url = f"https://www.youtube.com/playlist?list={playlist_id}" + + if not playlist_url: + raise ValueError("Playlist URL not found.") + + cached_data = self.load_playlist_videos(playlist_id) + if cached_data: + return cached_data + + opts = self.get_ydl_opts() + + opts.update({"extract_flat": True, + "skip_download": True, + "quiet": True, }) + + with self.fetch_playlist_lock: + with YoutubeDL(opts) as ydl: + data = ydl.extract_info(playlist_url, download=False) + + self.save_playlist_videos(playlist_id, data) + return data + + def save_playlist_videos(self, playlist_id: str, data: dict): + save_json(data, self.get_playlist_cache_path(playlist_id)) + + def get_ydl_opts(self): + return { + 'cookiefile': self.get_profile_cookie_path(self.active_profile_name), + 'verbose': True, + 'download': False, + 'js_runtimes': {"node": {}}, + 'extract_flat': False, + 'lazy_playlist': False, + "ignoreerrors": False + } + + def get_download_dir(self, playlist_name: str | None = None): + directory = self.temp_path / "downloads" + if playlist_name: + safe_playlist_name = sanitize_filename(playlist_name).strip(" .") or "Unnamed Playlist" + directory = directory / "playlists" / safe_playlist_name + directory.mkdir(parents=True, exist_ok=True) + return directory + + def _download_video_worker(self, video: dict, playlist_name: str | None = None): + video_id = str(video.get("id") or "unknown") + video_url = resolve_video_page_url(video, video_id) + video_name = sanitize_filename(str(video.get("title") or "Unnamed Video")).strip(" .") or "Unnamed Video" + output_base = self.get_download_dir(playlist_name) / f"{video_name}-{sanitize_filename(video_id)}" + + opts = self.get_ydl_opts() + opts.update({ + "download": True, + "skip_download": False, + "quiet": True, + "noplaylist": True, + "format": "bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]/bv*+ba/b", + "merge_output_format": "mp4", + "outtmpl": f"{output_base}.%(ext)s", + "postprocessors": [{"key": "FFmpegVideoRemuxer", "preferedformat": "mp4"}], + "progress_hooks": [self._check_download_cancelled], + }) + with YoutubeDL(opts) as ydl: + ydl.download([video_url]) + return Path(f"{output_base}.mp4") + + def _check_download_cancelled(self, _status): + if self.download_cancel_event.is_set(): + raise RuntimeError("Download cancelled because the application is closing.") + + def _submit_download_task(self, function, *args): + future = self.download_pool.submit(function, *args) + with self.download_tasks_lock: + self.download_tasks.add(future) + future.add_done_callback(self._forget_download_task) + return future + + def _forget_download_task(self, future): + with self.download_tasks_lock: + self.download_tasks.discard(future) + + def has_active_download_tasks(self): + with self.download_tasks_lock: + return any(not future.done() for future in self.download_tasks) + + def cancel_download_tasks(self): + self.download_cancel_event.set() + with self.download_tasks_lock: + for future in self.download_tasks: + future.cancel() + + def download_video(self, video: dict, playlist_name: str | None = None, + callback=None, reset_cancel=True): + if reset_cancel: + self.download_cancel_event.clear() + future = self._submit_download_task(self._download_video_worker, video, playlist_name) + if callback: + future.add_done_callback( + lambda result: callback(result, video, playlist_name) + ) + return future + + def download_playlist(self, playlist: dict, playlist_data: dict | None = None, + callback=None, queued_callback=None, producer_callback=None, + reset_cancel=True): + def queue_videos(): + try: + data = playlist_data or self.fetch_playlist_videos(playlist) + entries = [entry for entry in data.get("entries", []) if entry] + if self.download_cancel_event.is_set(): + return + if queued_callback: + queued_callback(len(entries)) + title = str(playlist.get("title") or playlist.get("id") or "Unnamed Playlist") + for video in entries: + if self.download_cancel_event.is_set(): + break + self.download_video(video, title, callback, reset_cancel=False) + except Exception as error: + if queued_callback: + queued_callback(1) + if callback: + callback(error, None, playlist.get("title")) + finally: + if producer_callback: + producer_callback() + + if reset_cancel: + self.download_cancel_event.clear() + return self._submit_download_task(queue_videos) + + def download_all_playlists(self, callback=None, queued_callback=None, + producer_callback=None): + self.download_cancel_event.clear() + for playlist in (item for item in self.playlists.get("entries", []) if item): + self.download_playlist( + playlist, callback=callback, queued_callback=queued_callback, + producer_callback=producer_callback, reset_cancel=False + ) + + def open_download_folder(self): + download_dir = self.temp_path / "downloads" + + if not download_dir.is_dir(): + messagebox.error(self.main_window, title="Error", message="The download folder does not exist.") + return + + webbrowser.open(download_dir.as_posix()) diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..624d26c --- /dev/null +++ b/build.ps1 @@ -0,0 +1,40 @@ +[CmdletBinding()] +param( + [switch]$Clean +) + +$ErrorActionPreference = "Stop" +$ProjectDir = $PSScriptRoot +$VenvPython = Join-Path $ProjectDir ".venv\Scripts\python.exe" +$Python = if (Test-Path -LiteralPath $VenvPython) { $VenvPython } else { "python" } + +if ($Clean) { + $BuildDir = Join-Path $ProjectDir "build" + $DistDir = Join-Path $ProjectDir "dist" + + if (Test-Path -LiteralPath $BuildDir) { + Remove-Item -LiteralPath $BuildDir -Recurse -Force + } + if (Test-Path -LiteralPath $DistDir) { + Remove-Item -LiteralPath $DistDir -Recurse -Force + } +} + +& $Python -c "import PyInstaller" 2>$null +if ($LASTEXITCODE -ne 0) { + throw 'PyInstaller is not installed. Run: .\.venv\Scripts\python.exe -m pip install -e ".[build]"' +} + +Push-Location $ProjectDir +try { + & $Python -m PyInstaller --noconfirm "PlaylistSaver.spec" + if ($LASTEXITCODE -ne 0) { + throw "PyInstaller exited with code $LASTEXITCODE." + } +} +finally { + Pop-Location +} + +Write-Host "Build completed: $ProjectDir\dist\PlaylistSaver" + diff --git a/constant.py b/constant.py new file mode 100644 index 0000000..279c433 --- /dev/null +++ b/constant.py @@ -0,0 +1,5 @@ +from pathlib import Path + +VERSION = "0.0.3" +YT_PLAYLISTS_URL = "https://www.youtube.com/feed/playlists" +PROGRAM_DIR = Path(__file__).parent diff --git a/main_ctk.py b/deprecated/main_ctk.py similarity index 99% rename from main_ctk.py rename to deprecated/main_ctk.py index 744864b..4956a9c 100644 --- a/main_ctk.py +++ b/deprecated/main_ctk.py @@ -1006,7 +1006,7 @@ def main(url_schema, cookie_file: str): thumbnail_path = Path( ROOT_DIR, - "temp", + "../temp", "thumbnails", f"thumbnail_{video.id}_{thumbnail_resolution}.jpg" ) diff --git a/dialog.py b/dialog.py new file mode 100644 index 0000000..f85e049 --- /dev/null +++ b/dialog.py @@ -0,0 +1,615 @@ +import logging +import subprocess +import threading +from pathlib import Path + +from PySide6.QtCore import Signal, QObject, QTimer +from PySide6.QtWidgets import ( + QDialog, + QLabel, QPushButton, QProgressDialog, + QVBoxLayout, QFrame, QHBoxLayout, QMessageBox, QInputDialog, QScrollArea +) + +from constant import PROGRAM_DIR +from pl_lib.messagebox import ask_yes_no_with_plain_text +from utils import find_mpv_path, download_file +from widgets import VideoItem +from yt_lib import resolve_video_page_url + + +logger = logging.getLogger("PlayerDialog") +logger.setLevel(logging.INFO) + + +class DownloadProgressDialog(QProgressDialog): + tasks_added = Signal(int) + task_finished = Signal(str, bool) + producer_finished = Signal() + + def __init__(self, title: str, total: int, parent=None, producers: int = 0): + super().__init__("Preparing downloads...", "Hide", 0, total, parent) + self.completed_count = 0 + self.failed_count = 0 + self.failed_videos = [] + self.remaining_producers = producers + self.summary_shown = False + self.setWindowTitle(title) + self.setFixedWidth(540) + self.setMinimumDuration(0) + self.setAutoClose(False) + self.setAutoReset(False) + if total == 0: + self.setRange(0, 0) + else: + self.setValue(0) + self.tasks_added.connect(self.add_tasks) + self.task_finished.connect(self.update_task) + self.producer_finished.connect(self.finish_producer) + + status_label = self.findChild(QLabel) + if status_label is not None: + status_label.setWordWrap(True) + status_label.setMaximumWidth(500) + self.show() + + def add_tasks(self, count: int): + if self.maximum() == 0: + self.setRange(0, count) + else: + self.setMaximum(self.maximum() + count) + self.setValue(self.completed_count) + self.setLabelText(f"Downloaded {self.completed_count}/{self.maximum()} videos") + + def finish_future(self, result, video=None, playlist_name=None): + video_title = (video or {}).get("title") or "Unknown video" + video_id = (video or {}).get("id") or "unknown" + display_name = f"{video_title} [{video_id}]" + if playlist_name: + display_name = f"{playlist_name} / {display_name}" + if isinstance(result, Exception): + self.task_finished.emit(f"{display_name}\n{result}", False) + return + try: + path = result.result() + except Exception as error: + self.task_finished.emit(f"{display_name}\n{error}", False) + return + self.task_finished.emit(path.name, True) + + def update_task(self, message: str, succeeded: bool): + self.completed_count += 1 + if not succeeded: + self.failed_count += 1 + self.failed_videos.append(message) + self.setValue(self.completed_count) + status = f"Downloaded {self.completed_count}/{self.maximum()} videos" + if self.failed_count: + status += f" ({self.failed_count} failed)" + self.setLabelText(f"{status}\n{message}") + self.show_failure_summary_if_finished() + + def finish_producer(self): + self.remaining_producers = max(0, self.remaining_producers - 1) + self.show_failure_summary_if_finished() + + def show_failure_summary_if_finished(self): + if self.summary_shown or self.remaining_producers > 0: + return + if self.maximum() == 0 or self.completed_count < self.maximum(): + return + self.summary_shown = True + if not self.failed_videos: + return + + def show_summary(): + should_close = ask_yes_no_with_plain_text( + self.parentWidget(), + "Download failures", + f"{len(self.failed_videos)} video(s) could not be downloaded. " + "Close the download progress window?", + "\n\n".join(self.failed_videos), + ) + if should_close: + self.hide() + + QTimer.singleShot(0, show_summary) + +class PlayerDialog(QDialog): + status_changed = Signal(str) + + def __init__(self, app, video: dict, /, parent): + QDialog.__init__(self, parent) + self.app = app + self.parent = parent + self.video = video + self.video_url = resolve_video_page_url(video) + self.process = None + self.setWindowTitle(video.get("title", "Video")) + self.resize(440, 190) + + self.status_changed.connect(self.set_status) + + self.layout = QVBoxLayout(self) + self.layout.setContentsMargins(16, 16, 16, 16) + self.layout.setSpacing(10) + + self.title_label = QLabel(video.get("title", "Video"), self) + self.title_label.setWordWrap(True) + + self.status_label = QLabel("Launching mpv...", self) + self.url_label = QLabel(self.video_url or "", self) + self.url_label.setWordWrap(True) + + self.close_btn = QPushButton("Close Player", self) + self.close_btn.clicked.connect(self.close) + + self.layout.addWidget(self.title_label) + self.layout.addWidget(self.status_label) + self.layout.addWidget(self.url_label) + self.layout.addWidget(self.close_btn) + + self.setStyleSheet(""" + QDialog { + background-color: #202020; + } + + QLabel { + background-color: transparent; + color: #f0f0f0; + } + + QPushButton { + background-color: #3a3a3a; + color: #f0f0f0; + border: 1px solid #505050; + border-radius: 6px; + padding: 7px 10px; + } + + QPushButton:hover { + background-color: #464646; + } + """) + + threading.Thread(target=self.run_mpv, daemon=True).start() + + def set_status(self, message: str): + self.status_label.setText(message) + + def run_mpv(self): + mpv_path = find_mpv_path(PROGRAM_DIR) + if not mpv_path: + self.status_changed.emit("mpv not found in bin/ or PATH.") + return + + try: + self.process = subprocess.Popen( + [ + mpv_path, + # "--force-window=immediate", + "--focus-on=open", + self.video_url, + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + except Exception as e: + logger.error("Unable to start mpv: %s", e) + self.status_changed.emit(f"Unable to start mpv: {e}") + return + + self.app.mpv_processes[self] = self.process + self.status_changed.emit("Playing in mpv...") + self.process.wait() + self.app.mpv_processes.pop(self, None) + self.status_changed.emit("Playback finished.") + + def closeEvent(self, event): + process = self.app.mpv_processes.pop(self, None) + if process and process.poll() is None: + process.terminate() + event.accept() + + +class SwitchProfileDialog(QDialog): + profile_changed = Signal(str) + + def __init__(self, app, /, parent): + QDialog.__init__(self, parent) + self.app = app + self.setWindowTitle("Switch Profile") + self.resize(420, 420) + + # Layout + self.layout = QVBoxLayout(self) + self.layout.setContentsMargins(16, 16, 16, 16) + self.layout.setSpacing(12) + + # Items + self.current_profile_label = QLabel(f"Current: {self.app.get_active_profile_name()}") + self.current_profile_label.setWordWrap(True) + self.current_profile_label.setObjectName("currentProfile") + + # Scroll are and frame (for profile item) + self.scroll_area = QScrollArea(self) + self.scroll_area.setWidgetResizable(True) + self.scroll_area.setFrameShape(QFrame.Shape.NoFrame) + + self.list_frame = QFrame(self.scroll_area) + self.list_frame.setObjectName("profileList") + self.list_layout = QVBoxLayout(self.list_frame) + self.list_layout.setContentsMargins(8, 8, 8, 8) + self.list_layout.setSpacing(8) + self.scroll_area.setWidget(self.list_frame) + + self.create_profile_btn = QPushButton("Create Profile") + self.create_profile_btn.setMinimumHeight(34) + self.create_profile_btn.clicked.connect(self.create_profile) + + self.layout.addWidget(self.current_profile_label) + self.layout.addWidget(self.scroll_area, 1) + self.layout.addWidget(self.create_profile_btn) + + self.render_profiles() + + self.setStyleSheet(""" + QDialog { + background-color: #202020; + } + + QScrollArea, #profileList { + background-color: #242424; + } + + #currentProfile { + background-color: transparent; + color: #f0f0f0; + font-weight: 600; + padding-bottom: 4px; + } + + QFrame#profileRow { + background-color: #2b2b2b; + border: 1px solid #3c3c3c; + border-radius: 8px; + } + + QLabel { + background-color: transparent; + color: #f0f0f0; + } + + QPushButton { + background-color: #3a3a3a; + color: #f0f0f0; + border: 1px solid #505050; + border-radius: 6px; + padding: 7px 10px; + } + + QPushButton:hover { + background-color: #464646; + } + + QPushButton:disabled { + color: #999999; + background-color: #303030; + } + """) + + def clear_profiles(self): + while self.list_layout.count(): + item = self.list_layout.takeAt(0) + widget = item.widget() + if widget: + widget.deleteLater() + + class ProfileRow(QFrame): + def __init__(self, parent, profile_name: str, active_profile: str, + switch_profile, delete_profile): + QFrame.__init__(self, parent) + self.setObjectName("profileRow") + + self.row_layout = QHBoxLayout(self) + self.row_layout.setContentsMargins(10, 8, 10, 8) + self.row_layout.setSpacing(8) + + self.name_label = QLabel(profile_name) + self.name_label.setWordWrap(True) + + self.switch_btn = QPushButton("Current" if profile_name == active_profile else "Switch") + self.switch_btn.setMinimumWidth(88) + self.switch_btn.setEnabled(profile_name != active_profile) + self.switch_btn.clicked.connect(lambda _checked=False, name=profile_name: switch_profile(name)) + + self.delete_btn = QPushButton("Delete") + self.delete_btn.setMinimumWidth(88) + self.delete_btn.setDisabled(profile_name == "Default") + self.delete_btn.clicked.connect(lambda _checked=False, name=profile_name: delete_profile(name)) + + self.row_layout.addWidget(self.name_label, 1) + self.row_layout.addWidget(self.switch_btn) + self.row_layout.addWidget(self.delete_btn) + + def render_profiles(self): + self.clear_profiles() + + meta = self.app.load_profiles_meta() + active_profile = meta["active_profile"] + + for profile_name in meta["profiles"]: + row = self.ProfileRow(self, profile_name, active_profile, self.switch_profile, self.delete_profile) + self.list_layout.addWidget(row) + + self.list_layout.addStretch(1) + + def switch_profile(self, profile_name: str): + self.profile_changed.emit(profile_name) + self.accept() + + def delete_profile(self, profile_name: str): + meta = self.app.load_profiles_meta() + + if profile_name == meta["active_profile"]: + QMessageBox.critical(self, "Error", "Cannot delete the active profile.") + return + + if profile_name == self.app.DEFAULT_PROFILE_NAME: + QMessageBox.critical(self, "Error", "Cannot delete the default profile.") + return + + reply = QMessageBox.question( + self, + "Delete Profile", + f"Delete profile '{profile_name}'?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + + if reply != QMessageBox.StandardButton.Yes: + return + + meta["profiles"] = [ + name for name in meta["profiles"] + if name != profile_name + ] + + self.app.save_profiles_meta(meta) + self.render_profiles() + + def create_profile(self): + profile_name, ok = QInputDialog.getText(self, "Create Profile", "Profile name:") + + if not ok: + return + + profile_name = profile_name.strip() + if not profile_name: + QMessageBox.critical(self, "Error", "Profile name cannot be empty.") + return + + self.app.ensure_profile_exists(profile_name) + self.render_profiles() + + +class PlaylistWindow(QDialog): + class LoadSignals(QObject): + playlist_ready = Signal(dict) + error = Signal(str) + download_status = Signal(str) + + def __init__(self, app, playlist: dict, /, parent=None, + open_player_callback=None): + QDialog.__init__(self, parent) + self.app = app + self.playlist = playlist + self.setWindowTitle(playlist.get("title", "Playlist")) + self.resize(760, 640) + self.signals = self.LoadSignals() + self.signals.playlist_ready.connect(self.render_videos) + self.signals.error.connect(self.show_error) + + self.open_player_callback = open_player_callback + + # Layout + self.layout = QVBoxLayout(self) + self.layout.setContentsMargins(16, 16, 16, 16) + self.layout.setSpacing(12) + + self.header_label = QLabel(playlist.get("title", "Playlist"), self) + self.header_label.setObjectName("playlistTitle") + self.header_label.setWordWrap(True) + + self.info_label = QLabel("Loading playlist videos...", self) + self.info_label.setObjectName("playlistInfo") + self.signals.download_status.connect(self.info_label.setText) + + self.content_layout = QHBoxLayout() + self.content_layout.setSpacing(12) + + # Scroll area and frame for video items + self.scroll_area = QScrollArea(self) + self.scroll_area.setWidgetResizable(True) + self.scroll_area.setFrameShape(QFrame.Shape.NoFrame) + + self.list_frame = QFrame(self.scroll_area) + self.list_frame.setObjectName("playlistFrame") + self.list_layout = QVBoxLayout(self.list_frame) + self.list_layout.setContentsMargins(8, 8, 8, 8) + self.list_layout.setSpacing(8) + self.scroll_area.setWidget(self.list_frame) + + self.control_panel = QFrame(self) + self.control_panel.setObjectName("playlistControls") + self.control_panel.setFixedWidth(150) + self.control_layout = QVBoxLayout(self.control_panel) + self.control_layout.setContentsMargins(12, 12, 12, 12) + self.control_layout.setSpacing(10) + + self.reload_playlist_btn = QPushButton("Reload") + self.save_playlist_btn = QPushButton("Save Playlist") + self.reload_playlist_btn.setMinimumHeight(34) + self.reload_playlist_btn.clicked.connect(self.render_playlist) + self.save_playlist_btn.clicked.connect(self.save_playlist) + + self.control_layout.addWidget(self.reload_playlist_btn) + self.control_layout.addWidget(self.save_playlist_btn) + self.control_layout.addStretch(1) + + self.content_layout.addWidget(self.scroll_area, 1) + self.content_layout.addWidget(self.control_panel) + + self.layout.addWidget(self.header_label) + self.layout.addWidget(self.info_label) + self.layout.addLayout(self.content_layout, 1) + + self.render_playlist() + + self.setStyleSheet(""" + QDialog { + background-color: #202020; + } + + QScrollArea, #playlistFrame { + background-color: #242424; + } + + #playlistTitle { + color: #f0f0f0; + font-size: 20px; + font-weight: 700; + } + + #playlistInfo { + color: #a8a8a8; + } + + #playlistControls { + background-color: #2b2b2b; + border: 1px solid #3c3c3c; + border-radius: 8px; + } + + QLabel { + background-color: transparent; + color: #f0f0f0; + } + + QPushButton { + background-color: #3a3a3a; + color: #f0f0f0; + border: 1px solid #505050; + border-radius: 6px; + padding: 7px 10px; + } + + QPushButton:hover { + background-color: #464646; + } + + QPushButton:disabled { + color: #999999; + background-color: #303030; + } + """) + + def render_playlist(self): + self.reload_playlist_btn.setEnabled(False) + self.info_label.setText("Loading playlist videos...") + self.app.main_window.clear_layout(self.list_layout) + + threading.Thread(target=self.load_playlist_worker, daemon=True).start() + + def load_playlist_worker(self): + try: + playlist_data = self.app.fetch_playlist_videos(self.playlist) + except Exception as e: + logger.error("Unable to load playlist videos: %s", e) + self.signals.error.emit(str(e)) + return + + if playlist_data is None: + playlist_data = {"entries": []} + + self.signals.playlist_ready.emit(playlist_data) + + def render_videos(self, playlist_data: dict): + self.playlist_data = playlist_data + self.app.main_window.clear_layout(self.list_layout) + self.reload_playlist_btn.setEnabled(True) + + entries = [entry for entry in playlist_data.get("entries", []) if entry is not None] + self.info_label.setText(f"{len(entries)} videos") + + if not entries: + self.list_layout.addWidget(QLabel("No videos found.", self.list_frame)) + self.list_layout.addStretch(1) + return + + for index, video in enumerate(entries, start=1): + thumbnail_path = self.get_video_thumbnail_path(video) + item = VideoItem( + self.list_frame, video, index, thumbnail_path, + thumbnail_size=self.app.THUMBNAIL_SIZE, + download_video_callback=lambda _checked=False, current=video: self.download_video(current), + ) + item.clicked.connect(self.open_player_callback) + self.list_layout.addWidget(item) + + self.list_layout.addStretch(1) + + def save_playlist(self): + data = getattr(self, "playlist_data", None) + total = len([entry for entry in data.get("entries", []) if entry]) if data else 0 + self.progress_dialog = DownloadProgressDialog( + "Save Playlist", total, self.app.main_window, producers=1 + ) + queued_callback = None if data else self.progress_dialog.tasks_added.emit + self.app.download_playlist( + self.playlist, data, self.progress_dialog.finish_future, queued_callback, + self.progress_dialog.producer_finished.emit, + ) + + def download_video(self, video: dict): + self.progress_dialog = DownloadProgressDialog("Download Video", 1, self.app.main_window) + self.app.download_video(video, callback=self.progress_dialog.finish_future) + + def on_download_done(self, result, video=None, playlist_name=None): + if isinstance(result, Exception): + self.signals.error.emit(f"Download failed: {result}") + return + try: + path = result.result() + except Exception as error: + self.signals.error.emit(f"Download failed: {error}") + return + self.signals.download_status.emit(f"Downloaded: {path.name}") + + def show_error(self, message: str): + self.reload_playlist_btn.setEnabled(True) + self.info_label.setText("Unable to load playlist.") + QMessageBox.critical(self, "Error", f"Unable to load playlist: {message}") + + def get_video_thumbnail_path(self, video: dict): + thumbnails = video.get("thumbnails") or [] + if not thumbnails: + return None + + thumbnail = thumbnails[0] + thumbnail_url = thumbnail.get("url") + if not thumbnail_url: + return None + + thumbnail_resolution = thumbnail.get("resolution", "unknown") + video_id = video.get("id") or "unknown" + thumbnail_path = Path(self.app.temp_path, "thumbnails", + f"thumbnail_{video_id}_{thumbnail_resolution}.jpg") + + if thumbnail_path.exists(): + return thumbnail_path + + if download_file(thumbnail_url, thumbnail_path): + return thumbnail_path + + return None diff --git a/main.py b/main.py index f54df55..0469498 100644 --- a/main.py +++ b/main.py @@ -1,1347 +1,49 @@ -import copy -import datetime -import json import logging import os -import shutil -import subprocess import sys -import threading -import traceback -import webbrowser from pathlib import Path -from yt_dlp.networking.exceptions import HTTPError -from typing import Callable import click -import requests -from yt_dlp import YoutubeDL -from utils import save_json, read_json, check_url_scheme, parser_url_scheme, find_mpv_path, find_http_status +import platformdirs # Qt -from PySide6.QtWidgets import ( - QApplication, QMainWindow, QWidget, QDialog, - QLabel, QPushButton, QFrame, QScrollArea, - QVBoxLayout, QHBoxLayout, QGridLayout, - QMessageBox, QInputDialog, QSizePolicy, QFileDialog, -) -from PySide6.QtCore import Qt, QTimer, Signal, QObject, QThread, QSize, QUrl -from PySide6.QtGui import QPixmap, QFont, QImage, QIcon, QDesktopServices +from PySide6.QtWidgets import QApplication, QMessageBox, QFileDialog, QMainWindow +from PySide6.QtGui import QIcon -VERSION = "Alpha-2-QtTest" -PROGRAM_DIR = Path(__file__).parent - -work_dir = None +from app import PlaylistSaver +from constant import PROGRAM_DIR +from pl_lib import messagebox logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) -YT_PLAYLISTS_URL = "https://www.youtube.com/feed/playlists" -def sanitize_filename(value: str): - invalid_chars = '<>:"/\\|?*' - return "".join("_" if char in invalid_chars else char for char in value) - - -def fetch_video_details(video_url: str, opts): - opts = copy.deepcopy(opts) - opts.update({"extract_flat": True, }) - - try: - with YoutubeDL(opts) as ydl: - info = ydl.extract_info(video_url, download=False) - except Exception as e: - logger.error(f"Unable to fetch video details: {e}\nURL:{video_url}") - return None - - return info - - -def resolve_video_page_url(video: dict): - return ( - video.get("webpage_url") - or video.get("url") - or (f"https://www.youtube.com/watch?v={video.get('id')}" if video.get("id") else None) - ) - - -def download_file(url, dest: Path): - try: - dest.parent.mkdir(parents=True, exist_ok=True) - with requests.get(url, stream=True) as r: - with dest.open(mode="wb") as f: - for chunk in r.iter_content(chunk_size=8192): - f.write(chunk) - return True - except Exception as e: - logger.error("Unable to download file: %s", e) - return False - - -fetch_playlist_lock = threading.Lock() - -class PlaylistFetchException(Exception): - def __init__(self, url, title, reason, enable_option=False, options=None, no_cancel=False): - super().__init__() - self.url = url - self.title = title - self.reason = reason - self.qt_options = { - "enable": enable_option, - "options": options, - "noCancelOption": no_cancel, - } - - def create_messagebox(self, master): - box = QMessageBox(master) - box.setWindowTitle(self.title) - box.setText(self.reason or self.title) - box.setIcon(QMessageBox.Icon.Critical) - - is_custom_option_enabled = self.qt_options["enable"] is True and type(self.qt_options["options"]) is list - - if is_custom_option_enabled: - for option in self.qt_options["options"]: - label = option.get("label", None) - action_func = option.get("action", None) - role = option.get("role", QMessageBox.ButtonRole.AcceptRole) - - # Some type check - if label is None or (action_func is None or not callable(action_func)): - logger.warning(f"Skipping {option} ({action_func}) because its label or action is not set yet or not callable.") - continue - - if not isinstance(role, QMessageBox.ButtonRole): - logger.warning(f"Skipping {option} ({role}) because its role type is not QMessageBox.ButtonRole.") - continue - - btn = box.addButton(label, role) - option["button"] = btn - - if not self.qt_options["noCancelOption"]: - box.addButton("Cancel", QMessageBox.ButtonRole.RejectRole) - elif not is_custom_option_enabled: - box.addButton(QMessageBox.StandardButton.Ok) - - box.exec() - - clicked = box.clickedButton() - - # If the option's button is clicked, Call the target action function - if is_custom_option_enabled: - for option in self.qt_options["options"]: - btn = option.get("button") - action_func = option.get("action") - - if clicked == btn: - return action_func() - - return None - -def fetch_playlists_data(url_schema, cookie_file_path: Path, opts, callback: Callable): - check_url_scheme(os.path.abspath(__file__), work_dir) - - error = None - - if url_schema is not None: - try: - parser_url_scheme(url_schema, cookie_path=cookie_file_path) - except Exception as e: - logger.error("Unable to parse URL scheme: %s", e) - input("Press enter to continue...") - - playlists_data = None - - with fetch_playlist_lock: - opts = copy.deepcopy(opts) - opts.update({ - 'extract_flat': True, - 'skip_download': True, - }) - - try: - with YoutubeDL(opts) as ydl: - playlists_data = ydl.extract_info(YT_PLAYLISTS_URL, download=False) - except Exception as e: - status_code = find_http_status(e) - if status_code == 401: - error = "Cookie are expired. Reopen the tool again in browser!", None - elif status_code == 403: - error = "Server forbidden. Did you logged in?", None - elif status_code == 429: - error = "Too many requests. Try again later.", None - elif status_code == 503: - error = "Server unavailable. Try again later.", None - elif status_code == 522: - error = "Connection timed out.", None - else: - error = (f"Unexpected error: {e} (Type: {type(e)})\n" - f"Traceback: {traceback.format_exc()}", None) - - if playlists_data is None: - logger.error(f"Unable to fetch playlists: {error}") - playlists_data = {"entries": []} - - pf_exec = None - if error is not None: - reason = error[0] if isinstance(error, tuple) else error - pf_exec = PlaylistFetchException( - url=YT_PLAYLISTS_URL, - title="Playlists Fetch Error", - reason=reason, - enable_option=True, - options=[ - {"label": "Reopen browser", - "action": lambda: webbrowser.open(YT_PLAYLISTS_URL)}, - ], - ) - - callback(playlists_data, pf_exec) - - -class PlaylistSaver(QApplication): - DEFAULT_PROFILE_NAME = "default" - THUMBNAIL_SIZE = QSize(144, 81) - mpv_processes = {} - - def __init__(self, url_schema=None, cookie_file: str | None = None): - super().__init__() - self.setApplicationName("PlaylistSaver") - - self.url_schema = url_schema - self.cookie_file = cookie_file - - # Path - self.save_path = Path(work_dir, "save") - self.temp_path = Path(work_dir, "temp") - self.profiles_meta_path = Path(self.save_path, "profiles.json") - self.profiles_path = Path(self.save_path, "profiles") - self.cookie_path = Path(self.profiles_path, self.DEFAULT_PROFILE_NAME, "cookies.txt") - - # Profile - self.active_profile_name = self.DEFAULT_PROFILE_NAME - - # Data - self.playlists: dict[str, list] = { - "entries": [] - } - - self.window = self.MainWindow(self) - - self.init() - self.window.show() - - class MainWindow(QMainWindow): - def __init__(self, parent): - super().__init__() - self.parent = parent - self.child_windows = [] - self.setWindowTitle("PlaylistSaver") - self.resize(700, 800) - - # Central widget - self.central_widget = QWidget(self) - self.setCentralWidget(self.central_widget) - - # Layouts - self.main_layout = QHBoxLayout(self.central_widget) - self.main_layout.setContentsMargins(12, 12, 12, 12) - self.main_layout.setSpacing(12) - - self.right_layout = QVBoxLayout() - self.right_layout.setContentsMargins(12, 12, 12, 12) - self.right_layout.setSpacing(10) - - self.playlists_layout = QVBoxLayout() - self.playlists_layout.setContentsMargins(10, 10, 10, 10) - self.playlists_layout.setSpacing(8) - - # Frame - self.playlists_scroll = QScrollArea(self) - self.playlists_scroll.setWidgetResizable(True) - self.playlists_scroll.setFrameShape(QFrame.NoFrame) - - self.playlists_frame = QFrame(self.playlists_scroll) - self.playlists_frame.setLayout(self.playlists_layout) - self.playlists_scroll.setWidget(self.playlists_frame) - - self.playlists_scroll.setMinimumWidth(400) - - self.right_panel = QFrame(self.central_widget) - self.right_panel.setObjectName("rightPanel") - self.right_panel.setFixedWidth(190) - self.right_panel.setLayout(self.right_layout) - - # Items - self.profile_label = QLabel(f"Current Profile: {self.parent.get_active_profile_name()}",) - self.profile_label.setObjectName("profileLabel") - self.profile_label.setWordWrap(True) - - self.loading_label = QLabel("Loading...") - self.loading_label.setObjectName("loadingLabel") - self.loading_label.setAlignment(Qt.AlignCenter) - - self.switch_profile_btn = QPushButton("Switch Profile") - self.refresh_playlists_btn = QPushButton("Refresh playlists") - self.switch_profile_btn.setMinimumHeight(34) - self.refresh_playlists_btn.setMinimumHeight(34) - - self.version_label = QLabel(f"v{VERSION}") - self.version_label.setObjectName("versionLabel") - - self.playlist_items_layout = QVBoxLayout() - self.playlist_items_layout.setContentsMargins(0, 0, 0, 0) - self.playlist_items_layout.setSpacing(8) - - self.playlists_layout.addWidget(self.loading_label, 0, Qt.AlignHCenter) - self.playlists_layout.addLayout(self.playlist_items_layout) - - # Bind item - self.left_layout = QVBoxLayout() - self.left_layout.setContentsMargins(0, 0, 0, 0) - self.left_layout.setSpacing(10) - self.left_layout.addWidget(self.playlists_scroll, 1) - - self.right_layout.addWidget(self.profile_label) - self.right_layout.addWidget(self.switch_profile_btn) - self.right_layout.addWidget(self.refresh_playlists_btn) - self.right_layout.addStretch(1) - self.right_layout.addWidget(self.version_label, 0, Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignHCenter) - - self.main_layout.addLayout(self.left_layout, 1) - self.main_layout.addWidget(self.right_panel) - - self.setStyleSheet(""" - QMainWindow { - background-color: #202020; - } - - QScrollArea, QFrame { - background-color: #242424; - } - - QLabel { - background-color: transparent; - } - - #rightPanel { - background-color: #2b2b2b; - border: 1px solid #3c3c3c; - border-radius: 8px; - } - - #profileLabel { - color: #f0f0f0; - font-weight: 600; - padding-bottom: 6px; - } - - #loadingLabel { - color: #a8a8a8; - padding: 4px; - } - - QPushButton { - background-color: #3a3a3a; - color: #f0f0f0; - border: 1px solid #505050; - border-radius: 6px; - padding: 7px 10px; - } - - QPushButton:hover { - background-color: #464646; - } - - QPushButton:pressed { - background-color: #303030; - } - """) - - # Bind signals function - self.signals = self.PlaylistWorkerSignals() - self.signals.playlists_ready.connect(self.build_ui) - self.signals.error.connect(self.show_error) - self.signals.playlist_fetch_error.connect(self.show_playlist_fetch_error) - - self.switch_profile_btn.clicked.connect(self.open_switch_profile_dialog) - self.refresh_playlists_btn.clicked.connect(self.parent.reload_playlists) - - class PlaylistWorkerSignals(QObject): - # Signals - playlists_ready = Signal(dict) - profile_delete = Signal(str) - - # playlist - playlist_clicked = Signal(dict) - video_clicked = Signal(str) - - # error - error = Signal(str) - playlist_fetch_error = Signal(object) - - def build_ui(self, playlists: dict[str, list]): - self.clear_layout(self.playlist_items_layout) - - playlists = playlists.get("entries", []) - - playlists = [playlist for playlist in playlists if playlist is not None] # ignore None item - - for playlist in playlists: - plist_id = playlist.get("id", None) - title = playlist.get("title", "Title not found") - - item = self.PlaylistItem( - self.playlists_frame, - playlist, - title=title, - ) - - if len(playlist.get("thumbnails", [])) > 0: - thumbnail_url = playlist.get("thumbnails", [])[0].get('url', None) - thumbnail_resolution = playlist.get("thumbnails", [])[0].get('resolution', None) - - thumbnail_path = Path(work_dir, "temp", "thumbnails", - f"thumbnail_{plist_id}_{thumbnail_resolution}.jpg") - - result = download_file(thumbnail_url, thumbnail_path) - - if result or (thumbnail_path.exists() and thumbnail_path.is_file()): - item.set_thumbnail(thumbnail_path) - - item.clicked.connect(self.open_playlist_window) - self.playlist_items_layout.addWidget(item) - - self.loading_label.setText("Done") - - @staticmethod - def clear_layout(layout): - while layout.count(): - item = layout.takeAt(0) - child_layout = item.layout() - widget = item.widget() - if child_layout: - PlaylistSaver.MainWindow.clear_layout(child_layout) - if widget: - widget.deleteLater() - - def open_playlist_window(self, playlist: dict): - window = self.PlaylistWindow(self, playlist) - self.child_windows.append(window) - window.destroyed.connect( - lambda _obj=None, w=window: self.child_windows.remove(w) if w in self.child_windows else None) - window.show() - - def show_error(self, message: str): - QMessageBox.critical(self, "Error", message) - - def show_playlist_fetch_error(self, error: PlaylistFetchException): - error.create_messagebox(self) - - def open_video_player(self, video: dict): - video_url = resolve_video_page_url(video) - if not video_url: - QMessageBox.critical(self, "Error", "Unable to resolve video URL.") - return - - dialog = self.PlayerDialog(self, video) - self.child_windows.append(dialog) - dialog.destroyed.connect( - lambda _obj=None, w=dialog: self.child_windows.remove(w) if w in self.child_windows else None) - dialog.show() - - def open_switch_profile_dialog(self): - dialog = self.SwitchProfileDialog(self) - dialog.profile_changed.connect(self.apply_profile_switch) - dialog.exec() - - def apply_profile_switch(self, profile_name: str): - self.parent.set_active_profile(profile_name) - self.profile_label.setText(f"Current Profile: {profile_name}") - - class PlaylistItem(QFrame): - clicked = Signal(dict) - - def __init__(self, parent, data, title: str = "", thumbnail_path: str | Path | None = None): - super().__init__(parent) - self.setObjectName("playlistItem") - self.original_thumbnail = None - - self.layout = QHBoxLayout(self) - self.layout.setContentsMargins(12, 10, 12, 10) - self.layout.setSpacing(12) - - self.setMinimumHeight(92) - self.setMaximumHeight(120) - self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - - # item - self.title_label = QLabel(title, self) - self.title_label.setObjectName("playlistTitle") - self.title_label.setWordWrap(True) - self.title_label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft) - self.title_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) - - self.thumbnail_label = QLabel(self) - self.thumbnail_label.setObjectName("thumbnailLabel") - self.thumbnail_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.thumbnail_label.setFixedSize(PlaylistSaver.THUMBNAIL_SIZE) - self.thumbnail_label.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) - - # bind - self.layout.addWidget(self.title_label, 1) - self.layout.addWidget(self.thumbnail_label) - - if thumbnail_path: - self.set_thumbnail(thumbnail_path) - - self.setStyleSheet(""" - QFrame#playlistItem { - background-color: #2b2b2b; - border: 1px solid #3c3c3c; - border-radius: 8px; - } - - QFrame#playlistItem:hover { - background-color: #4f0202; - border-color: #555555; - } - - QLabel { - background-color: transparent; - border: none; - color: #f0f0f0; - } - - #playlistTitle { - font-weight: 600; - } - - #thumbnailLabel { - background-color: #1f1f1f; - border: 1px solid #3a3a3a; - border-radius: 6px; - } - """) - - # Playlist data - self.data = data - - @property - def app(self): - return self.parent() - - def set_thumbnail(self, thumbnail_path: str | Path): - pixmap = QPixmap(str(thumbnail_path)) - if pixmap.isNull(): - self.thumbnail_label.hide() - return - - self.original_thumbnail = pixmap - self.thumbnail_label.show() - self.update_thumbnail_size() - - def update_thumbnail_size(self): - if not self.original_thumbnail: - return - - scaled = self.original_thumbnail.scaled( - self.thumbnail_label.size(), - Qt.AspectRatioMode.KeepAspectRatio, - Qt.TransformationMode.SmoothTransformation, - ) - self.thumbnail_label.setPixmap(scaled) - - def resizeEvent(self, event): - super().resizeEvent(event) - self.update_thumbnail_size() - - def mousePressEvent(self, event): - if event.button() == Qt.MouseButton.LeftButton: - self.clicked.emit(self.data) - super().mousePressEvent(event) - - class VideoItem(QFrame): - clicked = Signal(dict) - - def __init__(self, parent, video: dict, index: int = 0, thumbnail_path: str | Path | None = None): - super().__init__(parent) - self.setObjectName("videoItem") - self.original_thumbnail = None - self.video = video - self.id = video.get("id") - self.url = resolve_video_page_url(video) - - title = video.get("title", "Unnamed Video") - if index: - title = f"{index}. {title}" - - if video.get("duration"): - duration = str(datetime.timedelta(seconds=video.get("duration"))) - else: - duration = "Fetching duration..." - - self.layout = QHBoxLayout(self) - self.layout.setContentsMargins(12, 10, 12, 10) - self.layout.setSpacing(12) - - self.setMinimumHeight(92) - self.setMaximumHeight(120) - self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - - # item - self.title_label = QLabel(title, self) - self.title_label.setObjectName("videoTitle") - self.title_label.setWordWrap(True) - self.title_label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft) - self.title_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) - - self.duration_label = QLabel(duration, self) - self.duration_label.setObjectName("durationLabel") - self.duration_label.setAlignment(Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignLeft) - - self.download_button = QPushButton("Download", self) - self.download_button.setObjectName("downloadButton") - - self.thumbnail_label = QLabel(self) - self.thumbnail_label.setObjectName("thumbnailLabel") - self.thumbnail_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.thumbnail_label.setFixedSize(PlaylistSaver.THUMBNAIL_SIZE) - self.thumbnail_label.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) - self.thumbnail_label.hide() - - # bind - self.text_layout = QVBoxLayout() - self.text_layout.setContentsMargins(0, 0, 0, 0) - self.text_layout.setSpacing(6) - self.text_layout.addWidget(self.title_label, 1) - self.text_layout.addWidget(self.duration_label) - self.text_layout.addWidget(self.download_button, 1, - Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignRight) - - self.layout.addLayout(self.text_layout, 1) - self.layout.addWidget(self.thumbnail_label) - - if thumbnail_path: - self.set_thumbnail(thumbnail_path) - - self.setStyleSheet(""" - QFrame#videoItem { - background-color: #2b2b2b; - border: 1px solid #3c3c3c; - border-radius: 8px; - } - - QFrame#videoItem:hover { - background-color: #333333; - border-color: #555555; - } - - QLabel { - background-color: transparent; - border: none; - color: #f0f0f0; - } - - #videoTitle { - font-weight: 600; - } - - #thumbnailLabel { - background-color: #1f1f1f; - border: 1px solid #3a3a3a; - border-radius: 6px; - } - - #downloadButton { - max-height: 30px; - } - """) - - self.setCursor(Qt.CursorShape.PointingHandCursor) - - def set_thumbnail(self, thumbnail_path: str | Path): - pixmap = QPixmap(str(thumbnail_path)) - if pixmap.isNull(): - self.thumbnail_label.hide() - return - - self.original_thumbnail = pixmap - self.thumbnail_label.show() - self.update_thumbnail_size() - - def update_thumbnail_size(self): - if not self.original_thumbnail: - return - - scaled = self.original_thumbnail.scaled( - self.thumbnail_label.size(), - Qt.AspectRatioMode.KeepAspectRatio, - Qt.TransformationMode.SmoothTransformation, - ) - self.thumbnail_label.setPixmap(scaled) - - def resizeEvent(self, event): - super().resizeEvent(event) - self.update_thumbnail_size() - - def mousePressEvent(self, event): - if event.button() == Qt.MouseButton.LeftButton: - self.clicked.emit(self.video) - super().mousePressEvent(event) - - def set_duration(self, duration: str): - self.duration_label.setText(duration) - - class PlaylistWindow(QDialog): - class LoadSignals(QObject): - playlist_ready = Signal(dict) - error = Signal(str) - - def __init__(self, parent, playlist: dict): - QDialog.__init__(self, parent) - self.parent = parent - self.playlist = playlist - self.setWindowTitle(playlist.get("title", "Playlist")) - self.resize(760, 640) - self.signals = self.LoadSignals() - self.signals.playlist_ready.connect(self.render_videos) - self.signals.error.connect(self.show_error) - - # Layout - self.layout = QVBoxLayout(self) - self.layout.setContentsMargins(16, 16, 16, 16) - self.layout.setSpacing(12) - - self.header_label = QLabel(playlist.get("title", "Playlist"), self) - self.header_label.setObjectName("playlistTitle") - self.header_label.setWordWrap(True) - - self.info_label = QLabel("Loading playlist videos...", self) - self.info_label.setObjectName("playlistInfo") - - self.content_layout = QHBoxLayout() - self.content_layout.setSpacing(12) - - # Scroll area and frame for video items - self.scroll_area = QScrollArea(self) - self.scroll_area.setWidgetResizable(True) - self.scroll_area.setFrameShape(QFrame.NoFrame) - - self.list_frame = QFrame(self.scroll_area) - self.list_frame.setObjectName("playlistFrame") - self.list_layout = QVBoxLayout(self.list_frame) - self.list_layout.setContentsMargins(8, 8, 8, 8) - self.list_layout.setSpacing(8) - self.scroll_area.setWidget(self.list_frame) - - self.control_panel = QFrame(self) - self.control_panel.setObjectName("playlistControls") - self.control_panel.setFixedWidth(150) - self.control_layout = QVBoxLayout(self.control_panel) - self.control_layout.setContentsMargins(12, 12, 12, 12) - self.control_layout.setSpacing(10) - - self.reload_playlist_btn = QPushButton("Reload") - self.reload_playlist_btn.setMinimumHeight(34) - self.reload_playlist_btn.clicked.connect(self.render_playlist) - - self.control_layout.addWidget(self.reload_playlist_btn) - self.control_layout.addStretch(1) - - self.content_layout.addWidget(self.scroll_area, 1) - self.content_layout.addWidget(self.control_panel) - - self.layout.addWidget(self.header_label) - self.layout.addWidget(self.info_label) - self.layout.addLayout(self.content_layout, 1) - - self.render_playlist() - - self.setStyleSheet(""" - QDialog { - background-color: #202020; - } - - QScrollArea, #playlistFrame { - background-color: #242424; - } - - #playlistTitle { - color: #f0f0f0; - font-size: 20px; - font-weight: 700; - } - - #playlistInfo { - color: #a8a8a8; - } - - #playlistControls { - background-color: #2b2b2b; - border: 1px solid #3c3c3c; - border-radius: 8px; - } - - QLabel { - background-color: transparent; - color: #f0f0f0; - } - - QPushButton { - background-color: #3a3a3a; - color: #f0f0f0; - border: 1px solid #505050; - border-radius: 6px; - padding: 7px 10px; - } - - QPushButton:hover { - background-color: #464646; - } - - QPushButton:disabled { - color: #999999; - background-color: #303030; - } - """) - - @property - def app(self): - return self.parent.parent - - def render_playlist(self): - self.reload_playlist_btn.setEnabled(False) - self.info_label.setText("Loading playlist videos...") - PlaylistSaver.MainWindow.clear_layout(self.list_layout) - - threading.Thread(target=self.load_playlist_worker, daemon=True).start() - - def load_playlist_worker(self): - try: - playlist_data = self.app.fetch_playlist_videos(self.playlist) - except Exception as e: - logger.error("Unable to load playlist videos: %s", e) - self.signals.error.emit(str(e)) - return - - if playlist_data is None: - playlist_data = {"entries": []} - - self.signals.playlist_ready.emit(playlist_data) - - def render_videos(self, playlist_data: dict): - PlaylistSaver.MainWindow.clear_layout(self.list_layout) - self.reload_playlist_btn.setEnabled(True) - - entries = [entry for entry in playlist_data.get("entries", []) if entry is not None] - self.info_label.setText(f"{len(entries)} videos") - - if not entries: - self.list_layout.addWidget(QLabel("No videos found.", self.list_frame)) - self.list_layout.addStretch(1) - return - - for index, video in enumerate(entries, start=1): - thumbnail_path = self.get_video_thumbnail_path(video) - item = PlaylistSaver.MainWindow.VideoItem(self.list_frame, video, index, thumbnail_path) - item.clicked.connect(self.parent.open_video_player) - self.list_layout.addWidget(item) - - self.list_layout.addStretch(1) - - def show_error(self, message: str): - self.reload_playlist_btn.setEnabled(True) - self.info_label.setText("Unable to load playlist.") - QMessageBox.critical(self, "Error", f"Unable to load playlist: {message}") - - def get_video_thumbnail_path(self, video: dict): - thumbnails = video.get("thumbnails") or [] - if not thumbnails: - return None - - thumbnail = thumbnails[0] - thumbnail_url = thumbnail.get("url") - if not thumbnail_url: - return None - - thumbnail_resolution = thumbnail.get("resolution", "unknown") - video_id = video.get("id") or "unknown" - thumbnail_path = Path(work_dir, "temp", "thumbnails", - f"thumbnail_{video_id}_{thumbnail_resolution}.jpg") - - if thumbnail_path.exists(): - return thumbnail_path - - if download_file(thumbnail_url, thumbnail_path): - return thumbnail_path - - return None - - class PlayerDialog(QDialog): - status_changed = Signal(str) - - def __init__(self, parent, video: dict): - QDialog.__init__(self, parent) - self.parent = parent - self.video = video - self.video_url = resolve_video_page_url(video) - self.process = None - self.setWindowTitle(video.get("title", "Video")) - self.resize(440, 190) - - self.status_changed.connect(self.set_status) - - self.layout = QVBoxLayout(self) - self.layout.setContentsMargins(16, 16, 16, 16) - self.layout.setSpacing(10) - - self.title_label = QLabel(video.get("title", "Video"), self) - self.title_label.setWordWrap(True) - - self.status_label = QLabel("Launching mpv...", self) - self.url_label = QLabel(self.video_url or "", self) - self.url_label.setWordWrap(True) - - self.close_btn = QPushButton("Close Player", self) - self.close_btn.clicked.connect(self.close) - - self.layout.addWidget(self.title_label) - self.layout.addWidget(self.status_label) - self.layout.addWidget(self.url_label) - self.layout.addWidget(self.close_btn) - - self.setStyleSheet(""" - QDialog { - background-color: #202020; - } - - QLabel { - background-color: transparent; - color: #f0f0f0; - } - - QPushButton { - background-color: #3a3a3a; - color: #f0f0f0; - border: 1px solid #505050; - border-radius: 6px; - padding: 7px 10px; - } - - QPushButton:hover { - background-color: #464646; - } - """) - - threading.Thread(target=self.run_mpv, daemon=True).start() - - def set_status(self, message: str): - self.status_label.setText(message) - - def run_mpv(self): - mpv_path = find_mpv_path(PROGRAM_DIR) - if not mpv_path: - self.status_changed.emit("mpv not found in bin/ or PATH.") - return - - try: - self.process = subprocess.Popen( - [ - mpv_path, - # "--force-window=immediate", - "--focus-on=open", - self.video_url, - ], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), - ) - except Exception as e: - logger.error("Unable to start mpv: %s", e) - self.status_changed.emit(f"Unable to start mpv: {e}") - return - - self.parent.parent.mpv_processes[self] = self.process - self.status_changed.emit("Playing in mpv...") - self.process.wait() - self.parent.parent.mpv_processes.pop(self, None) - self.status_changed.emit("Playback finished.") - - def closeEvent(self, event): - process = self.parent.parent.mpv_processes.pop(self, None) - if process and process.poll() is None: - process.terminate() - event.accept() - - class SwitchProfileDialog(QDialog): - profile_changed = Signal(str) - - def __init__(self, parent): - QDialog.__init__(self, parent) - self.parent = parent - self.setWindowTitle("Switch Profile") - self.resize(420, 420) - - # Layout - self.layout = QVBoxLayout(self) - self.layout.setContentsMargins(16, 16, 16, 16) - self.layout.setSpacing(12) - - # Items - self.current_profile_label = QLabel(f"Current: {self.app.get_active_profile_name()}") - self.current_profile_label.setWordWrap(True) - self.current_profile_label.setObjectName("currentProfile") - - # Scroll are and frame (for profile item) - self.scroll_area = QScrollArea(self) - self.scroll_area.setWidgetResizable(True) - self.scroll_area.setFrameShape(QFrame.NoFrame) - - self.list_frame = QFrame(self.scroll_area) - self.list_frame.setObjectName("profileList") - self.list_layout = QVBoxLayout(self.list_frame) - self.list_layout.setContentsMargins(8, 8, 8, 8) - self.list_layout.setSpacing(8) - self.scroll_area.setWidget(self.list_frame) - - self.create_profile_btn = QPushButton("Create Profile") - self.create_profile_btn.setMinimumHeight(34) - self.create_profile_btn.clicked.connect(self.create_profile) - - self.layout.addWidget(self.current_profile_label) - self.layout.addWidget(self.scroll_area, 1) - self.layout.addWidget(self.create_profile_btn) - - self.render_profiles() - - self.setStyleSheet(""" - QDialog { - background-color: #202020; - } - - QScrollArea, #profileList { - background-color: #242424; - } - - #currentProfile { - background-color: transparent; - color: #f0f0f0; - font-weight: 600; - padding-bottom: 4px; - } - - QFrame#profileRow { - background-color: #2b2b2b; - border: 1px solid #3c3c3c; - border-radius: 8px; - } - - QLabel { - background-color: transparent; - color: #f0f0f0; - } - - QPushButton { - background-color: #3a3a3a; - color: #f0f0f0; - border: 1px solid #505050; - border-radius: 6px; - padding: 7px 10px; - } - - QPushButton:hover { - background-color: #464646; - } - - QPushButton:disabled { - color: #999999; - background-color: #303030; - } - """) - - @property - def app(self): - return self.parent.parent - - def clear_profiles(self): - while self.list_layout.count(): - item = self.list_layout.takeAt(0) - widget = item.widget() - if widget: - widget.deleteLater() - - class ProfileRow(QFrame): - def __init__(self, parent, profile_name: str, active_profile: str, - switch_profile, delete_profile): - QFrame.__init__(self, parent) - self.setObjectName("profileRow") - - self.row_layout = QHBoxLayout(self) - self.row_layout.setContentsMargins(10, 8, 10, 8) - self.row_layout.setSpacing(8) - - self.name_label = QLabel(profile_name) - self.name_label.setWordWrap(True) - - self.switch_btn = QPushButton("Current" if profile_name == active_profile else "Switch") - self.switch_btn.setMinimumWidth(88) - self.switch_btn.setEnabled(profile_name != active_profile) - self.switch_btn.clicked.connect(lambda _checked=False, name=profile_name: switch_profile(name)) - - self.delete_btn = QPushButton("Delete") - self.delete_btn.setMinimumWidth(88) - self.delete_btn.setDisabled(profile_name == "Default") - self.delete_btn.clicked.connect(lambda _checked=False, name=profile_name: delete_profile(name)) - - self.row_layout.addWidget(self.name_label, 1) - self.row_layout.addWidget(self.switch_btn) - self.row_layout.addWidget(self.delete_btn) - - def render_profiles(self): - self.clear_profiles() - - meta = self.app.load_profiles_meta() - active_profile = meta["active_profile"] - - for profile_name in meta["profiles"]: - row = self.ProfileRow(self, profile_name, active_profile, self.switch_profile, self.delete_profile) - self.list_layout.addWidget(row) - - self.list_layout.addStretch(1) - - def switch_profile(self, profile_name: str): - self.profile_changed.emit(profile_name) - self.accept() - - def delete_profile(self, profile_name: str): - meta = self.app.load_profiles_meta() - - if profile_name == meta["active_profile"]: - QMessageBox.critical(self, "Error", "Cannot delete the active profile.") - return - - if profile_name == self.app.DEFAULT_PROFILE_NAME: - QMessageBox.critical(self, "Error", "Cannot delete the default profile.") - return - - reply = QMessageBox.question( - self, - "Delete Profile", - f"Delete profile '{profile_name}'?", - QMessageBox.Yes | QMessageBox.No, - QMessageBox.No, - ) - - if reply != QMessageBox.Yes: - return - - meta["profiles"] = [ - name for name in meta["profiles"] - if name != profile_name - ] - - self.app.save_profiles_meta(meta) - self.render_profiles() - - def create_profile(self): - profile_name, ok = QInputDialog.getText(self, "Create Profile", "Profile name:") - - if not ok: - return - - profile_name = profile_name.strip() - if not profile_name: - QMessageBox.critical(self, "Error", "Profile name cannot be empty.") - return - - self.app.ensure_profile_exists(profile_name) - self.render_profiles() - - def init(self): - self.initialize_profiles() - # Start background thread after root is ready for UI callbacks. - self.reload_playlists() - - def reload_playlists(self): - self.window.loading_label.setText("Loading playlists...") - - threading.Thread( - target=fetch_playlists_data, - args=(self.url_schema, self.get_profile_cookie_path(self.active_profile_name), self.get_ydl_opts(), self.on_data_ready), - daemon=True, - ).start() - - # - # Profile - # - def initialize_profiles(self): - try: - self.profiles_path.parent.mkdir(parents=True, exist_ok=True) - except Exception as e: - logger.error("Unable to create profiles folder:", e) - QMessageBox.critical(self.window, "Error", - "Unable to create profiles folder.\n" - f"At path: {self.profiles_path.as_posix()}\n" - f"Reason: {e}\n" - f"Solution: Try to remove profiles folder manually and try again.") - - active_name = self.get_active_profile_name() - self.ensure_profile_exists(active_name) - self.set_active_profile(active_name) - - def ensure_profile_exists(self, profile_name: str): - meta = self.load_profiles_meta() - profiles = meta["profiles"] - - if profile_name not in profiles: - profiles.append(profile_name) - meta["profiles"] = profiles - self.save_profiles_meta(meta) - - self.get_profile_dir(profile_name).parent.mkdir(parents=True, exist_ok=True) - - def get_active_profile_name(self): - meta = self.load_profiles_meta() - self.active_profile_name = meta.get("active_profile", None) - return self.active_profile_name - - def load_profiles_meta(self): - meta = read_json(self.profiles_meta_path) - if not meta: - return { - "active_profile": self.DEFAULT_PROFILE_NAME, - "profiles": [self.DEFAULT_PROFILE_NAME], - } - - profiles = meta.get("profiles") or [self.DEFAULT_PROFILE_NAME] - active_profile = meta.get("active_profile") or profiles[0] - - if active_profile not in profiles: - profiles.insert(0, active_profile) - - return { - "active_profile": active_profile, - "profiles": profiles, - } - - def set_active_profile(self, profile_name: str): - meta = self.load_profiles_meta() - profiles = meta["profiles"] - - if profile_name not in profiles: - profiles.append(profile_name) - - meta["profiles"] = profiles - meta["active_profile"] = profile_name - self.save_profiles_meta(meta) - - self.active_profile_name = profile_name - self.cookie_path = self.get_profile_cookie_path(profile_name) - self.cookie_path.parent.mkdir(parents=True, exist_ok=True) - - def save_profiles_meta(self, meta: dict): - save_json(meta, self.profiles_meta_path) - - def delete_profile(self, profile_name: str): - meta = self.load_profiles_meta() - - if not profile_name in meta["profiles"]: - return - - meta["profiles"].remove(profile_name) - - self.save_profiles_meta(meta) - - # - # Path - # - def get_profile_cookie_path(self, profile_name: str): - return Path(self.get_profile_dir(profile_name), "cookies.txt") - - def get_profile_dir(self, profile_name: str): - return Path(self.profiles_path, sanitize_filename(profile_name)) - - def get_profile_temp_dir(self, profile_name: str | None = None): - profile_name = profile_name or self.active_profile_name - return self.temp_path / sanitize_filename(profile_name) - - def get_playlist_cache_path(self, playlist_id: str): - safe_id = sanitize_filename(playlist_id or "unknown") - return self.get_profile_temp_dir() / "playlists" / f"{safe_id}.json" - - # - # PlaylistData - # - def on_data_ready(self, data, error): - if isinstance(error, PlaylistFetchException): - self.window.signals.playlist_fetch_error.emit(error) - if data is None: - data = {"entries": []} - - self.playlists["entries"] = data.get("entries", []) - self.window.signals.playlists_ready.emit(self.playlists) - - def load_playlist_videos(self, playlist_id: str): - cache_path = self.get_playlist_cache_path(playlist_id) - if os.path.exists(cache_path): - return read_json(cache_path) - return None - - def fetch_playlist_videos(self, playlist: dict): - playlist_id = playlist.get("id") - playlist_url = playlist.get("url") or playlist.get("webpage_url") - - if not playlist_url and playlist_id and not str(playlist_id).startswith("VL"): - playlist_url = f"https://www.youtube.com/playlist?list={playlist_id}" - - if not playlist_url: - raise ValueError("Playlist URL not found.") - - cached_data = self.load_playlist_videos(playlist_id) - if cached_data: - return cached_data - - opts = self.get_ydl_opts() - - opts.update({"extract_flat": True, - "skip_download": True, - "quiet": True, }) - - with fetch_playlist_lock: - with YoutubeDL(opts) as ydl: - data = ydl.extract_info(playlist_url, download=False) - - self.save_playlist_videos(playlist_id, data) - return data - - def save_playlist_videos(self, playlist_id: str, data: dict): - save_json(data, self.get_playlist_cache_path(playlist_id)) - - def get_ydl_opts(self): - return { - 'cookiefile': self.get_profile_cookie_path(self.active_profile_name), - 'verbose': True, - 'download': False, - 'js_runtimes': {"node": {}}, - 'extract_flat': False, - 'lazy_playlist': False, - "ignoreerrors": False - } +def show_error(message): + app = QApplication(sys.argv) + app.setApplicationName("Launcher Bootstrapper") + window = QMainWindow() + messagebox.error(window, "Error", message) + app.exit() @click.command() @click.argument("url_schema", default=None, required=False) @click.option("--cookie-file", "-cf", required=False, default=None, help="Cookies file", type=click.Path(exists=True)) -@click.option("--new-work-dir", "-wd", required=False, default=None, help="New working directory", +@click.option("--work-dir", "-wd", required=False, default=None, help="Custom working directory", type=click.Path(exists=True)) -def main(url_schema, cookie_file: str, new_work_dir: str): - global work_dir - +def main(url_schema, cookie_file: str, work_dir: str | None): icon_path = PROGRAM_DIR / "assets" / "icon.ico" - if new_work_dir: - work_dir = Path(new_work_dir) + # Default working directory + if not work_dir: + try: + # Use user application data directory + work_dir = work_dir = platformdirs.user_data_dir("PlaylistSaver", appauthor=False, roaming=True) + os.makedirs(work_dir, exist_ok=True) + except Exception as e: + logger.warning("Failed to get application directory: %s", e) + show_error("Unable to get application directory. Try specifying 'work_dir' argument.") + sys.exit(-1) if work_dir is None: app = QApplication(sys.argv) @@ -1360,18 +62,26 @@ def main(url_schema, cookie_file: str, new_work_dir: str): None, "Warning", "PlaylistSaver requires a working directory to store files. Please specify a working directory" - " using argument \"--new-work-dir\".", + " using argument \"--work-dir\".", ) sys.exit(1) app.shutdown() + logger.info(f"Working directory: {work_dir}") + + # Create app and set icon app = PlaylistSaver( + work_dir, url_schema, - cookie_file, + cookie_file=cookie_file, ) + if icon_path.exists(): app.setWindowIcon(QIcon(icon_path.as_posix())) + else: + logger.warning("Application icon not found.") + sys.exit(app.exec()) diff --git a/pl_lib/messagebox.py b/pl_lib/messagebox.py new file mode 100644 index 0000000..e766748 --- /dev/null +++ b/pl_lib/messagebox.py @@ -0,0 +1,108 @@ +from PySide6.QtWidgets import QMessageBox, QMainWindow, QWidget, QDialog, QVBoxLayout, QLabel, QPlainTextEdit, \ + QDialogButtonBox, QSizePolicy + + +def create_messagebox(parent: QWidget | None, title: str, message: str, + icon: QMessageBox.Icon = QMessageBox.Icon.Information, + button: QMessageBox.StandardButton = QMessageBox.StandardButton.Ok) -> QMessageBox: + msg = QMessageBox(parent) + msg.setWindowTitle(title) + msg.setText(message) + msg.setIcon(icon) + msg.setStandardButtons(button) + return msg + + +def error(parent: QWidget | None, title: str, message: str, + button: QMessageBox.StandardButton = QMessageBox.StandardButton.Ok) -> None: + create_messagebox( + parent, + title=title, + message=message, + icon=QMessageBox.Icon.Critical, + button=button + ).exec() + + +def info(parent: QWidget | None, title: str, message: str, + button: QMessageBox.StandardButton = QMessageBox.StandardButton.Ok) -> None: + create_messagebox( + parent, + title=title, + message=message, + icon=QMessageBox.Icon.Information, + button=button + ).exec() + + +def warning(parent: QWidget | None, title: str, message: str, + button: QMessageBox.StandardButton = QMessageBox.StandardButton.Ok) -> None: + create_messagebox( + parent, + title=title, + message=message, + icon=QMessageBox.Icon.Warning, + button=button + ).exec() + + +def error_with_plain_text( + parent: QWidget | None, + title: str, + message: str, + content: str, +) -> None: + dialog = QDialog(parent) + dialog.setWindowTitle(title) + dialog.resize(720, 420) + + layout = QVBoxLayout(dialog) + + label = QLabel(message) + label.setWordWrap(True) + + details_box = QPlainTextEdit() + details_box.setReadOnly(True) + details_box.setPlainText(content) + details_box.setSizePolicy( + QSizePolicy.Policy.Expanding, + QSizePolicy.Policy.Expanding, + ) + + buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok) + buttons.accepted.connect(dialog.accept) + + layout.addWidget(label) + layout.addWidget(details_box, 1) + layout.addWidget(buttons) + dialog.exec() + + +def ask_yes_no_with_plain_text(parent: QWidget | None, title: str, message: str, content: str): + dialog = QDialog(parent) + dialog.setWindowTitle(title) + dialog.resize(720, 420) + + layout = QVBoxLayout(dialog) + + label = QLabel(message) + label.setWordWrap(True) + label.setWordWrap(True) + + details_box = QPlainTextEdit() + details_box.setReadOnly(True) + details_box.setPlainText(content) + details_box.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Yes | + QDialogButtonBox.StandardButton.No + ) + buttons.button(QDialogButtonBox.StandardButton.Yes).clicked.connect(dialog.accept) + buttons.button(QDialogButtonBox.StandardButton.No).clicked.connect(dialog.reject) + + layout.addWidget(label) + layout.addWidget(details_box, 1) + layout.addWidget(buttons) + + return dialog.exec() == QDialog.DialogCode.Accepted diff --git a/pl_lib/progress_dialog.py b/pl_lib/progress_dialog.py new file mode 100644 index 0000000..6b72f9d --- /dev/null +++ b/pl_lib/progress_dialog.py @@ -0,0 +1,171 @@ +from PySide6.QtCore import Qt, Slot, QTimer +from PySide6.QtWidgets import ( + QDialog, + QLabel, + QProgressBar, + QPushButton, + QScrollArea, + QSizePolicy, + QVBoxLayout, + QWidget, +) + + +class ProgressRow(QWidget): + def __init__(self, name: str, parent=None): + super().__init__(parent) + + self.setSizePolicy( + QSizePolicy.Policy.Expanding, + QSizePolicy.Policy.Fixed, + ) + + self.name_label = QLabel(name, self) + self.name_label.setWordWrap(True) + self.name_label.setTextInteractionFlags( + Qt.TextInteractionFlag.TextSelectableByMouse + ) + + self.status_label = QLabel("Preparing...", self) + self.status_label.setWordWrap(True) + self.status_label.setTextInteractionFlags( + Qt.TextInteractionFlag.TextSelectableByMouse + ) + + self.progress_bar = QProgressBar(self) + self.progress_bar.setRange(0, 100) + self.progress_bar.setValue(0) + + # Flags + self.finished = False + + layout = QVBoxLayout(self) + layout.setContentsMargins(8, 8, 8, 8) + layout.setSpacing(4) + layout.addWidget(self.name_label) + layout.addWidget(self.status_label) + layout.addWidget(self.progress_bar) + + +class ProgressDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + + self.setWindowTitle("Downloads") + self.resize(500, 300) + self.setMinimumSize(360, 220) + + self.rows: dict[str, ProgressRow] = {} + + self.scroll_area = QScrollArea(self) + self.download_widget = QWidget(self.scroll_area) + self.download_layout = QVBoxLayout(self.download_widget) + self.download_layout.setContentsMargins(4, 4, 4, 4) + self.download_layout.setSpacing(8) + self.download_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + + self.scroll_area.setWidget(self.download_widget) + self.scroll_area.setWidgetResizable(True) + self.scroll_area.setHorizontalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOff + ) + + self.close_button = QPushButton("Close", self) + self.close_button.clicked.connect(self.hide) + + layout = QVBoxLayout(self) + layout.setContentsMargins(8, 8, 8, 8) + layout.setSpacing(8) + layout.addWidget(self.scroll_area, 1) + layout.addWidget( + self.close_button, + 0, + Qt.AlignmentFlag.AlignRight, + ) + + self.close_timer = QTimer(self) + self.close_timer.setSingleShot(True) + self.close_timer.timeout.connect(self.cleanup) + + @Slot(str, str) + def add_task(self, task_id: str, name: str): + # Prevent new tasks from being deleted by cleanup timer + if task_id in self.rows: + return + + row = ProgressRow(name, self.download_widget) + self.rows[task_id] = row + + self.download_layout.addWidget(row) + + if not self.isVisible(): + self.show() + + @Slot(str, str, float) + def update_task( + self, + task_id: str, + status: str, + percent: float, + ): + row = self.rows.get(task_id) + if row is None: + # Prevent started、progress missing due to timing issues + self.add_task(task_id, task_id) + row = self.rows[task_id] + + value = max(0, min(100, round(percent))) + row.status_label.setText(status) + row.progress_bar.setValue(value) + + @Slot(str) + def finish_task(self, task_id: str): + row = self.rows.get(task_id) + if row is None: + return + + row.status_label.setText("Completed") + row.progress_bar.setValue(100) + row.finished = True + + if self.all_tasks_finished(): + self.close_timer.start(600) + + def all_tasks_finished(self): + return bool(self.rows) and all( + row.finished + for row in self.rows.values() + ) + + def cleanup(self): + """ + If you want to use this function with a timer. use self.close_timer.start(TIMEOUT) + :return: + """ + for task_id in list(self.rows): + self.remove_task(task_id) + + self.hide() + + @Slot(str, str) + def fail_task(self, task_id: str, error: str): + row = self.rows.get(task_id) + if row is None: + self.add_task(task_id, task_id) + row = self.rows[task_id] + + row.status_label.setText(f"Failed: {error}") + row.progress_bar.setStyleSheet( + "QProgressBar::chunk { background-color: #c62828; }" + ) + # Move to top + self.download_layout.removeWidget(row) + self.download_layout.insertWidget(0, row) + + @Slot(str) + def remove_task(self, task_id: str): + row = self.rows.pop(task_id, None) + if row is not None: + self.download_layout.removeWidget(row) + row.setParent(None) + row.deleteLater() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fd7b4a1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,46 @@ +[build-system] +requires = ["setuptools>=77"] +build-backend = "setuptools.build_meta" + +[project] +name = "playlist-saver" +dynamic = ["version"] +description = "A desktop tool for saving YouTube playlists." +readme = "README.md" +requires-python = ">=3.10" +license = "MIT" +dependencies = [ + "click>=8.1", + "platformdirs>=4.0", + "PySide6>=6.8", + "requests>=2.31", + "yt-dlp>=2025.1.15", +] + +[project.optional-dependencies] +build = [ + "pyinstaller>=6.11", +] + +[project.scripts] +playlist-saver = "main:main" + +[tool.setuptools] +py-modules = [ + "app", + "constant", + "dialog", + "main", + "utils", + "widgets", + "window", + "yt_lib", +] + +[tool.setuptools.packages.find] +include = ["pl_lib*"] +namespaces = true + +[tool.setuptools.dynamic] +version = {attr = "constant.VERSION"} + diff --git a/utils.py b/utils.py index 1f63a8b..bce35ec 100644 --- a/utils.py +++ b/utils.py @@ -1,15 +1,20 @@ import base64 import binascii +import hashlib +import hmac import json import platform import shutil import urllib -import winreg +try: + import winreg +except ImportError: # Windows-only standard library module + winreg = None from pathlib import Path -from urllib.parse import unquote import sys import os import logging +import requests logger = logging.getLogger("PlaylistSaver.Utils") logger.setLevel(logging.INFO) @@ -20,6 +25,9 @@ def set_info(target_logger): logger = target_logger def key_exists(hive, sub_key): + if winreg is None: + return False + try: # Attempt to open the key for reading with winreg.OpenKey(hive, sub_key, 0, winreg.KEY_READ) as key: @@ -42,9 +50,9 @@ def create_url_scheme(main_file_path, work_dir): command_key = winreg.CreateKey(open_key, "command") if getattr(sys, 'frozen', False): - command = f"\"{sys.executable}\" --new-work-dir \"{work_dir}\" \"%1\"" + command = f"\"{sys.executable}\" --work-dir \"{work_dir}\" \"%1\"" else: - command = f"\"{sys.executable}\" \"{main_file_path}\" --new-work-dir \"{work_dir}\" \"%1\"" + command = f"\"{sys.executable}\" \"{main_file_path}\" --work-dir \"{work_dir}\" \"%1\"" winreg.SetValueEx(command_key, None, 0, winreg.REG_SZ, command) @@ -53,12 +61,15 @@ def create_url_scheme(main_file_path, work_dir): except OSError as e: logger.error("Unable to create URL scheme: %s", e) -def check_url_scheme(main_file_path, work_dir): +def check_url_scheme(executable_path, work_dir): + if winreg is None: + return + try: - if not key_exists(winreg.HKEY_LOCAL_MACHINE, "PlaylistSaver"): - create_url_scheme(main_file_path, work_dir) + if not key_exists(winreg.HKEY_CURRENT_USER, r"Software\Classes\PlaylistSaver"): + create_url_scheme(executable_path, work_dir) except FileNotFoundError: - create_url_scheme(main_file_path, work_dir) + create_url_scheme(executable_path, work_dir) def read_cookies(cookie_path): try: @@ -84,19 +95,54 @@ def save_base64_netscape_cookie(cookies_encoded, cookie_path): logger.error("Unable to decode base64 cookie: %s", cookies_encoded) return - logger.debug("==== COOKIE FILE ====") - for line in cookies.split("\n"): - print(line, "=>", len(line.split("\t"))) - logger.debug("=====================") - save_cookies(cookies, cookie_path) -def parser_url_scheme(url, cookie_path: Path): +def _decode_urlsafe(value: str) -> bytes: + return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + + +def decrypt_cookie_payload(payload: str, secret_key: str) -> str: + raw = _decode_urlsafe(payload) + if len(raw) < 49 or raw[0] != 1: + raise ValueError("Unsupported encrypted cookie payload.") + + master_key = _decode_urlsafe(secret_key) + if len(master_key) != 32: + raise ValueError("Invalid PlaylistSaver secret key.") + + signed_data, supplied_tag = raw[:-32], raw[-32:] + nonce, ciphertext = raw[1:17], raw[17:-32] + encryption_key = hmac.new(master_key, b"PlaylistSaver cookie encryption", hashlib.sha256).digest() + authentication_key = hmac.new(master_key, b"PlaylistSaver cookie authentication", hashlib.sha256).digest() + expected_tag = hmac.new(authentication_key, signed_data, hashlib.sha256).digest() + if not hmac.compare_digest(supplied_tag, expected_tag): + raise ValueError("Cookie payload authentication failed. Check the Helper secret key.") + + plaintext = bytearray(len(ciphertext)) + for offset in range(0, len(ciphertext), 32): + counter = (offset // 32).to_bytes(4, "big") + stream = hmac.new(encryption_key, nonce + counter, hashlib.sha256).digest() + block = ciphertext[offset:offset + 32] + plaintext[offset:offset + len(block)] = bytes(a ^ b for a, b in zip(block, stream)) + + return plaintext.decode("utf-8") + + +def parser_url_scheme(url, cookie_path: Path, secret_key: str | None = None): logger.debug("Parsing URL scheme: %s", url) - url = urllib.parse.urlparse(unquote(url)) + # parse_qs performs percent-decoding itself. Decoding the whole URL first + # would turn an encoded Base64 "+" into a query-string space. + url = urllib.parse.urlparse(url) parameters = urllib.parse.parse_qs(url.query) + if "encryptedcookies" in parameters: + if not secret_key: + raise ValueError("PlaylistSaver secret key is unavailable.") + cookies = decrypt_cookie_payload(parameters["encryptedcookies"][0], secret_key) + save_cookies(cookies, cookie_path) + return + func_map = { "base64cookies": { "func": save_base64_netscape_cookie, @@ -133,27 +179,33 @@ def find_mpv_path(program_dir: Path): platform_name = platform.system().lower() machine = platform.machine().lower() - if platform.system().lower() == "windows" and ("amd64" in machine or "x86_64" in machine): + if platform_name == "windows" and ("amd64" in machine or "x86_64" in machine): local_candidates.append(Path(program_dir, "bin", "mpv", "nt", "x64", "mpv.exe")) - elif platform.system().lower() == "windows" and ("arm64" in machine or "aarch64" in machine): + elif platform_name == "windows" and ("arm64" in machine or "aarch64" in machine): local_candidates.append(Path(program_dir, "bin", "mpv", "nt", "arm", "mpv.exe")) - elif platform.system().lower() == "windows" and machine in {"x86", "i386", "i686"}: + elif platform_name == "windows" and machine in {"x86", "i386", "i686"}: local_candidates.append(Path(program_dir, "bin", "mpv", "nt", "x86", "mpv.exe")) - if platform.system().lower() == "darwin" and ("amd64" in machine or "x86_64" in machine): + if platform_name == "darwin" and ("amd64" in machine or "x86_64" in machine): local_candidates.append(Path(program_dir, "bin", "mpv", "darwin", "x64", "mpv")) - elif platform.system() == "darwin" and ("arm64" in machine or "aarch64" in machine): + elif platform_name == "darwin" and ("arm64" in machine or "aarch64" in machine): local_candidates.append(Path(program_dir, "bin", "mpv", "darwin", "arm", "mpv")) - if platform.system().lower() == "linux" and ("amd64" in machine or "x86_64" in machine): + if platform_name == "linux" and ("amd64" in machine or "x86_64" in machine): local_candidates.append(Path(program_dir, "bin", "mpv", "linux", "x64", "mpv")) - elif platform.system().lower() == "linux" and ("arm64" in machine or "aarch64" in machine): + elif platform_name == "linux" and ("arm64" in machine or "aarch64" in machine): local_candidates.append(Path(program_dir, "bin", "mpv", "linux", "arm", "mpv")) - elif platform.system().lower() == "linux" and machine in {"x86", "i386", "i686"}: - local_candidates.append(Path(program_dir, "bin", "mpv", "linux", "x86", "mpv.exe")) + elif platform_name == "linux" and machine in {"x86", "i386", "i686"}: + local_candidates.append(Path(program_dir, "bin", "mpv", "linux", "x86", "mpv")) for candidate in local_candidates: if candidate.exists() and candidate.is_file(): + if os.name != "nt" and not os.access(candidate, os.X_OK): + try: + candidate.chmod(candidate.stat().st_mode | 0o111) + except OSError as exc: + logger.warning("Unable to make bundled mpv executable: %s", exc) + continue return candidate # failback to PATH mpv (if available) @@ -201,4 +253,18 @@ def cookie_string_to_netscape(cookie_str): value.strip() ])) - return "\n".join(lines) \ No newline at end of file + return "\n".join(lines) + + +def download_file(url, dest: Path): + try: + dest.parent.mkdir(parents=True, exist_ok=True) + with requests.get(url, stream=True, timeout=30) as r: + r.raise_for_status() + with dest.open(mode="wb") as f: + for chunk in r.iter_content(chunk_size=8192): + f.write(chunk) + return True + except Exception as e: + logger.error("Unable to download file: %s", e) + return False diff --git a/widgets.py b/widgets.py new file mode 100644 index 0000000..e035316 --- /dev/null +++ b/widgets.py @@ -0,0 +1,252 @@ +import datetime +from pathlib import Path +from typing import Callable + +from PySide6.QtWidgets import QLabel, QFrame, QHBoxLayout, QSizePolicy, QPushButton, QVBoxLayout +from PySide6.QtCore import Qt, Signal, QSize +from PySide6.QtGui import QPixmap + +from yt_lib import resolve_video_page_url + + +class PlaylistItem(QFrame): + clicked = Signal(dict) + + def __init__(self, + parent, + data, + title: str = "", + thumbnail_path: str | Path | None = None, + thumbnail_size: QSize = None): + super().__init__(parent) + self.setObjectName("playlistItem") + self.original_thumbnail = None + + self.layout = QHBoxLayout(self) + self.layout.setContentsMargins(12, 10, 12, 10) + self.layout.setSpacing(12) + + self.setMinimumHeight(92) + self.setMaximumHeight(120) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + + # item + self.title_label = QLabel(title, self) + self.title_label.setObjectName("playlistTitle") + self.title_label.setWordWrap(True) + self.title_label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft) + self.title_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + + self.thumbnail_label = QLabel(self) + self.thumbnail_label.setObjectName("thumbnailLabel") + self.thumbnail_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.thumbnail_label.setFixedSize(thumbnail_size) + self.thumbnail_label.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + + # bind + self.layout.addWidget(self.title_label, 1) + self.layout.addWidget(self.thumbnail_label) + + if thumbnail_path: + self.set_thumbnail(thumbnail_path) + + self.setStyleSheet(""" + QFrame#playlistItem { + background-color: #2b2b2b; + border: 1px solid #3c3c3c; + border-radius: 8px; + } + + QFrame#playlistItem:hover { + background-color: #4f0202; + border-color: #555555; + } + + QLabel { + background-color: transparent; + border: none; + color: #f0f0f0; + } + + #playlistTitle { + font-weight: 600; + } + + #thumbnailLabel { + background-color: #1f1f1f; + border: 1px solid #3a3a3a; + border-radius: 6px; + } + """) + + # Playlist data + self.data = data + + def set_thumbnail(self, thumbnail_path: str | Path): + pixmap = QPixmap(str(thumbnail_path)) + if pixmap.isNull(): + self.thumbnail_label.hide() + return + + self.original_thumbnail = pixmap + self.thumbnail_label.show() + self.update_thumbnail_size() + + def update_thumbnail_size(self): + if not self.original_thumbnail: + return + + scaled = self.original_thumbnail.scaled( + self.thumbnail_label.size(), + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + self.thumbnail_label.setPixmap(scaled) + + def resizeEvent(self, event): + super().resizeEvent(event) + self.update_thumbnail_size() + + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + self.clicked.emit(self.data) + super().mousePressEvent(event) + + +class VideoItem(QFrame): + clicked = Signal(dict) + + def __init__(self, parent, video: dict, + index: int = 0, thumbnail_path: str | Path | None = None, + thumbnail_size: QSize = None, + download_video_callback: Callable = None): + super().__init__(parent) + self.setObjectName("videoItem") + self.original_thumbnail = None + self.video = video + self.id = video.get("id") + self.url = resolve_video_page_url(video) + + title = video.get("title", "Unnamed Video") + if index: + title = f"{index}. {title}" + + if video.get("duration"): + duration = str(datetime.timedelta(seconds=video.get("duration"))) + else: + duration = "Fetching duration..." + + self.layout = QHBoxLayout(self) + self.layout.setContentsMargins(12, 10, 12, 10) + self.layout.setSpacing(12) + + self.setMinimumHeight(92) + self.setMaximumHeight(120) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + + # item + self.title_label = QLabel(title, self) + self.title_label.setObjectName("videoTitle") + self.title_label.setWordWrap(True) + self.title_label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft) + self.title_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + + self.duration_label = QLabel(duration, self) + self.duration_label.setObjectName("durationLabel") + self.duration_label.setAlignment(Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignLeft) + + self.download_button = QPushButton("Download Video", self) + self.download_button.setObjectName("downloadButton") + + self.thumbnail_label = QLabel(self) + self.thumbnail_label.setObjectName("thumbnailLabel") + self.thumbnail_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.thumbnail_label.setFixedSize(thumbnail_size) + self.thumbnail_label.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + self.thumbnail_label.hide() + + # bind + self.text_layout = QVBoxLayout() + self.text_layout.setContentsMargins(0, 0, 0, 0) + self.text_layout.setSpacing(6) + self.text_layout.addWidget(self.title_label, 1) + self.text_layout.addWidget(self.duration_label) + self.text_layout.addWidget(self.download_button, 1, + Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignLeft) + + self.layout.addLayout(self.text_layout, 1) + self.layout.addWidget(self.thumbnail_label) + + if callable(download_video_callback): + self.download_button.clicked.connect(download_video_callback) + + if thumbnail_path: + self.set_thumbnail(thumbnail_path) + + self.setStyleSheet(""" + QFrame#videoItem { + background-color: #2b2b2b; + border: 1px solid #3c3c3c; + border-radius: 8px; + } + + QFrame#videoItem:hover { + background-color: #333333; + border-color: #555555; + } + + QLabel { + background-color: transparent; + border: none; + color: #f0f0f0; + } + + #videoTitle { + font-weight: 600; + } + + #thumbnailLabel { + background-color: #1f1f1f; + border: 1px solid #3a3a3a; + border-radius: 6px; + } + + #downloadButton { + max-height: 30px; + } + """) + + self.setCursor(Qt.CursorShape.PointingHandCursor) + + def set_thumbnail(self, thumbnail_path: str | Path): + pixmap = QPixmap(str(thumbnail_path)) + if pixmap.isNull(): + self.thumbnail_label.hide() + return + + self.original_thumbnail = pixmap + self.thumbnail_label.show() + self.update_thumbnail_size() + + def update_thumbnail_size(self): + if not self.original_thumbnail: + return + + scaled = self.original_thumbnail.scaled( + self.thumbnail_label.size(), + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + self.thumbnail_label.setPixmap(scaled) + + def resizeEvent(self, event): + super().resizeEvent(event) + self.update_thumbnail_size() + + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + self.clicked.emit(self.video) + super().mousePressEvent(event) + + def set_duration(self, duration: str): + self.duration_label.setText(duration) diff --git a/window.py b/window.py new file mode 100644 index 0000000..5141c77 --- /dev/null +++ b/window.py @@ -0,0 +1,332 @@ +import logging +import subprocess +from pathlib import Path + +from PySide6.QtWidgets import ( + QMainWindow, QWidget, + QLabel, QPushButton, QFrame, QScrollArea, + QVBoxLayout, QHBoxLayout, + QMessageBox, QLineEdit, QDialog, QApplication, ) +from PySide6.QtCore import Qt, Signal, QObject + +from constant import VERSION +from dialog import DownloadProgressDialog, PlayerDialog, SwitchProfileDialog, PlaylistWindow +from utils import download_file +from widgets import PlaylistItem +from yt_lib import PlaylistFetchException, resolve_video_page_url + +logger = logging.getLogger("MainWindow") + + +class PlaylistMainWindow(QMainWindow): + def __init__(self, app): + super(PlaylistMainWindow, self).__init__() + self.app = app + self.child_windows = [] + self.setWindowTitle("PlaylistSaver") + self.resize(700, 800) + + # Central widget + self.central_widget = QWidget(self) + self.setCentralWidget(self.central_widget) + + # Layouts + self.main_layout = QHBoxLayout(self.central_widget) + self.main_layout.setContentsMargins(12, 12, 12, 12) + self.main_layout.setSpacing(12) + + self.right_layout = QVBoxLayout() + self.right_layout.setContentsMargins(12, 12, 12, 12) + self.right_layout.setSpacing(10) + + self.playlists_layout = QVBoxLayout() + self.playlists_layout.setContentsMargins(10, 10, 10, 10) + self.playlists_layout.setSpacing(8) + + # Frame + self.playlists_scroll = QScrollArea(self) + self.playlists_scroll.setWidgetResizable(True) + self.playlists_scroll.setFrameShape(QFrame.Shape.NoFrame) + + self.playlists_frame = QFrame(self.playlists_scroll) + self.playlists_frame.setLayout(self.playlists_layout) + self.playlists_scroll.setWidget(self.playlists_frame) + + self.playlists_scroll.setMinimumWidth(400) + + self.right_panel = QFrame(self.central_widget) + self.right_panel.setObjectName("rightPanel") + self.right_panel.setFixedWidth(190) + self.right_panel.setLayout(self.right_layout) + + # Items + self.profile_label = QLabel(f"Current Profile: {self.app.get_active_profile_name()}" ,) + self.profile_label.setObjectName("profileLabel") + self.profile_label.setWordWrap(True) + + self.loading_label = QLabel("Loading...") + self.loading_label.setObjectName("loadingLabel") + self.loading_label.setAlignment(Qt.AlignmentFlag.AlignCenter | Qt.AlignmentFlag.AlignBottom) + + self.switch_profile_btn = QPushButton("Switch Profile") + self.helper_key_btn = QPushButton("Helper Secret Key") + self.refresh_playlists_btn = QPushButton("Refresh playlists") + self.save_all_playlists_btn = QPushButton("Save All Playlists") + self.open_download_folder_btn = QPushButton("Open Download Folder") + self.switch_profile_btn.setMinimumHeight(34) + self.helper_key_btn.setMinimumHeight(34) + self.refresh_playlists_btn.setMinimumHeight(34) + self.save_all_playlists_btn.setMinimumHeight(34) + + self.version_label = QLabel(f"v{VERSION}") + self.version_label.setObjectName("versionLabel") + + self.playlist_items_layout = QVBoxLayout() + self.playlist_items_layout.setContentsMargins(0, 0, 0, 0) + self.playlist_items_layout.setSpacing(8) + + self.playlists_layout.addLayout(self.playlist_items_layout) + self.playlists_layout.addWidget(self.loading_label, 0, Qt.AlignmentFlag.AlignHCenter) + + # Bind item + self.left_layout = QVBoxLayout() + self.left_layout.setContentsMargins(0, 0, 0, 0) + self.left_layout.setSpacing(10) + self.left_layout.addWidget(self.playlists_scroll, 1) + + self.right_layout.addWidget(self.profile_label) + self.right_layout.addWidget(self.switch_profile_btn) + self.right_layout.addWidget(self.helper_key_btn) + self.right_layout.addWidget(self.refresh_playlists_btn) + self.right_layout.addWidget(self.save_all_playlists_btn) + self.right_layout.addWidget(self.open_download_folder_btn) + self.right_layout.addStretch(1) + self.right_layout.addWidget(self.version_label, 0, Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignHCenter) + + self.main_layout.addLayout(self.left_layout, 1) + self.main_layout.addWidget(self.right_panel) + + self.setStyleSheet(""" + QMainWindow { + background-color: #202020; + } + + QScrollArea, QFrame { + background-color: #242424; + } + + QLabel { + background-color: transparent; + } + + #rightPanel { + background-color: #2b2b2b; + border: 1px solid #3c3c3c; + border-radius: 8px; + } + + #profileLabel { + color: #f0f0f0; + font-weight: 600; + padding-bottom: 6px; + } + + #loadingLabel { + color: #a8a8a8; + padding: 4px; + } + + QPushButton { + background-color: #3a3a3a; + color: #f0f0f0; + border: 1px solid #505050; + border-radius: 6px; + padding: 7px 10px; + } + + QPushButton:hover { + background-color: #464646; + } + + QPushButton:pressed { + background-color: #303030; + } + """) + + # Bind signals function + self.signals = self.PlaylistWorkerSignals() + self.signals.playlists_ready.connect(self.build_ui) + self.signals.error.connect(self.show_error) + self.signals.playlist_fetch_error.connect(self.show_playlist_fetch_error) + self.signals.download_status.connect(self.loading_label.setText) + + self.switch_profile_btn.clicked.connect(self.open_switch_profile_dialog) + self.helper_key_btn.clicked.connect(self.show_helper_secret_key) + self.refresh_playlists_btn.clicked.connect(self.app.reload_playlists) + self.save_all_playlists_btn.clicked.connect(self.save_all_playlists) + self.open_download_folder_btn.clicked.connect(self.app.open_download_folder) + + class PlaylistWorkerSignals(QObject): + # Signals + playlists_ready = Signal(dict) + profile_delete = Signal(str) + + # playlist + playlist_clicked = Signal(dict) + video_clicked = Signal(str) + + # error + error = Signal(str) + playlist_fetch_error = Signal(object) + download_status = Signal(str) + + def build_ui(self, playlists: dict[str, list]): + self.clear_layout(self.playlist_items_layout) + + playlists = playlists.get("entries", []) + + playlists = [playlist for playlist in playlists if playlist is not None] # ignore None item + + for playlist in playlists: + plist_id = playlist.get("id", None) + title = playlist.get("title", "Title not found") + + item = PlaylistItem( + self.playlists_frame, + playlist, + title=title, + thumbnail_size=self.app.THUMBNAIL_SIZE + ) + + if len(playlist.get("thumbnails", [])) > 0: + thumbnail_url = playlist.get("thumbnails", [])[0].get('url', None) + thumbnail_resolution = playlist.get("thumbnails", [])[0].get('resolution', None) + + thumbnail_path = Path(self.app.temp_path, "thumbnails", + f"thumbnail_{plist_id}_{thumbnail_resolution}.jpg") + + result = download_file(thumbnail_url, thumbnail_path) + + if result or (thumbnail_path.exists() and thumbnail_path.is_file()): + item.set_thumbnail(thumbnail_path) + + item.clicked.connect(self.open_playlist_window) + self.playlist_items_layout.addWidget(item) + + self.loading_label.setText("Done") + + def save_all_playlists(self): + playlists = [item for item in self.app.playlists.get("entries", []) if item] + if not playlists: + QMessageBox.information(self, "Download", "No playlists loaded.") + return + self.progress_dialog = DownloadProgressDialog( + "Save All Playlists", 0, self, producers=len(playlists) + ) + self.app.download_all_playlists( + self.progress_dialog.finish_future, + self.progress_dialog.tasks_added.emit, + self.progress_dialog.producer_finished.emit, + ) + + def on_download_done(self, result, video=None, playlist_name=None): + if isinstance(result, Exception): + self.signals.error.emit(f"Download failed: {result}") + return + try: + path = result.result() + except Exception as error: + self.signals.error.emit(f"Download failed: {error}") + return + self.signals.download_status.emit(f"Downloaded: {path.name}") + + def clear_layout(self, layout): + while layout.count(): + item = layout.takeAt(0) + child_layout = item.layout() + widget = item.widget() + if child_layout: + self.clear_layout(child_layout) + if widget: + widget.deleteLater() + + def open_playlist_window(self, playlist: dict): + window = PlaylistWindow(self.app, playlist, parent=self, + open_player_callback=self.open_video_player) + self.child_windows.append(window) + + window.destroyed.connect( + lambda _obj=None, w=window: self.child_windows.remove(w) if w in self.child_windows else None + ) + + window.show() + + def show_error(self, message: str): + QMessageBox.critical(self, "Error", message) + + def show_playlist_fetch_error(self, error: PlaylistFetchException): + error.create_messagebox(self) + + def open_video_player(self, video: dict): + video_url = resolve_video_page_url(video) + if not video_url: + QMessageBox.critical(self, "Error", "Unable to resolve video URL.") + return + + dialog = PlayerDialog(self.app, video, parent=self) + self.child_windows.append(dialog) + dialog.destroyed.connect( + lambda _obj=None, w=dialog: self.child_windows.remove(w) if w in self.child_windows else None) + dialog.show() + + def open_switch_profile_dialog(self): + dialog = SwitchProfileDialog(self.app, parent=self) + dialog.profile_changed.connect(self.apply_profile_switch) + dialog.exec() + + def show_helper_secret_key(self): + dialog = QDialog(self) + dialog.setWindowTitle("Helper Secret Key") + layout = QVBoxLayout(dialog) + layout.addWidget(QLabel("Copy this key into the PlaylistSaver Helper settings page:")) + + key_field = QLineEdit(self.app.secret_key or "") + key_field.setReadOnly(True) + key_field.setEchoMode(QLineEdit.EchoMode.Password) + layout.addWidget(key_field) + + buttons = QHBoxLayout() + reveal_btn = QPushButton("Show") + copy_btn = QPushButton("Copy") + close_btn = QPushButton("Close") + reveal_btn.clicked.connect(lambda: key_field.setEchoMode(QLineEdit.EchoMode.Normal)) + copy_btn.clicked.connect(lambda: QApplication.clipboard().setText(key_field.text())) + close_btn.clicked.connect(dialog.accept) + buttons.addWidget(reveal_btn) + buttons.addWidget(copy_btn) + buttons.addWidget(close_btn) + layout.addLayout(buttons) + dialog.resize(540, 140) + dialog.exec() + + def apply_profile_switch(self, profile_name: str): + self.app.set_active_profile(profile_name) + self.profile_label.setText(f"Current Profile: {profile_name}") + + def closeEvent(self, event): + if not self.app.has_active_download_tasks(): + event.accept() + return + + answer = QMessageBox.question( + self, + "Downloads in progress", + "Downloads are still running. Close the application and cancel them?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if answer == QMessageBox.StandardButton.Yes: + self.app.cancel_download_tasks() + event.accept() + else: + event.ignore() diff --git a/yt_lib.py b/yt_lib.py new file mode 100644 index 0000000..cfa7c31 --- /dev/null +++ b/yt_lib.py @@ -0,0 +1,169 @@ +import copy +import logging +import os +import threading +import traceback +import webbrowser +from pathlib import Path +from typing import Callable + +from PySide6.QtWidgets import QMessageBox +from yt_dlp import YoutubeDL + +from constant import YT_PLAYLISTS_URL +from utils import check_url_scheme, parser_url_scheme, find_http_status + +logger = logging.getLogger("PLib") + +def sanitize_filename(value: str): + invalid_chars = '<>:"/\\|?*' + return "".join("_" if char in invalid_chars else char for char in value) + + +def fetch_video_details(video_url: str, opts): + opts = copy.deepcopy(opts) + opts.update({"extract_flat": True, }) + + try: + with YoutubeDL(opts) as ydl: + info = ydl.extract_info(video_url, download=False) + except Exception as e: + logger.error(f"Unable to fetch video details: {e}\nURL:{video_url}") + return None + + return info + + +def resolve_video_page_url(video: dict | None=None, video_id: str=None) -> str: + if not video and not video_id: + raise Exception("No video id provided") + + if isinstance(video_id, str): + return f"https://www.youtube.com/watch?v={video_id}" + + return ( + video.get("webpage_url") + or video.get("url") + or (f"https://www.youtube.com/watch?v={video.get('id')}" if video.get("id") else None) + ) + +def fetch_playlists_data(url_schema, + cookie_file_path: Path, opts, callback: Callable, + work_dir: Path, fetch_lock: threading.Lock, + main_filepath: Path, secret_key: str | None = None): + check_url_scheme(main_filepath.absolute().as_posix(), work_dir) + + error = None + + if url_schema is not None: + try: + parser_url_scheme(url_schema, cookie_path=cookie_file_path, secret_key=secret_key) + except Exception as e: + logger.error("Unable to parse URL scheme: %s", e) + + playlists_data = None + + with fetch_lock: + opts = copy.deepcopy(opts) + opts.update({ + 'extract_flat': True, + 'skip_download': True, + }) + + try: + with YoutubeDL(opts) as ydl: + playlists_data = ydl.extract_info(YT_PLAYLISTS_URL, download=False) + except Exception as e: + status_code = find_http_status(e) + if status_code == 401: + error = "Cookie are expired. Reopen the tool again in browser!", None + elif status_code == 403: + error = "Server forbidden. Did you logged in?", None + elif status_code == 429: + error = "Too many requests. Try again later.", None + elif status_code == 503: + error = "Server unavailable. Try again later.", None + elif status_code == 522: + error = "Connection timed out.", None + else: + error = (f"Unexpected error: {e} (Type: {type(e)})\n" + f"Traceback: {traceback.format_exc()}", None) + + if playlists_data is None: + logger.error(f"Unable to fetch playlists: {error[0]}") + playlists_data = {"entries": []} + + pf_exec = None + if error is not None: + reason = error[0] if isinstance(error, tuple) else error + pf_exec = PlaylistFetchException( + url=YT_PLAYLISTS_URL, + title="Playlists Fetch Error", + reason=reason, + enable_option=True, + options=[ + {"label": "Reopen browser", + "action": lambda: webbrowser.open(YT_PLAYLISTS_URL)}, + ], + ) + + callback(playlists_data, pf_exec) + + +class PlaylistFetchException(Exception): + def __init__(self, url, title, reason, enable_option=False, options=None, no_cancel=False): + super().__init__() + self.url = url + self.title = title + self.reason = reason + self.qt_options = { + "enable": enable_option, + "options": options, + "noCancelOption": no_cancel, + } + + def create_messagebox(self, master): + box = QMessageBox(master) + box.setWindowTitle(self.title) + box.setText(self.reason or self.title) + box.setIcon(QMessageBox.Icon.Critical) + + is_custom_option_enabled = self.qt_options["enable"] is True and type(self.qt_options["options"]) is list + + if is_custom_option_enabled: + for option in self.qt_options["options"]: + label = option.get("label", None) + action_func = option.get("action", None) + role = option.get("role", QMessageBox.ButtonRole.AcceptRole) + + # Some type check + if label is None or (action_func is None or not callable(action_func)): + logger.warning(f"Skipping {option} ({action_func}) because its label or action is not set yet or not callable.") + continue + + if not isinstance(role, QMessageBox.ButtonRole): + logger.warning(f"Skipping {option} ({role}) because its role type is not QMessageBox.ButtonRole.") + continue + + btn = box.addButton(label, role) + option["button"] = btn + + if not self.qt_options["noCancelOption"]: + box.addButton("Cancel", QMessageBox.ButtonRole.RejectRole) + elif not is_custom_option_enabled: + box.addButton(QMessageBox.StandardButton.Ok) + + box.exec() + + clicked = box.clickedButton() + + # If the option's button is clicked, Call the target action function + if is_custom_option_enabled: + for option in self.qt_options["options"]: + btn = option.get("button") + action_func = option.get("action") + + if clicked == btn: + return action_func() + + return None