57 Commits
Author SHA1 Message Date
MyLuxy f6fe484e54 fix: navbar logo is not a focus stop 2026-10-25 23:45:26 +01:00
MyLuxy 2e77f2068b fix: settings keeps ask first 2026-10-25 22:43:17 +01:00
MyLuxy 9f28719caa fix: pkg read-ahead without exceptions 2026-10-25 22:13:58 +01:00
MyLuxy b70fa3c513 feat: faster pkg streaming 2026-10-25 22:09:28 +01:00
MyLuxy 7cb30a7dce fix: settings pages centred without the crumb row, About removed, tiles keep their scroll 2026-10-25 21:32:54 +01:00
MyLuxy 24dc4f0232 feat: settings page 2026-10-25 21:01:45 +01:00
MyLuxy 5a4e6c9afe fix: the app waits for the helper's answer past its log lines, and the package server reuses its connections 2026-10-25 18:55:05 +01:00
MyLuxy 6ae574ddb0 fix: Delete removes a download again, a streamed PKG says to follow it on the PS5, FileKeeper gets a new link when it stalls 2026-10-25 18:30:50 +01:00
MyLuxy b9f0d621bb feat: the download choice speaks every language, with the PS5 badge and Overdrive's mark 2026-10-25 18:19:52 +01:00
MyLuxy 89e3f1147f fix: the helper payload's build no longer hides the filesystem helper's folder 2026-10-25 17:57:38 +01:00
MyLuxy 1fe410fc70 feat: PKGs download in the background with Overdrive closed, or in Overdrive, as the player picks 2026-10-25 17:50:04 +01:00
MyLuxy cb8864dcdc fix: FileKeeper's gateways get sixteen connections, under the point where they answer 503 2026-10-25 17:50:04 +01:00
MyLuxy a91c046069 fix: the QR generator's MIT notice ships inside the app 2026-10-25 16:43:45 +01:00
MyLuxy 710c8ba4ea fix: a TorBox phone login finds its key whatever the field is called 2026-10-25 16:35:31 +01:00
MyLuxy d14bd64c35 feat: TorBox and Real-Debrid link from the phone with a QR code 2026-10-25 16:34:42 +01:00
MyLuxy 5f00d94e61 feat: streamed PKGs go through the console's LAN address and outlast a restart 2026-10-25 16:34:42 +01:00
MyLuxy e014b2e10b feat: PKGs on signed links reach the console through Overdrive 2026-10-25 16:02:48 +01:00
MyLuxy cf111d734f fix: up from the first download goes to the add strip 2026-10-25 16:02:48 +01:00
MyLuxy d2d756dae1 fix: a download idle between short bursts restarts itself 2026-10-25 16:02:42 +01:00
MyLuxy 5d7dc0cefc fix: a USB download keeps its checkpoint across a restart 2026-10-25 16:02:31 +01:00
MyLuxy 0784ddeab5 fix: ask the host what it answers a HEAD 2026-10-25 02:26:32 +02:00
MyLuxy 89b118c44f fix: the downloads list moves up and down by row 2026-10-25 02:12:06 +02:00
MyLuxy 1d12d9ca3a fix: log what the host serves and how long a refusal takes 2026-10-25 02:02:41 +02:00
MyLuxy 4f8c4f6736 fix: offer a refused PKG link again in the shapes it could need 2026-10-25 01:48:52 +02:00
MyLuxy cc38ff2064 fix: name the package's size where a link is handed to the console 2026-10-25 01:24:44 +02:00
MyLuxy 7ecd260b1c feat: app.log names the build and why an install was refused 2026-10-25 01:06:09 +02:00
MyLuxy c8a48ff951 fix: a refused link logs its reason, not the URL that hid it 2026-10-25 00:31:25 +02:00
MyLuxy 96524ee1ed fix: keep drive numbers visible in the download disk setting 2026-10-24 23:37:46 +02:00
MyLuxy 51b04d1755 fix: the search keyboard has no symbols page 2026-10-24 23:29:28 +02:00
MyLuxy b915ab4219 fix: the symbols key names the characters it holds 2026-10-24 23:28:20 +02:00
MyLuxy 810c280aff feat: one dialog for the three debrid services 2026-10-24 23:00:11 +02:00
MyLuxy 28be7532f3 feat: Real-Debrid and Premiumize as debrid clients 2026-10-24 22:56:07 +02:00
MyLuxy 82ef2e9010 fix: debrid CDNs get eight connections and the full Retry-After 2026-10-24 22:53:28 +02:00
MyLuxy 0e96556d78 feat: one service per download card, with its own logo 2026-10-24 22:26:21 +02:00
MyLuxy 60d9d7cd5a fix: the add cards clear the focus ring 2026-10-24 22:03:40 +02:00
MyLuxy 092da5c963 fix: the symbols key shows what its page adds 2026-10-24 21:59:34 +02:00
MyLuxy 2e71702b5b feat: the download cards guide their own setup 2026-10-24 21:32:49 +02:00
MyLuxy 4ee3cc7f8a feat: the keyboard has a page of symbols 2026-10-24 21:02:15 +02:00
MyLuxy 7feaec2e27 feat: downloads from a pasted link, TorBox and qBittorrent 2026-10-24 20:53:05 +02:00
MyLuxy 5dd3154581 fix: a stalled download restarts itself 2026-10-24 20:52:55 +02:00
MyLuxy bc0b88b8e8 feat: the disk chosen for downloads is remembered 2026-10-24 20:12:42 +02:00
MyLuxy 005637d283 fix: downloads start over when the partial or the staging no longer matches 2026-10-24 20:03:29 +02:00
MyLuxy e95f973d7d fix: only DLC and backport packs without a game are hidden 2026-10-24 19:44:03 +02:00
MyLuxy 6eb050771f feat: third-party DLC packs are not offered 2026-10-24 19:41:35 +02:00
MyLuxy c9b93eb9a8 fix: the download progress bar spans the compact header item 2026-10-24 19:27:16 +02:00
MyLuxy 8e2d89276e fix: a heart for favorites in the header 2026-10-24 17:46:36 +02:00
MyLuxy 71ddfca7fb feat: the new header, a settings page and a favorites page over the old UI 2026-10-24 17:27:00 +02:00
MyLuxy 5a002ff088 revert: the redesigned navigation, grids and settings (#100) 2026-10-24 17:22:23 +02:00
MyLuxy 126e9a3e4e feat: v1.0.10 2026-10-24 16:54:52 +02:00
MyLuxy 08a4bb11c9 fix: the update test takes its version from the app 2026-10-24 16:54:52 +02:00
MyLuxy abfa31a21e fix: Recently added keeps its row on Discover 2026-10-24 16:35:42 +02:00
MyLuxy d0fdc3d3ae fix: the preview launcher starts where /proc is missing 2026-10-24 16:35:42 +02:00
Elija 1f3bf76c35 feat: redesigned navigation, grids and settings across the UI (#100) 2026-10-24 16:33:26 +02:00
MyLuxy 34365d2c1a fix: the README logo is drawn at its shown size, no jagged edges 2026-10-23 20:48:08 +02:00
MyLuxy c08550c6e4 feat: PKGs go by their link to the PS5's own downloads, archives stay in Overdrive (#50) 2026-10-23 20:36:26 +02:00
MyLuxy ddb158d7f6 fix: old firmwares trust today's sites, Mozilla's root certificates beside the console's (#28) 2026-10-23 19:59:24 +02:00
Paliddo86 13feffd4cb feat: macOS preview and local build workflow (#93) 2026-10-23 02:54:27 +02:00
120 changed files with 10350 additions and 421 deletions

No files matched your search

+1 -1
View File
@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2 176.16 36" width="2113.89" height="432" role="img" aria-label="Overdrive">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2 176.16 36" width="440" height="89.92" role="img" aria-label="Overdrive">
<path d="M26.34 12.24A11 11 0 1 1 19.76 5.66" fill="none" stroke="#f2f2f3" stroke-width="4.5"/>
<path d="M28.4 3.6 17.6 18.1a2.9 2.9 0 1 1-3.7-3.7Z" fill="#f2f2f3"/>
<path d="M55.06 26.5Q52.34 26.5 50.22 25.17Q48.09 23.84 46.87 21.41Q45.66 18.98 45.66 15.66Q45.66 12.34 46.87 9.9Q48.09 7.47 50.22 6.14Q52.34 4.8 55.06 4.8Q57.78 4.8 59.9 6.14Q62.03 7.47 63.24 9.9Q64.45 12.34 64.45 15.66Q64.45 18.98 63.24 21.41Q62.03 23.84 59.9 25.17Q57.78 26.5 55.06 26.5ZM55.06 24.02Q56.94 24.02 58.48 23.06Q60.02 22.09 60.93 20.23Q61.84 18.37 61.84 15.66Q61.84 12.95 60.93 11.08Q60.02 9.21 58.48 8.24Q56.94 7.28 55.06 7.28Q53.16 7.28 51.62 8.24Q50.07 9.21 49.17 11.08Q48.26 12.95 48.26 15.66Q48.26 18.37 49.17 20.23Q50.07 22.09 51.62 23.06Q53.16 24.02 55.06 24.02ZM72.18 26.2L66.17 10.51L68.94 10.51L72.28 19.77Q72.8 21.17 73.17 22.56Q73.54 23.95 73.95 25.25L73.07 25.25Q73.47 23.95 73.84 22.56Q74.22 21.17 74.73 19.77L78.05 10.51L80.82 10.51L74.81 26.2L72.18 26.2ZM89.62 26.54Q87.34 26.54 85.7 25.52Q84.06 24.5 83.17 22.69Q82.29 20.88 82.29 18.47Q82.29 16.06 83.16 14.22Q84.03 12.38 85.62 11.34Q87.22 10.3 89.34 10.3Q90.66 10.3 91.89 10.74Q93.12 11.18 94.1 12.12Q95.07 13.07 95.65 14.57Q96.22 16.08 96.22 18.2L96.22 19.19L83.93 19.19L83.93 17.05L94.86 17.05L93.69 17.83Q93.69 16.29 93.19 15.09Q92.7 13.89 91.73 13.22Q90.76 12.54 89.34 12.54Q87.94 12.54 86.92 13.22Q85.9 13.91 85.36 15.03Q84.82 16.16 84.82 17.49L84.82 18.85Q84.82 20.58 85.42 21.8Q86.01 23.01 87.1 23.65Q88.19 24.29 89.64 24.29Q90.59 24.29 91.36 24.01Q92.13 23.72 92.68 23.17Q93.23 22.62 93.52 21.82L95.97 22.39Q95.61 23.61 94.73 24.55Q93.86 25.49 92.55 26.02Q91.25 26.54 89.62 26.54ZM99.09 26.2L99.09 10.51L101.53 10.51L101.53 12.93L101.67 12.93Q102.11 11.74 103.19 11.01Q104.28 10.28 105.69 10.28Q105.99 10.28 106.36 10.31Q106.73 10.33 106.94 10.34L106.94 12.88Q106.83 12.85 106.38 12.79Q105.93 12.73 105.42 12.73Q104.33 12.73 103.47 13.19Q102.61 13.65 102.11 14.48Q101.62 15.3 101.62 16.4L101.62 26.2L99.09 26.2ZM115 26.53Q113.03 26.53 111.53 25.53Q110.03 24.53 109.18 22.7Q108.34 20.88 108.34 18.4Q108.34 15.92 109.18 14.1Q110.03 12.28 111.53 11.29Q113.04 10.3 115.01 10.3Q116.45 10.3 117.36 10.77Q118.27 11.25 118.79 11.88Q119.31 12.51 119.61 13L119.78 13L119.78 5.1L122.3 5.1L122.3 26.2L119.87 26.2L119.87 23.78L119.63 23.78Q119.31 24.29 118.78 24.93Q118.24 25.58 117.33 26.05Q116.43 26.53 115 26.53ZM115.38 24.27Q116.81 24.27 117.81 23.52Q118.8 22.77 119.32 21.44Q119.84 20.11 119.84 18.37Q119.84 16.63 119.33 15.32Q118.82 14.02 117.82 13.29Q116.82 12.56 115.38 12.56Q113.91 12.56 112.91 13.32Q111.91 14.08 111.41 15.39Q110.9 16.7 110.9 18.37Q110.9 20.05 111.41 21.39Q111.92 22.73 112.93 23.5Q113.93 24.27 115.38 24.27ZM126 26.2L126 10.51L128.43 10.51L128.43 12.93L128.57 12.93Q129.01 11.74 130.1 11.01Q131.18 10.28 132.6 10.28Q132.89 10.28 133.26 10.31Q133.63 10.33 133.84 10.34L133.84 12.88Q133.73 12.85 133.28 12.79Q132.84 12.73 132.33 12.73Q131.24 12.73 130.37 13.19Q129.51 13.65 129.01 14.48Q128.52 15.3 128.52 16.4L128.52 26.2L126 26.2ZM136.01 26.2L136.01 10.51L138.53 10.51L138.53 26.2L136.01 26.2ZM137.27 7.88Q136.56 7.88 136.04 7.38Q135.53 6.89 135.53 6.19Q135.53 5.48 136.04 5Q136.56 4.52 137.27 4.52Q137.99 4.52 138.51 5Q139.02 5.48 139.02 6.19Q139.02 6.89 138.51 7.38Q137.99 7.88 137.27 7.88ZM146.77 26.2L140.76 10.51L143.53 10.51L146.87 19.77Q147.39 21.17 147.76 22.56Q148.13 23.95 148.54 25.25L147.66 25.25Q148.06 23.95 148.43 22.56Q148.81 21.17 149.32 19.77L152.65 10.51L155.41 10.51L149.4 26.2L146.77 26.2ZM164.21 26.54Q161.93 26.54 160.29 25.52Q158.65 24.5 157.76 22.69Q156.88 20.88 156.88 18.47Q156.88 16.06 157.75 14.22Q158.62 12.38 160.21 11.34Q161.81 10.3 163.93 10.3Q165.25 10.3 166.48 10.74Q167.71 11.18 168.69 12.12Q169.67 13.07 170.24 14.57Q170.81 16.08 170.81 18.2L170.81 19.19L158.52 19.19L158.52 17.05L169.45 17.05L168.28 17.83Q168.28 16.29 167.78 15.09Q167.29 13.89 166.32 13.22Q165.35 12.54 163.93 12.54Q162.53 12.54 161.51 13.22Q160.49 13.91 159.95 15.03Q159.41 16.16 159.41 17.49L159.41 18.85Q159.41 20.58 160.01 21.8Q160.6 23.01 161.69 23.65Q162.78 24.29 164.23 24.29Q165.18 24.29 165.95 24.01Q166.72 23.72 167.27 23.17Q167.82 22.62 168.11 21.82L170.56 22.39Q170.2 23.61 169.33 24.55Q168.45 25.49 167.14 26.02Q165.84 26.54 164.21 26.54Z" fill="#f2f2f3" stroke="#f2f2f3" stroke-width="0.9" stroke-linejoin="round"/>

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

+1 -1
View File
@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2 176.16 36" width="2113.89" height="432" role="img" aria-label="Overdrive">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2 176.16 36" width="440" height="89.92" role="img" aria-label="Overdrive">
<path d="M26.34 12.24A11 11 0 1 1 19.76 5.66" fill="none" stroke="#0b0b0d" stroke-width="4.5"/>
<path d="M28.4 3.6 17.6 18.1a2.9 2.9 0 1 1-3.7-3.7Z" fill="#0b0b0d"/>
<path d="M55.06 26.5Q52.34 26.5 50.22 25.17Q48.09 23.84 46.87 21.41Q45.66 18.98 45.66 15.66Q45.66 12.34 46.87 9.9Q48.09 7.47 50.22 6.14Q52.34 4.8 55.06 4.8Q57.78 4.8 59.9 6.14Q62.03 7.47 63.24 9.9Q64.45 12.34 64.45 15.66Q64.45 18.98 63.24 21.41Q62.03 23.84 59.9 25.17Q57.78 26.5 55.06 26.5ZM55.06 24.02Q56.94 24.02 58.48 23.06Q60.02 22.09 60.93 20.23Q61.84 18.37 61.84 15.66Q61.84 12.95 60.93 11.08Q60.02 9.21 58.48 8.24Q56.94 7.28 55.06 7.28Q53.16 7.28 51.62 8.24Q50.07 9.21 49.17 11.08Q48.26 12.95 48.26 15.66Q48.26 18.37 49.17 20.23Q50.07 22.09 51.62 23.06Q53.16 24.02 55.06 24.02ZM72.18 26.2L66.17 10.51L68.94 10.51L72.28 19.77Q72.8 21.17 73.17 22.56Q73.54 23.95 73.95 25.25L73.07 25.25Q73.47 23.95 73.84 22.56Q74.22 21.17 74.73 19.77L78.05 10.51L80.82 10.51L74.81 26.2L72.18 26.2ZM89.62 26.54Q87.34 26.54 85.7 25.52Q84.06 24.5 83.17 22.69Q82.29 20.88 82.29 18.47Q82.29 16.06 83.16 14.22Q84.03 12.38 85.62 11.34Q87.22 10.3 89.34 10.3Q90.66 10.3 91.89 10.74Q93.12 11.18 94.1 12.12Q95.07 13.07 95.65 14.57Q96.22 16.08 96.22 18.2L96.22 19.19L83.93 19.19L83.93 17.05L94.86 17.05L93.69 17.83Q93.69 16.29 93.19 15.09Q92.7 13.89 91.73 13.22Q90.76 12.54 89.34 12.54Q87.94 12.54 86.92 13.22Q85.9 13.91 85.36 15.03Q84.82 16.16 84.82 17.49L84.82 18.85Q84.82 20.58 85.42 21.8Q86.01 23.01 87.1 23.65Q88.19 24.29 89.64 24.29Q90.59 24.29 91.36 24.01Q92.13 23.72 92.68 23.17Q93.23 22.62 93.52 21.82L95.97 22.39Q95.61 23.61 94.73 24.55Q93.86 25.49 92.55 26.02Q91.25 26.54 89.62 26.54ZM99.09 26.2L99.09 10.51L101.53 10.51L101.53 12.93L101.67 12.93Q102.11 11.74 103.19 11.01Q104.28 10.28 105.69 10.28Q105.99 10.28 106.36 10.31Q106.73 10.33 106.94 10.34L106.94 12.88Q106.83 12.85 106.38 12.79Q105.93 12.73 105.42 12.73Q104.33 12.73 103.47 13.19Q102.61 13.65 102.11 14.48Q101.62 15.3 101.62 16.4L101.62 26.2L99.09 26.2ZM115 26.53Q113.03 26.53 111.53 25.53Q110.03 24.53 109.18 22.7Q108.34 20.88 108.34 18.4Q108.34 15.92 109.18 14.1Q110.03 12.28 111.53 11.29Q113.04 10.3 115.01 10.3Q116.45 10.3 117.36 10.77Q118.27 11.25 118.79 11.88Q119.31 12.51 119.61 13L119.78 13L119.78 5.1L122.3 5.1L122.3 26.2L119.87 26.2L119.87 23.78L119.63 23.78Q119.31 24.29 118.78 24.93Q118.24 25.58 117.33 26.05Q116.43 26.53 115 26.53ZM115.38 24.27Q116.81 24.27 117.81 23.52Q118.8 22.77 119.32 21.44Q119.84 20.11 119.84 18.37Q119.84 16.63 119.33 15.32Q118.82 14.02 117.82 13.29Q116.82 12.56 115.38 12.56Q113.91 12.56 112.91 13.32Q111.91 14.08 111.41 15.39Q110.9 16.7 110.9 18.37Q110.9 20.05 111.41 21.39Q111.92 22.73 112.93 23.5Q113.93 24.27 115.38 24.27ZM126 26.2L126 10.51L128.43 10.51L128.43 12.93L128.57 12.93Q129.01 11.74 130.1 11.01Q131.18 10.28 132.6 10.28Q132.89 10.28 133.26 10.31Q133.63 10.33 133.84 10.34L133.84 12.88Q133.73 12.85 133.28 12.79Q132.84 12.73 132.33 12.73Q131.24 12.73 130.37 13.19Q129.51 13.65 129.01 14.48Q128.52 15.3 128.52 16.4L128.52 26.2L126 26.2ZM136.01 26.2L136.01 10.51L138.53 10.51L138.53 26.2L136.01 26.2ZM137.27 7.88Q136.56 7.88 136.04 7.38Q135.53 6.89 135.53 6.19Q135.53 5.48 136.04 5Q136.56 4.52 137.27 4.52Q137.99 4.52 138.51 5Q139.02 5.48 139.02 6.19Q139.02 6.89 138.51 7.38Q137.99 7.88 137.27 7.88ZM146.77 26.2L140.76 10.51L143.53 10.51L146.87 19.77Q147.39 21.17 147.76 22.56Q148.13 23.95 148.54 25.25L147.66 25.25Q148.06 23.95 148.43 22.56Q148.81 21.17 149.32 19.77L152.65 10.51L155.41 10.51L149.4 26.2L146.77 26.2ZM164.21 26.54Q161.93 26.54 160.29 25.52Q158.65 24.5 157.76 22.69Q156.88 20.88 156.88 18.47Q156.88 16.06 157.75 14.22Q158.62 12.38 160.21 11.34Q161.81 10.3 163.93 10.3Q165.25 10.3 166.48 10.74Q167.71 11.18 168.69 12.12Q169.67 13.07 170.24 14.57Q170.81 16.08 170.81 18.2L170.81 19.19L158.52 19.19L158.52 17.05L169.45 17.05L168.28 17.83Q168.28 16.29 167.78 15.09Q167.29 13.89 166.32 13.22Q165.35 12.54 163.93 12.54Q162.53 12.54 161.51 13.22Q160.49 13.91 159.95 15.03Q159.41 16.16 159.41 17.49L159.41 18.85Q159.41 20.58 160.01 21.8Q160.6 23.01 161.69 23.65Q162.78 24.29 164.23 24.29Q165.18 24.29 165.95 24.01Q166.72 23.72 167.27 23.17Q167.82 22.62 168.11 21.82L170.56 22.39Q170.2 23.61 169.33 24.55Q168.45 25.49 167.14 26.02Q165.84 26.54 164.21 26.54Z" fill="#0b0b0d" stroke="#0b0b0d" stroke-width="0.9" stroke-linejoin="round"/>

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

+8
View File
@@ -51,6 +51,14 @@ jobs:
path: ps5-react/.deps
key: deps-${{ hashFiles('ps5-react/dependencies.lock.json') }}
- name: Stamp the build
# app.log names the build that wrote it: a draft rebuilt with the same version is otherwise
# indistinguishable from the one before it. A local build keeps the committed "dev".
run: |
printf 'var BUILD = { commit: "%s", built: "%s" };\n\nexport { BUILD };\n' \
"${GITHUB_SHA:0:7}" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > app/build.js
cat app/build.js
- name: Build
working-directory: ps5-react
run: python3 tools/build_ps5.py --app-dir "$GITHUB_WORKSPACE/app" --llvm /usr/bin
+58 -2
View File
@@ -223,6 +223,7 @@ screen. In the other order ShadowMount+ finds the file again and puts the icon b
| `app/` | The application: JSX sources, state, assets, catalog data, and `app.json` (title id `PPSA99058`, version, capabilities) |
| `ps5-react/` | The framework it runs on, vendored from [half144/ps5-react](https://github.com/half144/ps5-react) |
| `packaging/make-exfat.sh` | Builds the exFAT image ShadowMount+ installs |
| `packaging/make-exfat-mac.sh` | The same image, built locally on macOS without `update.zip` |
| `.github/workflows/build.yml` | The build |
</details>
@@ -249,8 +250,9 @@ What a bundler throws away could not come back: most comments, the JSX syntax (i
Everything runs from the repository root through npm. Once after cloning, `npm run setup`
(`packaging/setup.sh`) installs every dependency: on Linux the apt packages for the desktop preview,
the PS5 build and the tests (it asks for `sudo` once), on macOS the Homebrew formulae; then Pillow
and the framework's npm packages. Read the script first if you'd rather install them yourself.
the PS5 build and the tests (it asks for `sudo` once); on macOS the Homebrew formulae, a
`ps5-react/.venv` with Pillow (Homebrew's `python3` refuses a global `pip install`) and the
framework's npm packages. Read the script first if you'd rather install them yourself.
```sh
git clone https://github.com/MyLuxy/overdrive.git
@@ -289,6 +291,42 @@ upstream ps5-react supports macOS only.
</details>
<details>
<summary><b>Developing on macOS</b></summary>
<br>
The preview and the PS5 build run on a Mac with Xcode's command line tools, Homebrew, Node 18 or
newer and Python 3.9 or newer. `npm run setup` installs the Homebrew formulae (CMake, LLVM 18,
SDL2, ninja, nasm, libarchive, xz, OpenSSL), creates `ps5-react/.venv` with Pillow, and installs the
framework's npm packages. Homebrew's `python3` is externally managed, so Pillow goes in the venv
rather than a global `pip install`.
```sh
xcode-select --install # once, if you have never built anything on this Mac
npm run setup # Homebrew formulae, ps5-react/.venv, framework npm packages
npm run preview:mac # the desktop preview
```
The `:mac` commands leave `preview`, `dev`, `test` and `build` untouched: those remain the ones
Linux and the other developers use. `preview:mac` and `dev:mac` run through `ps5-react/.venv`, put
LLVM 18 and Node on `PATH`, and prefer MacPorts at `/opt/local` with an SDL2 built in
`~/.local/overdrive-sdl2` when they exist. In that case `/usr/local/bin` is left out of `PATH`, so
CMake does not mix Homebrew's OpenSSL/CURL with the MacPorts copies and break the link.
| Command | What it does |
| --- | --- |
| `npm run setup:mac` | Creates `ps5-react/.venv` with Pillow when the native libraries are already installed |
| `npm run preview:mac` | Opens the desktop preview once |
| `npm run dev:mac` | Opens it and rebuilds on every save under `app/` |
| `npm run doctor:mac` | Checks the venv, Pillow, LLVM 18, Node, CMake and clang |
If the preview does not start: `npm run doctor:mac` first; `brew install llvm@18` when LLVM is
missing; `brew install sdl2` or `CMAKE_PREFIX_PATH=$HOME/.local/overdrive-sdl2` when SDL2 is
missing; and keep Homebrew's `/usr/local/bin` out of `PATH` when a link error mentions OpenSSL or
CURL and MacPorts supplies CMake.
</details>
<details>
<summary><b>Building</b></summary>
<br>
@@ -311,6 +349,23 @@ downloads its pinned dependencies into `ps5-react/.deps` and writes the app fold
`.receipt.json` into `ps5-react/dist`. It verifies the result itself (`tools/verify.py`), so a
green build means the payload structure and signatures check out.
On macOS, `npm run build:mac` does the same app build through `ps5-react/.venv` (Pillow for
`verify.py`) and LLVM 18, without `sudo` and without packaging. The image is a separate,
local-only step: `packaging/make-exfat-mac.sh` uses macOS's `newfs_exfat` and `hdiutil`, writes
`Overdrive.exfat` alone and **no `update.zip`**. The CI keeps using `packaging/make-exfat.sh` on
Ubuntu and still produces both files.
```sh
npm run build:mac # the PS5 app only, in ps5-react/dist/PPSA99058
npm run package:mac # Overdrive.exfat from the app already built
npm run image:mac # both, in one go
```
`npm run package:mac` overwrites `Overdrive.exfat` in the repository root, so keep a copy of the
image you had before. It checks the filesystem with `fsck_exfat` and that every file of the app is
in the image; `fsck_exfat -n Overdrive.exfat` checks it again afterwards. The image keeps the same
64 KiB clusters and `OVERDRIVE` label as the Linux one.
The image is a plain exFAT volume whose root is the app folder, with 64 KiB clusters and the
label `OVERDRIVE`. To release a new version, raise the version in `app/version.js` and
`app/app.json`, run the workflow, test the draft's image on a console, then publish the draft:
@@ -342,6 +397,7 @@ Nothing else in the framework is changed.
- **[ShadowMount+](https://github.com/drakmor/ShadowMountPlus)** by drakmor, which installs it.
- **[kstuff](https://github.com/EchoStretch/kstuff-lite)** by EchoStretch, the kernel payload that lets the console run homebrew and the games it installs.
- **[PS5 Homebrew Catalog](https://github.com/darkxex/ps5-homebrew-catalog)** by darkxex, the store API behind the Homebrew section, and every developer listed in it.
- **[QR Code generator](https://github.com/nayuki/QR-Code-generator)** by Project Nayuki (MIT), which draws the QR codes of the phone login; its notice ships in the app as `third_party/qrcodegen-LICENSE.txt`.
`ps5-react/` is GPL-3.0-or-later and carries its own `LICENSE`, `LICENSE-ATTRIBUTION` and
`NOTICE`. Every dependency notice is copied into `notices/` inside the built app, and
+1 -1
View File
@@ -8,7 +8,7 @@
"surface": { "width": 1920, "height": 1080 },
"refreshRate": 60,
"textFonts": ["chinese", "chinese-traditional", "japanese", "bengali", "devanagari", "arabic"],
"resources": ["data/releases", "data/pages", "i18n/locales"],
"resources": ["data/releases", "data/pages", "i18n/locales", "third_party/qrcodegen-LICENSE.txt"],
"filesystemAccess": "console",
"networking": true,
"timeoutSeconds": 0
+100
View File
@@ -0,0 +1,100 @@
// The favorites page (the header's Favorites item): the games and Homebrew apps kept with Triangle,
// newest first, by kind.
import '../assets/Inter-Regular.ttf';
import { Fragment, jsx, jsxs } from 'react/jsx-runtime';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { SPAN_MAX, fitBytes } from '../i18n/fit.js';
import { useT } from '../i18n/index.js';
import { FAVORITE_KINDS, matchesFavoriteKind } from '../state/favorite-kinds.js';
import { NO_FILTERS, filterGames, sortGames } from '../state/catalog.js';
import { useNavActions } from '../state/nav.jsx';
import { useStore } from '../state/store.jsx';
import { EmptyState } from './EmptyState.jsx';
import { FocusRing } from '../ui/FocusRing.jsx';
import { DETAILED_CAPTION, GameCard, useFavoriteAction } from '../ui/GameCard.jsx';
import { FocusScope, ScrollView, Text, View, VirtualList, useIsFocused } from '@ps5-react/core';
var COLUMNS = 6;
var CARD_WIDTH = 182;
var CARD_HEIGHT = 243 + DETAILED_CAPTION;
var TITLE = { fontFamily: "Inter-Regular", fontSize: 30, fontWeight: 600, color: "#f2f2f3", lineHeight: 42, letterSpacing: -0.75 };
// The filter chosen; the page stays mounted under a game's page (index.jsx), so it survives a visit.
var chosenKind = "all";
function ChipLabel({ label, count, current }) {
const focused = useIsFocused();
return /* @__PURE__ */ (0, jsxs)(Fragment, { children: [
/* @__PURE__ */ (0, jsx)(FocusRing, {}),
/* @__PURE__ */ (0, jsx)(Text, { focused, style: [{ fontFamily: "Inter-Regular", fontSize: 21, color: current ? "#0d0d0f" : "#f2f2f3d9", lineHeight: 30 }, focused && !current ? { color: "#f2f2f3" } : null], children: label }),
/* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 21, color: current ? "#0d0d0f99" : "#8a8a94", lineHeight: 30 }, children: String(count) })
] });
}
function Chip({ id, label, count, current, onPress }) {
return /* @__PURE__ */ (0, jsx)(View, { focusKey: `favorites-kind:${id}`, onPress, style: (state) => [
{ height: 60, flexDirection: "row", alignItems: "center", gap: 12, borderRadius: 14999, paddingHorizontal: 24, backgroundColor: current ? "#f2f2f3" : "#17171a" },
state.focused && !current ? { backgroundColor: "#2b2b30" } : null
], children: /* @__PURE__ */ (0, jsx)(ChipLabel, { label, count, current }) });
}
function Grid({ games: games2 }) {
const { openGame } = useNavActions();
const renderItem = (0, useCallback)(
({ item: game, index }) => /* @__PURE__ */ (0, jsx)(
GameCard,
{
game,
width: CARD_WIDTH,
detailed: true,
rowEnd: index % COLUMNS === COLUMNS - 1 || index === games2.length - 1,
rowStart: index % COLUMNS === 0,
onPress: () => openGame(game.id, `favorite:${game.id}`)
}
),
[games2, openGame]
);
return /* @__PURE__ */ (0, jsx)(VirtualList, { style: { marginTop: 36 }, data: games2, numColumns: COLUMNS, itemHeight: CARD_HEIGHT, rowGap: 40, columnGap: 24, initialNumRows: 3, keyExtractor: (game) => game.id, renderItem });
}
function FavoritesScreen({ onBack, onBrowse }) {
const t2 = useT();
const store = useStore();
const [kind, setKind] = (0, useState)(chosenKind);
const choose = (next) => {
chosenKind = next;
setKind(next);
};
const saved = (0, useMemo)(
() => sortGames(filterGames({ filters: { ...NO_FILTERS, wishlist: true }, query: "", wishlist: store.wishlist }), "newest"),
[store.wishlist]
);
const counts = (0, useMemo)(
() => Object.fromEntries(FAVORITE_KINDS.map(({ id }) => [id, saved.filter((game) => matchesFavoriteKind(game, id)).length])),
[saved]
);
const chips = FAVORITE_KINDS.filter(({ id }) => id === "all" || counts[id] > 0);
const shown = kind === "all" ? saved : saved.filter((game) => matchesFavoriteKind(game, kind));
// Removing the last favorite leaves the empty state; its button takes the focus.
const { focusLater } = useNavActions();
const listed = saved.length > 0;
const listedBefore = (0, useRef)(listed);
(0, useEffect)(() => {
if (listedBefore.current && !listed) focusLater("catalog:empty");
listedBefore.current = listed;
}, [listed]);
const onAction = useFavoriteAction();
const back2 = () => {
onBack();
return true;
};
// Not a trap: Up from the chips reaches the header, as on Homebrew, Downloads and Files.
return /* @__PURE__ */ (0, jsx)(FocusScope, { autoFocus: true, onBack: back2, onAction, children: /* @__PURE__ */ (0, jsxs)(ScrollView, { style: { flex: 1, paddingHorizontal: 48, paddingTop: 48, paddingBottom: 96 }, children: [
/* @__PURE__ */ (0, jsx)(View, { scrollAnchor: true, children: /* @__PURE__ */ (0, jsxs)(Text, { style: TITLE, children: [
fitBytes(t2("catalog.favorites"), SPAN_MAX),
saved.length ? /* @__PURE__ */ (0, jsx)(Text, { style: { fontWeight: 400, color: "#8a8a94" }, children: fitBytes(` ${t2("catalog.gameCount", { count: saved.length })}`, SPAN_MAX) }) : null
] }) }),
saved.length ? /* @__PURE__ */ (0, jsx)(FocusScope, { row: true, children: /* @__PURE__ */ (0, jsx)(View, { style: { marginTop: 36, flexDirection: "row", flexWrap: "wrap", gap: 12 }, children: chips.map(
({ id, label }) => /* @__PURE__ */ (0, jsx)(Chip, { id, label: t2(label), count: counts[id], current: kind === id, onPress: () => choose(id) }, id)
) }) }) : null,
saved.length ? /* @__PURE__ */ (0, jsx)(Grid, { games: shown }, kind) : /* @__PURE__ */ (0, jsx)(EmptyState, { onlyWishlist: true, onClear: onBrowse })
] }) });
}
export { FavoritesScreen };
+7
View File
@@ -0,0 +1,7 @@
// Filled in by the build: the commit it was made from and the moment, so a session in app.log names
// the build that wrote it. Two drafts of one version once looked identical in a log, and telling them
// apart cost more than this file.
var BUILD = { commit: "dev", built: "" };
export { BUILD };
+401
View File
@@ -0,0 +1,401 @@
// The Downloads page's own section for downloads that do not come from the catalog (issues #107,
// #89, #20): a pasted link, a torrent through the user's own qBittorrent, and links and magnets
// through a debrid service — TorBox, Real-Debrid or Premiumize, picked in one dialog so each card
// stays one thing. A file that has to be fetched first (a debrid service, qBittorrent) shows it in a
// dialog with Cancel; once the service names the file, it joins the normal queue, on the drive
// chosen in Settings.
//
// The strip scrolls with the list, so it is the screen that places it (useAddDownloads returns it
// beside the dialogs): a fixed strip above the list took the focus with it when the list scrolled.
import '../assets/Inter-Regular.ttf';
import { Fragment, jsx, jsxs } from 'react/jsx-runtime';
import { useEffect, useRef, useState } from 'react';
import { useT } from '../i18n/index.js';
import { useNavActions } from '../state/nav.jsx';
import { downloadQueue, storageRoots } from '../state/downloads.js';
import { downloadRoot } from '../state/download-root.js';
import { qbittorrent as qbittorrentSettings, services as serviceSettings, setService, setServices, useServices } from '../state/download-services.js';
import { refreshRealDebrid } from '../state/device-login.js';
import { cancellation } from '../state/download-sources.js';
import { addLink, linkInput } from '../state/link-download.js';
import { createTorbox } from '../state/torbox.js';
import { createRealDebrid } from '../state/realdebrid.js';
import { createPremiumize } from '../state/premiumize.js';
import { createQBittorrent } from '../state/qbittorrent.js';
import { URLDialog } from './URLDialog.jsx';
import { PhoneSetup } from './PhoneSetup.jsx';
import { primaryKey } from './DownloadRow.jsx';
import { Button2 } from '../ui/Button.jsx';
import { FocusRing } from '../ui/FocusRing.jsx';
import { Icon } from '../ui/Icon.jsx';
import { colors } from '../ui/colors.js';
import { FocusScope, Http, Text, View } from '@ps5-react/core';
// Where a pasted file is saved: the drive chosen in Settings, when it is still there.
function chosenRoot() {
try {
const roots = storageRoots();
const remembered = downloadRoot();
return remembered && roots.includes(remembered) ? remembered : roots[0] ?? "/data";
} catch {
return "/data";
}
}
var CARD = { width: 430, height: 124, flexDirection: "row", alignItems: "center", gap: 18, borderRadius: 16, paddingHorizontal: 20, backgroundColor: colors.surface };
function AddCard({ icon, title, detail, onPress, focusKey, nextFocusDown }) {
return /* @__PURE__ */ (0, jsxs)(View, {
focusKey, onPress, nextFocusDown,
style: (state) => [CARD, state.focused ? { backgroundColor: colors.surface2 } : null],
children: [
/* @__PURE__ */ (0, jsx)(FocusRing, {}),
/* @__PURE__ */ (0, jsx)(View, { style: { width: 58, height: 58, alignItems: "center", justifyContent: "center", borderRadius: 14, backgroundColor: colors.surface2 }, children: /* @__PURE__ */ (0, jsx)(Icon, { name: icon, size: 30, color: colors.fg }) }),
/* @__PURE__ */ (0, jsxs)(View, { style: { flex: 1, gap: 4 }, children: [
/* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 24, fontWeight: 600, color: colors.fg, numberOfLines: 1 }, children: title }),
/* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 19, color: colors.muted, numberOfLines: 2, lineHeight: 26 }, children: detail })
] })
]
});
}
// A wait with a bar: the service is fetching the file, or the client is still downloading it.
function Waiting({ title, detail, progress, error: error2, onCancel, onClose }) {
const t2 = useT();
const percent = Math.max(0, Math.min(1, progress));
return /* @__PURE__ */ (0, jsx)(FocusScope, {
trap: true, autoFocus: true,
onBack: () => {
if (error2) onClose();
else onCancel();
return true;
},
children: /* @__PURE__ */ (0, jsx)(View, { style: { position: "absolute", top: 0, right: 0, bottom: 0, left: 0, zIndex: 50, alignItems: "center", justifyContent: "center", backgroundColor: "#000000f2" }, children: /* @__PURE__ */ (0, jsxs)(View, { style: { width: 900, gap: 26, borderRadius: 18, borderWidth: 2, borderColor: colors.line, backgroundColor: colors.surface, padding: 44 }, children: [
/* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 31, fontWeight: 600, color: colors.fg, lineHeight: 42 }, children: title }),
/* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 22, color: error2 ? colors.fg : colors.muted, lineHeight: 32 }, children: error2 || detail }),
error2 ? null : /* @__PURE__ */ (0, jsxs)(View, { style: { gap: 12 }, children: [
/* @__PURE__ */ (0, jsx)(View, { style: { width: 812, height: 8, borderRadius: 4, backgroundColor: colors.surface3 }, children: /* @__PURE__ */ (0, jsx)(View, { style: { width: Math.round(812 * percent), height: 8, borderRadius: 4, backgroundColor: colors.fg } }) }),
/* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 20, color: colors.muted }, children: t2("add.percent", { percent: String(Math.round(percent * 100)) }) })
] }),
/* @__PURE__ */ (0, jsx)(View, { style: { flexDirection: "row", gap: 12 }, children: error2
? /* @__PURE__ */ (0, jsx)(Button2, { label: t2("add.close"), onPress: onClose, autoFocus: true })
: /* @__PURE__ */ (0, jsx)(Button2, { label: t2("add.cancel"), variant: "outline", onPress: onCancel, autoFocus: true }) })
] }) })
});
}
// The frame every dialog here shares: a modal over the page, Back leaving it.
function Dialog({ width = 980, onBack, children }) {
return /* @__PURE__ */ (0, jsx)(FocusScope, {
trap: true, autoFocus: true,
onBack: () => {
onBack();
return true;
},
children: /* @__PURE__ */ (0, jsx)(View, { style: { position: "absolute", top: 0, right: 0, bottom: 0, left: 0, zIndex: 50, alignItems: "center", justifyContent: "center", backgroundColor: "#000000f2" }, children: /* @__PURE__ */ (0, jsxs)(View, { style: { width, gap: 20, borderRadius: 18, borderWidth: 2, borderColor: colors.line, backgroundColor: colors.surface, padding: 44 }, children }) })
});
}
function serviceName(t2, service) {
return t2(`add.${service}Name`);
}
// The debrid services, in the order the dialog lists them. Each has its own key and its own card
// row, and none is ever used on another's behalf.
var DEBRID = ["torbox", "realdebrid", "premiumize"];
// The ones linked from the phone with a QR code (device-login.js); the typed key stays beside it.
var PHONE = ["torbox", "realdebrid"];
// The guided setup behind a card: its own service's fields, the first time the card is pressed and
// whenever the user asks to change them. What is already saved shows as it is; Continue goes on.
function SetupDialog({ service, onField, onDone, onCancel }) {
const t2 = useT();
const services = useServices();
const rows = DEBRID.includes(service)
? [{ field: service, icon: service, title: t2(`add.field.${service}`), value: services[service] }]
: [
{ field: "address", icon: "globe", title: t2("add.address"), value: services.address },
{ field: "user", icon: "controller", title: t2("add.user"), value: services.user },
{ field: "password", icon: "controller", title: t2("add.password"), value: services.password ? "••••••••" : "" }
];
return /* @__PURE__ */ (0, jsxs)(Dialog, { onBack: onCancel, children: [
/* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 34, fontWeight: 600, color: colors.fg }, children: t2("add.setupTitle", { service: serviceName(t2, service) }) }),
/* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 21, color: colors.muted, lineHeight: 30 }, children: t2("add.setupBody") }),
// Whoever has no client at all can still send a magnet to TorBox, from its own card.
service === "qbittorrent" ? /* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 20, color: colors.subtle, lineHeight: 28 }, children: t2("add.setupClientHint") }) : null,
rows.map((row) => /* @__PURE__ */ (0, jsxs)(View, {
focusKey: `downloads:setup:${row.field}`,
onPress: () => onField(row.field),
style: (state) => [{ flexDirection: "row", alignItems: "center", gap: 14, borderRadius: 12, paddingHorizontal: 16, height: 64, backgroundColor: colors.surface2 }, state.focused ? { backgroundColor: colors.surface3 } : null],
children: [
/* @__PURE__ */ (0, jsx)(FocusRing, {}),
/* @__PURE__ */ (0, jsx)(Icon, { name: row.icon, size: 20, color: colors.muted }),
/* @__PURE__ */ (0, jsx)(Text, { style: { flex: 1, fontFamily: "Inter-Regular", fontSize: 22, color: colors.fg }, children: row.title }),
/* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 20, color: row.value ? colors.fg : colors.subtle, numberOfLines: 1, ellipsizeMode: "head" }, children: row.value || t2("add.notSet") })
]
}, row.field)),
/* @__PURE__ */ (0, jsxs)(View, { style: { flexDirection: "row", gap: 12, paddingTop: 4 }, children: [
/* @__PURE__ */ (0, jsx)(Button2, { label: t2("add.continue"), focusKey: "downloads:setup:done", autoFocus: true, onPress: onDone }),
/* @__PURE__ */ (0, jsx)(Button2, { label: t2("add.cancel"), variant: "outline", onPress: onCancel })
] })
] });
}
// A card whose service is already set up asks before touching what is saved.
function AskDialog({ service, onContinue, onChange, onCancel }) {
const t2 = useT();
return /* @__PURE__ */ (0, jsxs)(Dialog, { width: 900, onBack: onCancel, children: [
/* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 32, fontWeight: 600, color: colors.fg }, children: t2("add.configuredTitle", { service: serviceName(t2, service) }) }),
/* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 21, color: colors.muted, lineHeight: 30 }, children: t2("add.configuredBody") }),
/* @__PURE__ */ (0, jsxs)(View, { style: { flexDirection: "row", gap: 12, paddingTop: 4 }, children: [
/* @__PURE__ */ (0, jsx)(Button2, { label: t2("add.continue"), focusKey: "downloads:ask:continue", autoFocus: true, onPress: onContinue }),
/* @__PURE__ */ (0, jsx)(Button2, { label: t2("add.modify"), icon: "settings", variant: "outline", onPress: onChange }),
/* @__PURE__ */ (0, jsx)(Button2, { label: t2("add.cancel"), variant: "ghost", onPress: onCancel })
] })
] });
}
// The debrid services, one row each with its own mark and whether its key is set: picking one goes
// on to its question or its setup, exactly as a card of its own would have.
function ServicesDialog({ services, onPick, onCancel }) {
const t2 = useT();
return /* @__PURE__ */ (0, jsxs)(Dialog, { onBack: onCancel, children: [
/* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 34, fontWeight: 600, color: colors.fg }, children: t2("add.servicesTitle") }),
/* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 21, color: colors.muted, lineHeight: 30 }, children: t2("add.servicesBody") }),
/* @__PURE__ */ (0, jsx)(View, { style: { flexDirection: "row", gap: 14 }, children: DEBRID.map((service) => /* @__PURE__ */ (0, jsxs)(View, {
focusKey: `downloads:debrid:${service}`,
onPress: () => onPick(service),
style: (state) => [{ width: 280, alignItems: "center", gap: 12, borderRadius: 14, paddingVertical: 24, backgroundColor: colors.surface2 }, state.focused ? { backgroundColor: colors.surface3 } : null],
children: [
/* @__PURE__ */ (0, jsx)(FocusRing, {}),
/* @__PURE__ */ (0, jsx)(Icon, { name: service, size: 52, color: colors.fg }),
/* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 23, fontWeight: 600, color: colors.fg }, children: serviceName(t2, service) }),
/* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 18, color: services[service] ? colors.fg : colors.subtle }, children: t2(services[service] ? "add.serviceReady" : PHONE.includes(service) ? "add.serviceLink" : "add.serviceNeeds") })
]
}, service)) })
] });
}
// The client a route names, built around the key saved for it: one debrid service each, or the
// qBittorrent on the home network.
function preparer(route, settings) {
if (route === "torbox") return createTorbox({ http: Http, key: settings.torbox });
if (route === "realdebrid") {
// A phone login's token lasts hours and is renewed from what the login saved; a typed one never ends.
const renew = settings.realdebridRefresh && settings.realdebridClient && settings.realdebridSecret ? async ({ signal }) => {
const token = await refreshRealDebrid({ http: Http, client: settings.realdebridClient, secret: settings.realdebridSecret, refresh: settings.realdebridRefresh, signal });
setServices(token);
return token;
} : null;
return createRealDebrid({ http: Http, key: settings.realdebrid, renew, expiresAt: Number(settings.realdebridExpires) || 0 });
}
if (route === "premiumize") return createPremiumize({ http: Http, key: settings.premiumize });
return createQBittorrent({ http: Http, ...qbittorrentSettings() });
}
// Hands the link to the service and waits; the file joins the queue as soon as it names one.
function RunDialog({ route, value, onAdded, onClose }) {
const t2 = useT();
const [state, setState] = useState({ progress: 0, error: "" });
const token = useRef(null);
(0, useEffect)(() => {
const cancel = cancellation();
token.current = cancel;
let live = true;
const settings = serviceSettings();
const progress = (report) => {
if (live) setState((old) => ({ ...old, progress: report.progress }));
};
void (async () => {
try {
const prepared = await preparer(route, settings).prepare(value, { onProgress: progress, signal: cancel.signal });
if (!live) return;
const id = addLink(downloadQueue(), { ...prepared, title: prepared.filename, root: chosenRoot() });
live = false;
onAdded(id);
} catch (error2) {
if (live) setState({ progress: 0, error: error2.message });
}
})();
return () => {
live = false;
cancel.cancel();
};
}, [route, value]);
const service = serviceName(t2, route);
return /* @__PURE__ */ (0, jsx)(Waiting, {
title: t2("add.waitTitle", { service }),
detail: t2("add.waitDetail", { service }),
progress: state.progress,
error: state.error,
onCancel: () => {
if (token.current) token.current.cancel();
onClose();
},
onClose
});
}
// The strip and its dialogs. The strip belongs in the page's list, where it scrolls with it, and
// the dialogs above everything: the screen asks for both.
function useAddDownloads({ onAdded, down } = {}) {
const t2 = useT();
const services = useServices();
const { focusLater } = useNavActions();
const [dialog, setDialog] = useState(null);
// { kind: "link" | "torrent" | "services" | "debrid" | "setup" | "ask" | "run" | "field", ... }
// A URL dialog closes itself once its submit returns: a submit that opened the next dialog has
// already replaced it by then, and this leaves that one alone.
const closeIf = (kind) => setDialog((current) => current && current.kind === kind ? null : current);
const add = (file) => {
const id = addLink(downloadQueue(), { url: file.url, filename: file.filename, headers: file.headers, sizeBytes: file.sizeBytes, title: file.filename, root: chosenRoot() });
if (onAdded) onAdded(id);
else focusLater(primaryKey(id));
};
// What comes after a setup or a question: the pasted value goes on to its service, and a card
// pressed on its own opens its own URL dialog.
const next2 = (next) => {
if (next.value != null) open(next.kind, next.value, next.service);
else setDialog({ kind: next.kind, service: next.service });
};
// A magnet pasted anywhere goes through a service: the queue cannot fetch one itself.
const open = (kind, value, service) => {
const parsed = linkInput(value);
if (kind !== "torrent" && kind !== "debrid" && parsed.kind === "magnet") kind = "torrent";
if (kind === "torrent") {
// This card is the client's: a magnet or a .torrent link, fetched by qBittorrent. The debrid
// card is its own, and never takes a torrent on this one's behalf.
const torrent = /\.torrent(?:[?#]|$)/i.test(parsed.value);
if (parsed.kind !== "magnet" && !torrent) throw new Error(t2("add.magnetOnly"));
if (!services.address) {
// Nothing is set up yet: the guide asks for the client, then carries on with the link.
setDialog({ kind: "setup", service: "qbittorrent", next: { kind: "torrent", value: parsed.value } });
return;
}
setDialog({ kind: "run", route: "qbittorrent", value: parsed.value });
return;
}
if (kind === "debrid") {
// A debrid service takes a magnet or a host link; only qBittorrent fetches a .torrent file.
if (/\.torrent(?:[?#]|$)/i.test(parsed.value) && parsed.kind !== "magnet") throw new Error(t2("add.torrentLinkNeedsClient"));
if (!services[service]) {
setDialog({ kind: "setup", service, next: { kind: "debrid", service, value: parsed.value } });
return;
}
setDialog({ kind: "run", route: service, value: parsed.value });
return;
}
// A plain link: the queue fetches it, through the host's own resolver when it knows the host.
add({ url: parsed.value, filename: parsed.name });
};
// The torrent card: its client's guided setup the first time, a "change it?" question after.
const card = () => {
const next3 = { kind: "torrent" };
setDialog(services.address ? { kind: "ask", service: "qbittorrent", next: next3 } : { kind: "setup", service: "qbittorrent", next: next3 });
};
// One debrid service from the dialog: its key's setup the first time, its question after. Each
// service keeps its own downloads, so none is ever used on another's behalf.
const pick = (service) => {
const next3 = { kind: "debrid", service };
setDialog(services[service] ? { kind: "ask", service, next: next3 } : { kind: "setup", service, next: next3 });
};
// The fields of a service, opened from its setup.
const field = (name, from) => {
const values = {
torbox: services.torbox, realdebrid: services.realdebrid, premiumize: services.premiumize,
address: services.address, user: services.user, password: services.password
};
const service = from?.service ?? (DEBRID.includes(name) ? name : "qbittorrent");
setDialog({ kind: "field", field: name, value: values[name] || "", service, next: from?.next });
};
// Continue from a setup: a service with nothing in it asks for its main field first.
const setupDone = (service, next3) => {
const settings = serviceSettings();
const main = service === "qbittorrent" ? "address" : service;
if (!settings[main]) return field(main, { service, next: next3 });
next2(next3);
};
const strip = /* @__PURE__ */ (0, jsxs)(View, { style: { gap: 14 }, children: [
/* @__PURE__ */ (0, jsx)(Text, { style: { paddingHorizontal: 6, fontFamily: "Inter-Regular", fontSize: 20, fontWeight: 600, color: colors.subtle, letterSpacing: 1 }, children: t2("add.title") }),
// The focus ring is drawn 7 px outside the card, and the scroll view clips what it has no room
// for: the first card sat flush with its left edge, so the ring was cut and looked broken.
// Down goes to the first row of the list, which the D-pad used to pass over for the panel on the
// right: it was the nearer thing below, not the one the player meant (2026-10-25).
/* @__PURE__ */ (0, jsxs)(View, { style: { flexDirection: "row", gap: 14, paddingLeft: 12 }, children: [
/* @__PURE__ */ (0, jsx)(AddCard, { icon: "link", title: t2("add.link"), detail: t2("add.linkDetail"), focusKey: "downloads:add:link", nextFocusDown: down, onPress: () => setDialog({ kind: "link" }) }),
/* @__PURE__ */ (0, jsx)(AddCard, { icon: "qbittorrent", title: t2("add.torrent"), detail: services.address ? t2("add.torrentDetail") : t2("add.torrentSetup"), focusKey: "downloads:add:torrent", nextFocusDown: down, onPress: card }),
/* @__PURE__ */ (0, jsx)(AddCard, { icon: "layers", title: t2("add.debrid"), detail: t2("add.debridDetail"), focusKey: "downloads:add:debrid", nextFocusDown: down, onPress: () => setDialog({ kind: "services" }) })
] })
] });
const dialogs = /* @__PURE__ */ (0, jsxs)(Fragment, { children: [
dialog && dialog.kind === "link" ? /* @__PURE__ */ (0, jsx)(URLDialog, {
title: t2("add.linkTitle"), detail: t2("add.linkBody"), initial: "",
submit: (value) => open("link", value), onClose: () => closeIf("link")
}) : null,
dialog && dialog.kind === "torrent" ? /* @__PURE__ */ (0, jsx)(URLDialog, {
title: t2("add.torrentTitle"), detail: t2("add.torrentBody"), initial: "magnet:?xt=urn:btih:",
submit: (value) => open("torrent", value), onClose: () => closeIf("torrent")
}) : null,
dialog && dialog.kind === "services" ? /* @__PURE__ */ (0, jsx)(ServicesDialog, {
services,
onPick: pick,
onCancel: () => setDialog(null)
}) : null,
dialog && dialog.kind === "debrid" ? /* @__PURE__ */ (0, jsx)(URLDialog, {
title: serviceName(t2, dialog.service),
detail: t2("add.debridBody", { service: serviceName(t2, dialog.service) }),
initial: "",
submit: (value) => open("debrid", value, dialog.service),
onClose: () => closeIf("debrid")
}) : null,
dialog && dialog.kind === "field" ? /* @__PURE__ */ (0, jsx)(URLDialog, {
title: t2(`add.field.${dialog.field}`), detail: t2(`add.fieldBody.${dialog.field}`), initial: dialog.value,
// This dialog's own close already goes back to the setup it came from, saved or not.
submit: (value) => {
setService(dialog.field, value);
setDialog({ kind: "setup", service: dialog.service, next: dialog.next });
},
onClose: () => setDialog({ kind: "setup", service: dialog.service, next: dialog.next })
}) : null,
dialog && dialog.kind === "setup" && PHONE.includes(dialog.service) ? /* @__PURE__ */ (0, jsx)(PhoneSetup, {
service: dialog.service,
onField: (name) => field(name, dialog),
onDone: () => setupDone(dialog.service, dialog.next),
onCancel: () => setDialog(null)
}) : null,
dialog && dialog.kind === "setup" && !PHONE.includes(dialog.service) ? /* @__PURE__ */ (0, jsx)(SetupDialog, {
service: dialog.service,
onField: (name) => field(name, dialog),
onDone: () => setupDone(dialog.service, dialog.next),
onCancel: () => setDialog(null)
}) : null,
dialog && dialog.kind === "ask" ? /* @__PURE__ */ (0, jsx)(AskDialog, {
service: dialog.service,
onContinue: () => next2(dialog.next),
onChange: () => setDialog({ kind: "setup", service: dialog.service, next: dialog.next }),
onCancel: () => setDialog(null)
}) : null,
dialog && dialog.kind === "run" ? /* @__PURE__ */ (0, jsx)(RunDialog, {
route: dialog.route, value: dialog.value,
onAdded: (id) => {
setDialog(null);
focusLater(primaryKey(id));
},
onClose: () => setDialog(null)
}) : null
] });
return { strip, dialogs };
}
export { useAddDownloads };
+28 -11
View File
@@ -41,11 +41,20 @@ import { Text, View } from '@ps5-react/core';
if (["paused", "failed"].includes(state) && STALE_PARTIAL.test(cause(error2))) return { icon: "download", label: "downloads.action.restart", onPress: () => run("restart") };
if (state === "paused") return { icon: "play", label: "downloads.action.resume", onPress: () => run("resume") };
if (state === "failed") return { icon: "download", label: "downloads.action.retry", onPress: () => run("resume") };
if (state === "awaiting-browser") return { icon: "play", label: "downloads.action.continueOnPage", onPress: openGame };
if (state === "awaiting-browser") return { icon: "play", label: "downloads.action.continueOnPage", onPress: openGame, page: true };
if (state === "downloaded") return { icon: "circleCheck", label: "downloads.action.install", onPress: () => run("register") };
return { icon: "chevronRight", label: "downloads.action.gamePage", onPress: openGame };
return { icon: "chevronRight", label: "downloads.action.gamePage", onPress: openGame, page: true };
}
function DeleteButton({ id, action, run, onFocus }) {
// The row's three buttons, by the key the D-pad moves to. A row outside the catalog, or one whose
// primary already opens the game page, has no info button.
var keyFor = (id, kind) => kind === "info" ? `downloads-info:${id}` : kind === "delete" ? `downloads-cancel:${id}` : primaryKey(id);
var infoShown = (job2) => Boolean(getGame(job2.gameId)) && !primary(job2).page;
// Where Up or Down lands in a neighbouring row, by the button it leaves from: the same button, or the
// primary one where that row has no info button. Plain keys, so a row redraws only when they change:
// the jobs themselves are new objects on every progress update.
var linksOf = (job2) => ({ info: keyFor(job2.id, infoShown(job2) ? "info" : "primary"), primary: primaryKey(job2.id), delete: keyFor(job2.id, "delete") });
var sameLinks = (a, b) => a === b || Boolean(a && b) && a.info === b.info && a.primary === b.primary && a.delete === b.delete;
function DeleteButton({ id, action, run, onFocus, vertical }) {
const { rtl } = useLocale();
const [armed, setArmed] = (0, useState)(false);
(0, useEffect)(() => {
@@ -61,6 +70,7 @@ import { Text, View } from '@ps5-react/core';
icon: armed ? "delete" : "x",
focusKey: `downloads-cancel:${id}`,
...focusEnd(rtl, PANEL_KEY),
...vertical,
onFocus,
onBlur: () => setArmed(false),
onPress: () => {
@@ -74,7 +84,7 @@ import { Text, View } from '@ps5-react/core';
}
);
}
function InfoButton({ id, onPress, onFocus }) {
function InfoButton({ id, onPress, onFocus, vertical }) {
return /* @__PURE__ */ (0, jsx)(
Button2,
{
@@ -82,12 +92,13 @@ import { Text, View } from '@ps5-react/core';
size: "icon",
icon: "info",
focusKey: `downloads-info:${id}`,
...vertical,
onFocus,
onPress
}
);
}
function DownloadRowView({ job: job2, game, autoFocus, run, onOpenGame, onFocusRow }) {
function DownloadRowView({ job: job2, game, autoFocus, up, down, run, onOpenGame, onFocusRow }) {
const t2 = useT();
const { state, release } = job2;
const shown = shownState(job2);
@@ -96,13 +107,17 @@ import { Text, View } from '@ps5-react/core';
const main = primary(job2, (name) => run(job2, name), openGame);
// A job outside the catalog has no page to open, and a primary button that already leads there
// (continue on page) needs no second one; the triangle shortcut opens it either way.
const infoPage = !getGame(job2.gameId) || main.onPress === openGame ? null : () => openGame();
const infoPage = infoShown(job2) ? () => openGame() : null;
// Up and Down go from a button to the same button of the row above or below, instead of letting
// the D-pad pick whatever is nearest: the summary panel sits right of the list and won, so Up from
// the first row's X landed on the storage block (2026-10-25).
const vertical = (kind) => ({ nextFocusUp: up?.[kind], nextFocusDown: down?.[kind] });
const onFocus = () => onFocusRow(job2);
const meta = [shownFormat(job2, release).toUpperCase(), `${release.provider} ${release.version || ""}`.trim(), job2.root].join(" \xB7 ");
const problem = job2.error && explainError(job2.error, Boolean(release.parts));
// The files' own title id differs from the game chosen: said before anything else but an error.
const mismatch = job2.titleMismatch ? t2("downloads.titleMismatch", { found: job2.titleMismatch, expected: job2.titleId }) : "";
const note = problem ? problem.action ? t2("downloads.problem", { title: problem.title, action: problem.action }) : problem.title : mismatch || (state === "downloaded" ? job2.message || t2("downloads.installHint") : state === "extracted" ? job2.message || "" : "");
const note = problem ? problem.action ? t2("downloads.problem", { title: problem.title, action: problem.action }) : problem.title : mismatch || (state === "downloaded" ? job2.message || t2("downloads.installHint") : state === "extracted" || job2.streamed ? job2.message || "" : "");
const cdn = isOverdriveCdn(release.provider);
return /* @__PURE__ */ (0, jsxs)(View, { style: cdn ? { position: "relative", borderRadius: 18, backgroundColor: "#17171a" } : { overflow: "hidden", borderRadius: 18, borderWidth: 2, borderColor: "#2a2a2f", backgroundColor: "#17171a" }, children: [
cdn ? /* @__PURE__ */ (0, jsx)(CdnAura, { radius: 12, width: 904 }) : null,
@@ -132,7 +147,7 @@ import { Text, View } from '@ps5-react/core';
note ? /* @__PURE__ */ (0, jsx)(Text, { style: { width: ROW, fontFamily: "Inter-Regular", fontSize: 21, color: problem ? "#f2f2f3" : mismatch ? "#fde68a" : "#a3a3ab", numberOfLines: 1, ellipsizeMode: "tail", lineHeight: 30 }, children: note }) : null
] }),
/* @__PURE__ */ (0, jsxs)(View, { style: { flexDirection: "row", gap: 12 }, children: [
infoPage ? /* @__PURE__ */ (0, jsx)(InfoButton, { id: job2.id, onPress: infoPage, onFocus }) : null,
infoPage ? /* @__PURE__ */ (0, jsx)(InfoButton, { id: job2.id, onPress: infoPage, onFocus, vertical: vertical("info") }) : null,
/* @__PURE__ */ (0, jsx)(
Button2,
{
@@ -142,6 +157,7 @@ import { Text, View } from '@ps5-react/core';
style: PRIMARY,
autoFocus,
focusKey: primaryKey(job2.id),
...vertical("primary"),
onFocus,
onPress: main.onPress
}
@@ -152,7 +168,8 @@ import { Text, View } from '@ps5-react/core';
id: job2.id,
action: clearAction(state),
run: (name) => run(job2, name),
onFocus
onFocus,
vertical: vertical("delete")
}
)
] })
@@ -162,7 +179,7 @@ import { Text, View } from '@ps5-react/core';
}
var DownloadRow = (0, memo)(
DownloadRowView,
(a, b) => shapeOf(a.job) === shapeOf(b.job) && a.autoFocus === b.autoFocus
(a, b) => shapeOf(a.job) === shapeOf(b.job) && a.autoFocus === b.autoFocus && sameLinks(a.up, b.up) && sameLinks(a.down, b.down)
);
export { DownloadRow, primaryKey };
export { DownloadRow, linksOf, primaryKey };
+29 -9
View File
@@ -2,9 +2,10 @@
// reconstructed from the esbuild bundle in eboot.bin
import '../assets/Inter-Regular.ttf';
import { jsx, jsxs } from 'react/jsx-runtime';
import { Fragment, jsx, jsxs } from 'react/jsx-runtime';
import { useEffect, useRef, useState } from 'react';
import { DownloadRow, primaryKey } from './DownloadRow.jsx';
import { useAddDownloads } from './AddDownloads.jsx';
import { DownloadRow, linksOf, primaryKey } from './DownloadRow.jsx';
import { SummaryPanel } from './SummaryPanel.jsx';
import { useJobs } from './useQueue.js';
import { formatNumber2, t } from '../i18n/core.js';
@@ -29,6 +30,8 @@ import { FocusScope, ScrollView, Text, View } from '@ps5-react/core';
];
var EMPTY_HINTS = [HINT.select, HINT.back];
var EMPTY_KEY = "downloads:empty";
// The strip's right-hand card, the one above a row's buttons.
var STRIP_KEY = "downloads:add:debrid";
function summary(jobs) {
const count = (states) => jobs.filter((job2) => states.includes(job2.state)).length;
return [
@@ -38,7 +41,7 @@ import { FocusScope, ScrollView, Text, View } from '@ps5-react/core';
[count(COMPLETE), "completed"]
].filter(([n]) => n > 0).map(([n, key]) => t(`downloads.summary.${key}`, { count: n })).join(" \xB7 ");
}
function Section({ title, jobs, first, handlers }) {
function Section({ title, jobs, first, handlers, links }) {
const t2 = useT();
if (!jobs.length) return null;
return /* @__PURE__ */ (0, jsxs)(View, { style: { gap: 18 }, children: [
@@ -50,6 +53,8 @@ import { FocusScope, ScrollView, Text, View } from '@ps5-react/core';
job: job2,
game: getGame(job2.gameId) ?? { title: job2.title, art: {} },
autoFocus: job2 === first,
up: links.get(job2.id).up,
down: links.get(job2.id).down,
run: handlers.run,
onOpenGame: handlers.openGame,
onFocusRow: handlers.focusRow
@@ -124,6 +129,15 @@ import { FocusScope, ScrollView, Text, View } from '@ps5-react/core';
const stopped = jobs.filter((job2) => ["paused", "failed", "awaiting-browser"].includes(placed(job2)));
const done = jobs.filter((job2) => COMPLETE.includes(placed(job2)));
const first = active2[0] ?? stopped[0] ?? done[0];
// The rows in the order they are drawn, across the sections: Up and Down go from a row to the one
// above or below it in this list, and Down from the strip's cards goes to the first of them. Up from
// the first row goes to the strip's card above its buttons, not to the summary panel on the right,
// which the D-pad found nearer (2026-10-25).
const order = [...active2, ...stopped, ...done];
const strip = { info: STRIP_KEY, primary: STRIP_KEY, delete: STRIP_KEY };
const near = order.map(linksOf);
const links = new Map(order.map((job2, index) => [job2.id, { up: index ? near[index - 1] : strip, down: near[index + 1] }]));
const add = useAddDownloads({ down: order.length ? primaryKey(order[0].id) : EMPTY_KEY });
return /* @__PURE__ */ (0, jsx)(FocusScope, { autoFocus: true, onBack: back2, onAction, children: /* @__PURE__ */ (0, jsxs)(View, { style: { flex: 1 }, children: [
/* @__PURE__ */ (0, jsxs)(View, { style: { flexDirection: "row", alignItems: "flex-end", gap: 48, paddingHorizontal: 48, paddingTop: 48, paddingBottom: 36 }, children: [
/* @__PURE__ */ (0, jsxs)(Text, { style: { width: 1356, fontFamily: "Inter-Regular", fontSize: 30, fontWeight: 600, color: "#f2f2f3", numberOfLines: 1, ellipsizeMode: "tail", lineHeight: 42, letterSpacing: -0.75 }, children: [
@@ -133,16 +147,22 @@ import { FocusScope, ScrollView, Text, View } from '@ps5-react/core';
/* @__PURE__ */ (0, jsx)(Text, { style: { width: 420, fontFamily: "Inter-Regular", fontSize: 21, color: "#f2f2f3", numberOfLines: 2, ellipsizeMode: "tail", lineHeight: 30 }, children: failure })
] }),
/* @__PURE__ */ (0, jsxs)(View, { style: { flex: 1, flexDirection: "row", gap: 48, paddingHorizontal: 48 }, children: [
jobs.length > 0 ? /* @__PURE__ */ (0, jsx)(ScrollView, { style: { flex: 1 }, children: /* @__PURE__ */ (0, jsxs)(View, { style: { gap: 60, paddingBottom: 60 }, children: [
/* @__PURE__ */ (0, jsx)(Section, { title: "downloads.section.active", jobs: active2, first, handlers }),
/* @__PURE__ */ (0, jsx)(Section, { title: "downloads.section.stopped", jobs: stopped, first, handlers }),
/* @__PURE__ */ (0, jsx)(Section, { title: "downloads.section.completed", jobs: done, first, handlers })
] }) }) : /* @__PURE__ */ (0, jsx)(Empty, { error: error2, onBrowse }),
// Downloads of the user's own (issues #107, #89, #20) scroll with the list, above it: a strip
// fixed outside took the focus with it as soon as the list was scrolled down.
/* @__PURE__ */ (0, jsx)(ScrollView, { style: { flex: 1 }, children: /* @__PURE__ */ (0, jsxs)(View, { style: { gap: 60, paddingBottom: 60 }, children: [
add.strip,
jobs.length > 0 ? /* @__PURE__ */ (0, jsxs)(Fragment, { children: [
/* @__PURE__ */ (0, jsx)(Section, { title: "downloads.section.active", jobs: active2, first, handlers, links }),
/* @__PURE__ */ (0, jsx)(Section, { title: "downloads.section.stopped", jobs: stopped, first, handlers, links }),
/* @__PURE__ */ (0, jsx)(Section, { title: "downloads.section.completed", jobs: done, first, handlers, links })
] }) : /* @__PURE__ */ (0, jsx)(View, { style: { height: 480 }, children: /* @__PURE__ */ (0, jsx)(Empty, { error: error2, onBrowse }) })
] }) }),
/* @__PURE__ */ (0, jsx)(SummaryPanel, { error: error2, onFocus: () => {
focused.current = null;
} })
] }),
/* @__PURE__ */ (0, jsx)(ButtonHints, { hints: jobs.length > 0 ? LIST_HINTS : EMPTY_HINTS })
/* @__PURE__ */ (0, jsx)(ButtonHints, { hints: jobs.length > 0 ? LIST_HINTS : EMPTY_HINTS }),
add.dialogs
] }) });
}
+165
View File
@@ -0,0 +1,165 @@
// The setup of TorBox and Real-Debrid: a QR code first, so the account is linked from the phone in
// a few seconds, and the typed key beside it as before. The code is asked for as the dialog opens and
// renewed by itself when it runs out; once the player approves it on the phone, the token is saved
// like a typed key (settings.json, kept through restarts and updates) and the dialog goes on.
import '../assets/Inter-Regular.ttf';
import { jsx, jsxs } from 'react/jsx-runtime';
import { useEffect, useState } from 'react';
import { useT } from '../i18n/index.js';
import { setServices, useServices } from '../state/download-services.js';
import { Expired, accountOf, createDeviceLogin } from '../state/device-login.js';
import { cancellation } from '../state/download-sources.js';
import { QRCode } from '../ui/QRCode.jsx';
import { Button2 } from '../ui/Button.jsx';
import { FocusRing } from '../ui/FocusRing.jsx';
import { Icon } from '../ui/Icon.jsx';
import { colors } from '../ui/colors.js';
import { FocusScope, Http, Text, View } from '@ps5-react/core';
// The phone login's life: a code is asked for, shown until the player approves it or it runs out
// (then the next one is asked for), and the token saved. `retry` starts over after a failure.
function usePhoneLogin(service, onLinked) {
const [state, setState] = (0, useState)({ phase: "starting" });
const [attempt, setAttempt] = (0, useState)(0);
(0, useEffect)(() => {
const cancel = cancellation();
let live = true, after = null;
const login = createDeviceLogin({ http: Http, service });
void (async () => {
for (;;) {
let session;
try {
session = await login.start({ signal: cancel.signal });
} catch (error) {
console.log(`[device-login] ${service}: no code: ${error.message}`);
if (live) setState({ phase: "error", error: error.message });
return;
}
if (!live) return;
console.log(`[device-login] ${service}: code shown, valid ${Math.round((session.expiresAt - Date.now()) / 1e3)} s`);
setState({ phase: "waiting", session });
let values;
try {
values = await login.approved(session, { signal: cancel.signal });
} catch (error) {
if (!live) return;
if (error instanceof Expired) {
console.log(`[device-login] ${service}: the code ran out; asking for a new one`);
continue;
}
setState({ phase: "error", error: error.message });
return;
}
if (!live) return;
setServices(values);
console.log(`[device-login] ${service}: linked from the phone`);
setState({ phase: "linked" });
const account = await accountOf({ http: Http, service, key: values[service], signal: cancel.signal });
if (!live) return;
if (account) setState({ phase: "linked", account });
// A moment to read "Linked" and whose account it is, then the dialog goes on by itself.
after = setTimeout(onLinked, 1800);
return;
}
})();
return () => {
live = false;
clearTimeout(after);
cancel.cancel();
};
}, [service, attempt]);
return { state, retry: () => setAttempt((value) => value + 1) };
}
// "8:21": what is left of the code, ticking once a second while it shows.
function useRemaining(expiresAt) {
const [now, setNow] = (0, useState)(Date.now());
(0, useEffect)(() => {
if (!expiresAt) return void 0;
const timer = setInterval(() => setNow(Date.now()), 1e3);
return () => clearInterval(timer);
}, [expiresAt]);
const left = Math.max(0, Math.round(((expiresAt || 0) - now) / 1e3));
return `${Math.floor(left / 60)}:${String(left % 60).padStart(2, "0")}`;
}
var TEXT = { fontFamily: "Inter-Regular", color: colors.fg };
var QR_SIZE = 380;
// The square on the left: the code once there is one, a plain panel saying why there is none yet.
function CodePanel({ state, service, t2 }) {
const session = state.session;
const remaining = useRemaining(session?.expiresAt);
if (state.phase === "linked") {
return /* @__PURE__ */ (0, jsxs)(View, { style: { width: QR_SIZE, height: QR_SIZE, borderRadius: 18, alignItems: "center", justifyContent: "center", gap: 18, backgroundColor: colors.surface2 }, children: [
/* @__PURE__ */ (0, jsx)(Icon, { name: "circleCheck", size: 96, color: colors.ok }),
/* @__PURE__ */ (0, jsx)(Text, { style: { ...TEXT, fontSize: 30, fontWeight: 600, color: colors.ok }, children: t2("add.phone.linked") })
] });
}
if (!session) {
return /* @__PURE__ */ (0, jsx)(View, { style: { width: QR_SIZE, height: QR_SIZE, borderRadius: 18, alignItems: "center", justifyContent: "center", padding: 30, backgroundColor: colors.surface2 }, children: /* @__PURE__ */ (0, jsx)(Text, { style: { ...TEXT, fontSize: 22, color: colors.muted, lineHeight: 32, textAlign: "center" }, children: state.phase === "error" ? t2("add.phone.failed") : t2("add.phone.starting", { service }) }) });
}
return /* @__PURE__ */ (0, jsxs)(View, { style: { width: QR_SIZE, gap: 14, alignItems: "center" }, children: [
/* @__PURE__ */ (0, jsx)(QRCode, { text: session.qr, size: QR_SIZE }),
/* @__PURE__ */ (0, jsx)(Text, { style: { ...TEXT, fontSize: 19, color: colors.subtle }, children: t2("add.phone.expires", { time: remaining }) })
] });
}
function PhoneSetup({ service, onField, onDone, onCancel }) {
const t2 = useT();
const services = useServices();
const name = t2(`add.${service}Name`);
const { state, retry } = usePhoneLogin(service, onDone);
const session = state.session;
const account = state.account;
const status = state.phase === "linked"
? [account?.name, account?.plan, account?.until ? t2("add.phone.until", { date: account.until }) : ""].filter(Boolean).join(" · ") || t2("add.phone.linkedBody")
: state.phase === "error" ? state.error : state.phase === "waiting" ? t2("add.phone.waiting") : "";
const key = services[service];
return /* @__PURE__ */ (0, jsx)(FocusScope, {
trap: true, autoFocus: true,
onBack: () => {
onCancel();
return true;
},
children: /* @__PURE__ */ (0, jsx)(View, { style: { position: "absolute", top: 0, right: 0, bottom: 0, left: 0, zIndex: 50, alignItems: "center", justifyContent: "center", backgroundColor: "#000000f2" }, children: /* @__PURE__ */ (0, jsxs)(View, { style: { width: 1320, gap: 30, borderRadius: 18, borderWidth: 2, borderColor: colors.line, backgroundColor: colors.surface, padding: 48 }, children: [
/* @__PURE__ */ (0, jsxs)(View, { style: { flexDirection: "row", alignItems: "center", gap: 18 }, children: [
/* @__PURE__ */ (0, jsx)(Icon, { name: service, size: 44, color: colors.fg }),
/* @__PURE__ */ (0, jsx)(Text, { style: { ...TEXT, fontSize: 34, fontWeight: 600 }, children: t2("add.setupTitle", { service: name }) })
] }),
/* @__PURE__ */ (0, jsxs)(View, { style: { flexDirection: "row", gap: 48 }, children: [
/* @__PURE__ */ (0, jsx)(CodePanel, { state, service: name, t2 }),
/* @__PURE__ */ (0, jsxs)(View, { style: { flex: 1, gap: 18 }, children: [
/* @__PURE__ */ (0, jsx)(Text, { style: { ...TEXT, fontSize: 30, fontWeight: 600 }, children: t2("add.phone.title") }),
/* @__PURE__ */ (0, jsx)(Text, { style: { ...TEXT, fontSize: 22, color: colors.muted, lineHeight: 32 }, children: t2("add.phone.step1") }),
/* @__PURE__ */ (0, jsx)(Text, { style: { ...TEXT, fontSize: 22, color: colors.muted, lineHeight: 32 }, children: t2("add.phone.step2", { service: name }) }),
session && state.phase !== "linked" ? /* @__PURE__ */ (0, jsxs)(View, { style: { gap: 6 }, children: [
/* @__PURE__ */ (0, jsx)(Text, { style: { ...TEXT, fontSize: 20, color: colors.subtle, lineHeight: 28 }, children: t2("add.phone.manual", { page: session.page }) }),
/* @__PURE__ */ (0, jsx)(Text, { style: { ...TEXT, fontSize: 58, fontWeight: 600, letterSpacing: 10, lineHeight: 72 }, children: session.code })
] }) : null,
status ? /* @__PURE__ */ (0, jsx)(Text, { style: { ...TEXT, fontSize: 21, color: state.phase === "linked" ? colors.ok : state.phase === "error" ? colors.warn : colors.info, lineHeight: 30, numberOfLines: 3 }, children: status }) : null,
state.phase === "error" ? /* @__PURE__ */ (0, jsx)(View, { style: { flexDirection: "row" }, children: /* @__PURE__ */ (0, jsx)(Button2, { label: t2("add.phone.retry"), icon: "download", variant: "outline", onPress: retry }) }) : null,
/* @__PURE__ */ (0, jsx)(Text, { style: { ...TEXT, fontSize: 19, color: colors.subtle, paddingTop: 6 }, children: t2("add.phone.or") }),
/* @__PURE__ */ (0, jsxs)(View, {
focusKey: `downloads:setup:${service}`,
onPress: () => onField(service),
style: (focus) => [{ flexDirection: "row", alignItems: "center", gap: 14, borderRadius: 12, paddingHorizontal: 16, height: 64, backgroundColor: colors.surface2 }, focus.focused ? { backgroundColor: colors.surface3 } : null],
children: [
/* @__PURE__ */ (0, jsx)(FocusRing, {}),
/* @__PURE__ */ (0, jsx)(Icon, { name: "link", size: 20, color: colors.muted }),
/* @__PURE__ */ (0, jsx)(Text, { style: { ...TEXT, flex: 1, fontSize: 22 }, children: t2(`add.field.${service}`) }),
/* @__PURE__ */ (0, jsx)(Text, { style: { ...TEXT, fontSize: 20, color: key ? colors.fg : colors.subtle, numberOfLines: 1, ellipsizeMode: "head" }, children: key ? `••••${key.slice(-4)}` : t2("add.notSet") })
]
})
] })
] }),
/* @__PURE__ */ (0, jsxs)(View, { style: { flexDirection: "row", gap: 12 }, children: [
/* @__PURE__ */ (0, jsx)(Button2, { label: t2("add.continue"), focusKey: "downloads:setup:done", autoFocus: true, onPress: onDone }),
/* @__PURE__ */ (0, jsx)(Button2, { label: t2("add.cancel"), variant: "outline", onPress: onCancel })
] })
] }) })
});
}
export { PhoneSetup };
+3 -1
View File
@@ -3,7 +3,9 @@
import { t } from '../i18n/core.js';
var STALE_PARTIAL = /partial file|checkpoint/i;
// "partial download" is the queue's own wording for a partial it adopted from an earlier session
// (issue #111): its Retry is the one that deletes it and starts over.
var STALE_PARTIAL = /partial (?:file|download)|checkpoint/i;
var TASK = /^\w+\.\w+ \/.*?: /;
var cause = (message) => message.replace(TASK, "");
var INCOMPLETE_PART = /Volume (\d+) of \d+ looks incomplete/;
+3 -1
View File
@@ -22,11 +22,13 @@ import { COMPLETE } from '../state/download-queue.js';
"installer-required",
"installing",
"installed",
"streamed",
"extraction-required",
"removing"
]);
var WAITING = ["resolving", "connecting", "verifying", "pausing", "finishing", "removing"];
var shownState = (job2) => job2.state === "extracting" && job2.phase === "finishing" ? "finishing" : job2.state;
// A PKG handed to the console's own downloads is done here, but not installed yet.
var shownState = (job2) => job2.state === "extracting" && job2.phase === "finishing" ? "finishing" : job2.state === "installed" && job2.streamed ? "streamed" : job2.state;
var hasStateLabel = (state) => LABELED.has(state);
var stateLabel = (state) => t(`downloadState.${state}`);
var clearAction = (state) => COMPLETE.includes(state) ? "remove" : "cancel";
+27 -7
View File
@@ -18,6 +18,7 @@ import { formatPercent2 } from '../i18n/core.js';
import { useT } from '../i18n/index.js';
import { editionsOf } from '../state/catalog.js';
import { BUSY, COMPLETE } from '../state/download-queue.js';
import { downloadRoot, setDownloadRoot, storageIndex } from '../state/download-root.js';
import { downloadQueue, driveName, freeBytes, storageRoots } from '../state/downloads.js';
import { useNavActions } from '../state/nav.jsx';
import { formatBytes } from '../state/size.js';
@@ -27,7 +28,8 @@ import { glide } from '../ui/ProgressBar.jsx';
import { ShimmerText } from '../ui/ShimmerText.jsx';
import { Shine } from '../ui/Shine.jsx';
import { EASE_OUT } from '../ui/ease.js';
import { DownloadFormats, Text, View, motion } from '@ps5-react/core';
import { pkgMode } from '../state/pkg-mode.js';
import { DownloadFormats, Platform, Text, View, motion } from '@ps5-react/core';
var WIDTH6 = 272;
var GB2 = 1e9;
@@ -53,10 +55,11 @@ import { DownloadFormats, Text, View, motion } from '@ps5-react/core';
});
const [storage, setStorage] = (0, useState)(() => {
const first = releases[preferredRelease(releases)];
return Math.max(0, roots.findIndex((root2) => {
// The drive picked last time (issue #103), unless it went away or has no room for this release.
return storageIndex(roots, downloadRoot(), (root2) => {
const need2 = spaceNeeded(first, root2);
return !need2 || freeBytes(root2) >= need2 + MARGIN2;
}));
});
});
const release = releases[selected];
const download = useDownload(game, release, roots[storage]);
@@ -65,14 +68,26 @@ import { DownloadFormats, Text, View, motion } from '@ps5-react/core';
const need = spaceNeeded(release, roots[storage]);
const lacking = download.status === "idle" && need && free[storage] !== null && free[storage] < need + MARGIN2;
const stale = ["paused", "failed"].includes(download.status) && STALE_PARTIAL.test(cause(error2));
const status = lacking ? "no-space" : stale ? "stale" : download.status;
// A PKG the PS5's Downloads took is theirs to install: "Installed" here said what had not happened.
const streamedOut = job2?.state === "installed" && job2.streamed;
const status = lacking ? "no-space" : stale ? "stale" : streamedOut ? "streamed" : download.status;
// A new download first shows what the chosen source is and how to use it (DownloadTipsDialog);
// resuming, installing or pausing one already queued goes straight on.
// A PKG goes to the PS5's own Downloads, in the background, or stays in Overdrive: the player says
// which before it starts, or once for every PKG (pkg-mode.js). A file that may turn out to be a PKG
// (a ".binary") follows what was remembered, and tries the console first otherwise.
const startNew = () => {
const mode = pkgMode();
const pkg = Platform.OS === "ps5" && ["pkg", "fpkg"].includes(String(release?.format || "").toLowerCase()) && !release?.parts;
if (pkg && mode === "ask") onRequestDialog({ kind: "stream", game, release, submit: (choice) => download.advance(choice) });
else download.advance(mode === "overdrive" ? "here" : mode === "console" ? "console" : void 0);
};
const begin = () => {
if (stale) restart();
else if (lacking) return;
// A homebrew app has one source, its developer's GitHub release: the tips are about choosing hosts.
else if (status === "idle" && release && !game.homebrew) onRequestDialog({ kind: "tips", game, release, submit: () => download.advance(), changeSource: () => chooseSource() });
else if (status === "idle" && release && !game.homebrew) onRequestDialog({ kind: "tips", game, release, submit: startNew, changeSource: () => chooseSource() });
else if (status === "idle") startNew();
else download.advance();
};
const { openEdition, focusLater, push } = useNavActions();
@@ -123,7 +138,7 @@ import { DownloadFormats, Text, View, motion } from '@ps5-react/core';
const problem = browser ? {
title: step ? t2("download.browser.partTitle", step) : t2("download.browser.title"),
action: t2("download.browser.action")
} : error2 ? explainError(error2, Boolean(job2?.release.parts)) : lacking ? { title: t2("download.noFit"), action: spaceAdvice(roots, free, storage, need) } : shadowMount || status === "installed" ? null : { title: t2("shadowMount.offTitle"), action: t2("shadowMount.offAction") };
} : error2 ? explainError(error2, Boolean(job2?.release.parts)) : lacking ? { title: t2("download.noFit"), action: spaceAdvice(roots, free, storage, need) } : shadowMount || status === "installed" || status === "streamed" ? null : { title: t2("shadowMount.offTitle"), action: t2("shadowMount.offAction") };
const notice = job2?.message;
const pick2 = (index) => {
if (index !== selected && job2 && !written && ["awaiting-browser", "failed", "paused"].includes(job2.state)) downloadQueue().remove(job2.id);
@@ -247,7 +262,12 @@ import { DownloadFormats, Text, View, motion } from '@ps5-react/core';
{
focusKey: storageFocus,
nextFocusDown: after(storageFocus),
onPress: roots.length > 1 ? () => setStorage((index) => (index + 1) % roots.length) : null,
onPress: roots.length > 1 ? () => {
// Picking a drive here picks it for the games after this one too (issue #103).
const next = (storage + 1) % roots.length;
setDownloadRoot(roots[next]);
setStorage(next);
} : null,
text: [t2("download.saveTo", { drive: driveName(roots[storage]) }), free[storage] !== null && t2("download.free", { size: formatBytes(free[storage]) })].filter(Boolean).join(" \xB7 ")
}
) : null,
+5 -1
View File
@@ -12,7 +12,8 @@ import { Icon } from '../ui/Icon.jsx';
import { colors } from '../ui/colors.js';
import { Text, View } from '@ps5-react/core';
var DISABLED = ["unavailable", "no-space", "removing"];
// "streamed": the PS5's own Downloads have it, and nothing is left to press here.
var DISABLED = ["unavailable", "no-space", "removing", "streamed"];
var CONTENT = {
idle: { icon: "download", text: "download.button.idle" },
unavailable: { icon: "download", text: "download.button.unavailable" },
@@ -37,6 +38,8 @@ import { Text, View } from '@ps5-react/core';
"installer-required": { icon: "download", text: "download.button.installer-required" },
installing: { icon: "download", text: "download.button.installing" },
installed: { icon: "circleCheck", text: "download.button.installed" },
// Handed to the PS5's own Downloads: not installed yet, and followed there, not here.
streamed: { icon: "controller", text: "download.button.streamed" },
"extraction-required": { icon: "circleCheck", text: "download.button.extraction-required" },
removing: { icon: "delete", text: "download.button.removing" }
};
@@ -45,6 +48,7 @@ import { Text, View } from '@ps5-react/core';
if (status === "unavailable") return [t("download.status.unavailable"), null];
if (status === "awaiting-browser") return [t("download.status.awaitingLink"), null];
if (status === "finishing") return [stateLabel(status), t("downloads.writingToDisk")];
if (status === "streamed") return [stateLabel(status), null];
if (status === "no-space") return [t("download.status.noSpace"), sizeGb && t("download.status.needs", { size: formatSize(sizeGb) })];
if (COMPLETE.includes(status)) return [stateLabel(status), sizeGb && formatSize(sizeGb)];
const of = sizeGb && t("download.status.progress", { done: formatSize(sizeGb * progress), total: formatSize(sizeGb) });
+97
View File
@@ -0,0 +1,97 @@
// Where a PKG downloads, asked before it starts: in Overdrive as before, with pause, resume and the
// install at the end, or in the background on the PS5, where its own Downloads fetch it and the
// console is free while it does (Overdrive can close). Overdrive comes first and wears the "faster"
// badge: sixteen connections beat the console's own queue. "Remember" answers it for every PKG from
// then on; Settings can change that answer again (pkg-mode.js).
import '../assets/Inter-Regular.ttf';
import { jsx, jsxs } from 'react/jsx-runtime';
import { useEffect, useState } from 'react';
import { useT } from '../i18n/index.js';
import { useNavActions } from '../state/nav.jsx';
import { setPkgMode } from '../state/pkg-mode.js';
import { formatBytes } from '../state/size.js';
import { Cover } from '../ui/Cover.jsx';
import { FocusRing } from '../ui/FocusRing.jsx';
import { Icon } from '../ui/Icon.jsx';
import { LogoMark } from '../ui/Logo.jsx';
import { PlatformBadge } from '../ui/PlatformBadge.jsx';
import { colors } from '../ui/colors.js';
import { FocusScope, Text, View } from '@ps5-react/core';
var TEXT = { fontFamily: "Inter-Regular", color: colors.fg };
// Each choice carries its own mark: the PS5 badge the game cards wear, for the console's Downloads,
// and Overdrive's dial for Overdrive. The badge is wide and low, the dial square, so the badge is a
// little wider (54 units, 81 px) than the dial (44 units, 66 px) to weigh about the same in its tile.
var MARKS = {
console: () => /* @__PURE__ */ (0, jsx)(PlatformBadge, { platforms: ["ps5"], scale: 54 / 44 }),
here: () => /* @__PURE__ */ (0, jsx)(LogoMark, { size: 44, color: colors.fg })
};
function Option({ focusKey, mark, title, body, badge, onPress }) {
return /* @__PURE__ */ (0, jsxs)(View, {
focusKey, onPress,
style: (state) => [{ position: "relative", flex: 1, gap: 16, borderRadius: 20, padding: 32, backgroundColor: colors.surface2 }, state.focused ? { backgroundColor: colors.surface3 } : null],
children: [
/* @__PURE__ */ (0, jsx)(FocusRing, {}),
/* @__PURE__ */ (0, jsxs)(View, { style: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" }, children: [
/* @__PURE__ */ (0, jsx)(View, { style: { width: 96, height: 96, alignItems: "center", justifyContent: "center", borderRadius: 22, backgroundColor: colors.surface3 }, children: MARKS[mark]() }),
badge ? /* @__PURE__ */ (0, jsx)(View, { style: { borderRadius: 999, paddingHorizontal: 16, paddingVertical: 6, backgroundColor: colors.ok }, children: /* @__PURE__ */ (0, jsx)(Text, { style: { ...TEXT, fontSize: 18, fontWeight: 600, color: colors.accentFg }, children: badge }) }) : null
] }),
/* @__PURE__ */ (0, jsx)(Text, { style: { ...TEXT, fontSize: 30, fontWeight: 600, lineHeight: 38 }, children: title }),
/* @__PURE__ */ (0, jsx)(Text, { style: { ...TEXT, fontSize: 21, color: colors.muted, lineHeight: 31 }, children: body })
]
});
}
function StreamChoiceDialog({ game, release, submit, onClose }) {
const t2 = useT();
const [remember, setRemember] = (0, useState)(false);
const { focusLater } = useNavActions();
// As the tips dialog: a focus move still pending from the page would land under this one.
(0, useEffect)(() => {
focusLater("stream:here");
}, []);
const pick = (mode) => {
if (remember) setPkgMode(mode === "here" ? "overdrive" : "console");
onClose();
submit(mode);
};
const size = release?.sizeBytes ? formatBytes(release.sizeBytes) : "";
return /* @__PURE__ */ (0, jsx)(FocusScope, {
trap: true, autoFocus: true,
onBack: () => {
onClose();
return true;
},
onAction: () => true,
children: /* @__PURE__ */ (0, jsx)(View, { style: { position: "absolute", top: 0, right: 0, bottom: 0, left: 0, zIndex: 50, alignItems: "center", justifyContent: "center", backgroundColor: "#000000d9" }, children: /* @__PURE__ */ (0, jsxs)(View, { style: { width: 1240, gap: 30, borderRadius: 24, borderWidth: 2, borderColor: colors.line, backgroundColor: colors.surface, paddingHorizontal: 56, paddingTop: 44, paddingBottom: 40 }, children: [
/* @__PURE__ */ (0, jsxs)(View, { style: { flexDirection: "row", alignItems: "center", gap: 24 }, children: [
/* @__PURE__ */ (0, jsx)(Cover, { game, label: false, style: { width: 84, height: 84, borderRadius: 12, overflow: "hidden" } }),
/* @__PURE__ */ (0, jsxs)(View, { style: { flex: 1, gap: 4 }, children: [
/* @__PURE__ */ (0, jsx)(Text, { style: { ...TEXT, fontSize: 40, fontWeight: 600, letterSpacing: -1, lineHeight: 48 }, children: t2("stream.title") }),
/* @__PURE__ */ (0, jsx)(Text, { style: { ...TEXT, fontSize: 21, color: colors.muted, numberOfLines: 1, ellipsizeMode: "tail" }, children: [game.title, size].filter(Boolean).join(" \xB7 ") })
] })
] }),
/* @__PURE__ */ (0, jsxs)(View, { style: { flexDirection: "row", gap: 24 }, children: [
/* @__PURE__ */ (0, jsx)(Option, { focusKey: "stream:here", mark: "here", title: t2("stream.here.title"), body: t2("stream.here.body"), badge: t2("stream.faster"), onPress: () => pick("here") }),
/* @__PURE__ */ (0, jsx)(Option, { focusKey: "stream:console", mark: "console", title: t2("stream.console.title"), body: t2("stream.console.body"), onPress: () => pick("console") })
] }),
/* @__PURE__ */ (0, jsxs)(View, {
focusKey: "stream:remember",
onPress: () => setRemember(!remember),
style: (state) => [{ position: "relative", flexDirection: "row", alignItems: "center", gap: 18, borderRadius: 14, marginHorizontal: -16, paddingHorizontal: 16, paddingVertical: 12 }, state.focused ? { backgroundColor: colors.surface2 } : null],
children: [
/* @__PURE__ */ (0, jsx)(FocusRing, {}),
/* @__PURE__ */ (0, jsx)(View, { style: { width: 34, height: 34, alignItems: "center", justifyContent: "center", borderRadius: 8, borderWidth: 2, borderColor: remember ? colors.fg : colors.subtle, backgroundColor: remember ? colors.fg : "transparent" }, children: remember ? /* @__PURE__ */ (0, jsx)(Icon, { name: "check", size: 22, color: colors.accentFg }) : null }),
/* @__PURE__ */ (0, jsxs)(View, { style: { flex: 1, gap: 2 }, children: [
/* @__PURE__ */ (0, jsx)(Text, { style: { ...TEXT, fontSize: 22, fontWeight: 600 }, children: t2("stream.remember") }),
/* @__PURE__ */ (0, jsx)(Text, { style: { ...TEXT, fontSize: 19, color: colors.subtle }, children: t2("stream.rememberDetail") })
] })
]
})
] }) })
});
}
export { StreamChoiceDialog };
+2 -1
View File
@@ -7,6 +7,7 @@ import { useEffect, useState } from 'react';
import { BrowserCaptureDialog } from '../downloads/BrowserCaptureDialog.jsx';
import { URLDialog } from '../downloads/URLDialog.jsx';
import { DownloadTipsDialog } from './DownloadTipsDialog.jsx';
import { StreamChoiceDialog } from './StreamChoiceDialog.jsx';
import { About } from './About.jsx';
import { GameFacts } from './GameFacts.jsx';
import { GameHero } from './GameHero.jsx';
@@ -51,7 +52,7 @@ import { AnimatePresence, FocusScope, ScrollView, Text, View } from '@ps5-react/
const [toast, setToast] = (0, useState)(null);
const [viewing, setViewing] = (0, useState)(null);
const { focusLater } = useNavActions();
const Dialog = { source: SourcePicker, options: OptionsSheet, version: VersionSheet, tips: DownloadTipsDialog }[dialog?.kind] ?? URLDialog;
const Dialog = { source: SourcePicker, options: OptionsSheet, version: VersionSheet, tips: DownloadTipsDialog, stream: StreamChoiceDialog }[dialog?.kind] ?? URLDialog;
const related = relatedGames(game);
const about = game.about.filter((text) => text.trim());
(0, useEffect)(() => {
+1 -2
View File
@@ -4,7 +4,7 @@
import { t } from '../i18n/core.js';
import { sourceKind } from '../state/download-sources.js';
import { isPs5Package } from '../state/firmware.js';
import { isPatchOnly } from '../state/release-sets.js';
import { isDlc, isPatchOnly } from '../state/release-sets.js';
import { formatBytes } from '../state/size.js';
var KIND = {
@@ -74,7 +74,6 @@ import { formatBytes } from '../state/size.js';
status: release.unavailableReason ? null : compatibility(release)
};
}
var isDlc = ({ label }) => /\bDLCs?\b/i.test(label || "") && !/\b(?:game|full)\b|\+\s*(?:all\s+)?DLCs?\b|\bwith\s+(?:all\s+)?DLCs?\b|\bincl/i.test(label);
var GROUP_RANK = { direct: 0, page: 1, browser: 2, unavailable: 3 };
var HEALTH_RANK = { ok: 0, flaky: 2 };
var isBackport = ({ label }) => /\bback\s*port\b/i.test(label || "");
+3 -2
View File
@@ -33,14 +33,15 @@ import { sameRelease } from '../state/release-sets.js';
} catch {
}
}, [job2?.id]);
const advance = () => {
// `streamMode` for a new download: "console" or "here" (pkg-mode.js), none to try the console first.
const advance = (streamMode) => {
try {
setError("");
const queue = downloadQueue();
if (!job2) {
if (release) {
started.current = true;
queue.enqueue(game, release, root2);
queue.enqueue(game, release, root2, { streamMode });
}
} else if (BUSY.includes(job2.state) && job2.state !== "pausing" && job2.state !== "installing") queue.pause(job2.id);
else if (job2.state === "installer-required") queue.install(job2.id);
+85 -1
View File
@@ -87,6 +87,7 @@
"downloadState.file-downloaded": "تم تحميل الملف؛ تفقد صيغته",
"downloadState.finishing": "جاري الإنهاء…",
"downloadState.installed": "تم التثبيت",
"downloadState.streamed": "أُرسل إلى PS5",
"downloadState.installer-required": "تم التنزيل؛ جاهز للتثبيت",
"downloadState.installing": "جارٍ التثبيت",
"downloadState.paused": "مؤقف",
@@ -116,11 +117,33 @@
"downloads.installHint": "أغلق Overdrive لإنهاء: ShadowMount يثبت بمجرد عدم فتح أي لعبة.",
"downloads.installRequested": "تم طلب التثبيت. أغلق Overdrive لإنهاء: ShadowMount يثبت بمجرد عدم فتح أي لعبة.",
"downloads.installed": "تم التثبيت. اللعبة على الشاشة الرئيسية.",
"downloads.streamed": "يقوم PS5 بتنزيله وتثبيته: تابعه في التنزيلات. يمكنك إغلاق Overdrive.",
"download.button.streamed": "على PS5",
"downloads.streamedProxy": "لم يبدأ التنزيل في الخلفية: يمرّره Overdrive إلى PS5. أبقه مفتوحًا حتى ينتهي.",
"downloads.proxyProgress": "عبر Overdrive: {done} من {total} ({percent}%). أبقِ Overdrive مفتوحًا.",
"downloads.proxyDone": "استلم PS5 كل شيء: يكمل التثبيت في التنزيلات.",
"stream.title": "كيف تريد تنزيله؟",
"stream.console.title": "في الخلفية على PS5",
"stream.console.body": "يقوم PS5 بتنزيله. أغلق Overdrive وتابع اللعب.",
"stream.here.title": "في Overdrive",
"stream.here.body": "يُنزَّل هنا مع الإيقاف المؤقت والاستئناف. يبقى Overdrive مفتوحًا.",
"stream.recommended": "مُوصى به",
"stream.faster": "أسرع",
"stream.remember": "تذكّر اختياري",
"stream.rememberDetail": "لكل حزم PKG. يمكنك تغييره في الإعدادات.",
"settings.pkgMode": "تنزيلات PKG",
"settings.pkgModeDetail": "مكان تنزيل لعبة PKG.",
"settings.pkgMode.ask": "اسأل",
"settings.pkgMode.console": "في الخلفية",
"settings.pkgMode.overdrive": "Overdrive",
"downloads.streamedBackground": "يقوم PS5 بتنزيله: تابعه في التنزيلات. يمكنك إغلاق Overdrive.",
"downloads.helperProgress": "على PS5: {done} من {total} ({percent}%). تابعه في التنزيلات.",
"downloads.installedFreed": "تم التثبيت. حُذف ملف PKG: تم تحرير {size}.",
"downloads.notice.complete": "اكتمل التحميل",
"downloads.notice.confirm": "تم العثور على ملف، أكّده في Overdrive",
"downloads.notice.failed": "فشل التحميل",
"downloads.notice.installed": "تم تثبيت اللعبة",
"downloads.notice.streamed": "أُرسل إلى تنزيلات PS5",
"downloads.packagesExtracted": "تم استخراج الحزم إلى {path}. مثبت PKG مطلوب.",
"downloads.panel.activity": "النشاط",
"downloads.panel.average": "متوسط",
@@ -392,6 +415,9 @@
"settings.deletePackagesDetail": "يحرر المساحة بعد أن يثبّت الجهاز اللعبة. أوقفه للاحتفاظ بالملفات.",
"settings.on": "تشغيل",
"settings.off": "إيقاف",
"settings.disk": "قرص التنزيلات",
"settings.diskAuto": "تلقائي",
"settings.diskDetail": "القرص الذي تُحفظ فيه الألعاب الجديدة. يبقى مختارًا للمرات القادمة.",
"settings.downloads": "التنزيلات",
"settings.speedLimit": "حد السرعة",
"settings.speedLimitDetail": "يترك سعة للأجهزة الأخرى. لا تُقيَّد تحديثات التطبيق أبدًا.",
@@ -479,5 +505,63 @@
"versions.recommended": "موصى به · يعمل بشكل أصلي على PS5",
"versions.sources": "{count, plural, zero {لا مصادر} one {# مصدر} two {# مصدران} few {# مصادر} many {# مصدر} other {# مصدر}}",
"versions.thisPage": "هذه الصفحة",
"versions.title": "اختر الإصدار"
"versions.title": "اختر الإصدار",
"settings.group.general": "عام",
"settings.group.downloads": "التنزيلات",
"settings.group.services": "الخدمات المرتبطة",
"settings.group.about": "حول",
"settings.recommended": "موصى به",
"settings.faster": "أسرع",
"settings.unlink": "إلغاء الربط",
"settings.unlinkTitle": "إلغاء ربط {service}؟",
"settings.unlinkBody": "يُحذف مفتاحه من هذا الجهاز، وتتوقف التنزيلات عبره حتى تربطه من جديد.",
"settings.speedUnlimitedDetail": "بأسرع ما يسمح به المصدر",
"settings.speedSide": "لعبة بحجم 50 GB",
"settings.speedSideBest": "حسب سرعة المصدر",
"settings.speedSideNote": "الأوقات عند سرعة المصدر الكاملة. المصدر البطيء يبقى بطيئًا حتى دون حد.",
"settings.minutes": "{n} دقيقة",
"settings.hoursMinutes": "{h} ساعة {m} دقيقة",
"settings.diskSide": "المساحة على الأقراص",
"settings.diskAutoDetail": "أول قرص فيه مساحة للعبة",
"settings.diskSideAuto": "تلقائي يأخذ أول قرص فيه مساحة للعبة، بدءًا من التخزين الداخلي.",
"settings.diskSideNote": "الألعاب الجديدة تذهب هنا. إن كان القرص مفقودًا أو ممتلئًا، تقترح صفحة اللعبة قرصًا آخر.",
"settings.pkgAskDetail": "قبل كل تنزيل، ويمكنه تذكّر الجواب",
"settings.pkgConsoleDetail": "جهاز PS5 ينزّله: أغلق Overdrive واستمر في استخدام الجهاز",
"settings.pkgOverdriveDetail": "يُنزَّل هنا، مع الإيقاف والاستئناف: يبقى Overdrive مفتوحًا",
"settings.pkgSide": "إلى أين تذهب اللعبة",
"settings.pkgSideConsole": "تنزيلات PS5 نفسها",
"settings.pkgSideOverdrive": "صفحة التنزيلات في Overdrive",
"settings.cleanupOffDetail": "يبقى ملف PKG في downloads/packages",
"settings.cleanupSide": "بعد التثبيت",
"settings.cleanupSideOn": "يحرّر المساحة التي أخذها ملف PKG على قرص التنزيلات. ملف PKG الذي يرفضه الجهاز يبقى، لتثبيته يدويًا.",
"settings.cleanupSideOff": "يبقى الملف في downloads/packages: مفيد لإعادة التثبيت أو النسخ، لكنه يشغل مساحة.",
"settings.vibrationSide": "معاينة",
"settings.vibrationSideOn": "اهتزاز بنسبة {level}% عند التنقل بين العناصر.",
"settings.vibrationSideOff": "لا اهتزاز أثناء التنقل.",
"settings.languageSide": "كيف يبدو",
"settings.languageSideNote": "أجزاء التطبيق نفسها تتغير فورًا، دون إعادة تشغيل.",
"settings.languageSideRtl": "لغة تُكتب من اليمين إلى اليسار: تنعكس الواجهة.",
"settings.torboxLead": "ينزّل الروابط والروابط المغناطيسية على خوادم TorBox. يبقى المفتاح على هذا الجهاز.",
"settings.realdebridLead": "ينزّل الروابط والروابط المغناطيسية على خوادم Real-Debrid. يبقى الرمز على هذا الجهاز.",
"settings.premiumizeLead": "ينزّل الروابط والروابط المغناطيسية على خوادم Premiumize. يُضبط بمفتاح API الخاص به.",
"settings.qbittorrentLead": "qBittorrent على شبكة المنزل يجلب التورنت، والجهاز يأخذ الملف جاهزًا.",
"settings.account": "الحساب",
"settings.serviceSideLinked": "يبقى المفتاح على هذا الجهاز: يظل بعد إعادة التشغيل والتحديثات.",
"settings.servicePhone": "امسح الرمز بكاميرا هاتفك ثم وافق على Overdrive. لا حاجة لنسخ أي مفتاح.",
"settings.qbitSideSet": "يبقى العنوان وتسجيل الدخول على هذا الجهاز. يجب أن تكون واجهة WebUI على شبكة الجهاز.",
"settings.version": "الإصدار",
"settings.versionLead": "النسخة العاملة، ومن أين تأتي التحديثات.",
"settings.versionBuild": "النسخة",
"settings.versionBuilt": "تاريخ البناء",
"settings.versionCheck": "البحث عن تحديثات",
"settings.versionChecking": "جارٍ البحث…",
"settings.versionUpToDate": "v{version} هو الأحدث.",
"settings.versionAvailable": "v{version} متاح: سيعرضه Overdrive.",
"settings.versionFailed": "فشل البحث. حاول مرة أخرى بعد قليل.",
"settings.versionNote": "يفحص Overdrive عند كل تشغيل، ويثبّت التحديث من هنا.",
"settings.licenses": "التراخيص",
"settings.licensesValue": "مفتوح المصدر",
"settings.licensesLead": "Overdrive برنامج حر: هذه تراخيص التطبيق والمشاريع التي بُني عليها.",
"settings.licensesSide": "الإشعارات المرفقة",
"settings.licensesEmpty": "الإشعارات غير موجودة في هذه النسخة."
}
+85 -1
View File
@@ -87,6 +87,7 @@
"downloadState.file-downloaded": "ফাইল ডাউনলোড হয়েছে; এর ফর্ম্যাট পরীক্ষা করুন",
"downloadState.finishing": "শেষ করছি…",
"downloadState.installed": "ইনস্টল হয়েছে",
"downloadState.streamed": "PS5-এ পাঠানো হয়েছে",
"downloadState.installer-required": "ডাউনলোড হয়েছে; ইনস্টলের জন্য প্রস্তুত",
"downloadState.installing": "ইনস্টল হচ্ছে",
"downloadState.paused": "থেমে গেছে",
@@ -116,11 +117,33 @@
"downloads.installHint": "Overdrive বন্ধ করুন যাতে এটি শেষ হয়: ShadowMount ইনস্টল করে একবার কোনো গেম খোলা না থাকে।",
"downloads.installRequested": "ইনস্টলের অনুরোধ করা হয়েছে। Overdrive বন্ধ করুন: কোনো গেম খোলা না থাকলে ShadowMount ইনস্টল করে।",
"downloads.installed": "ইনস্টল হয়েছে। এটি হোম স্ক্রিনে আছে।",
"downloads.streamed": "PS5 এটি ডাউনলোড ও ইনস্টল করে: ডাউনলোডে দেখুন। আপনি Overdrive বন্ধ করতে পারেন।",
"download.button.streamed": "PS5-এ",
"downloads.streamedProxy": "ব্যাকগ্রাউন্ড চালু হয়নি: Overdrive এটি PS5-এ দিচ্ছে। শেষ না হওয়া পর্যন্ত খোলা রাখুন।",
"downloads.proxyProgress": "Overdrive-এর মাধ্যমে: {total}-এর মধ্যে {done} ({percent}%)। Overdrive খোলা রাখুন।",
"downloads.proxyDone": "PS5 সব পেয়েছে: ইনস্টল এর ডাউনলোডে শেষ হবে।",
"stream.title": "কীভাবে ডাউনলোড করবেন?",
"stream.console.title": "PS5-এ ব্যাকগ্রাউন্ডে",
"stream.console.body": "PS5 নিজেই ডাউনলোড করবে। Overdrive বন্ধ করে খেলতে থাকুন।",
"stream.here.title": "Overdrive-এ",
"stream.here.body": "এখানেই ডাউনলোড হবে, থামানো ও আবার চালুর সুবিধাসহ। Overdrive খোলা থাকবে।",
"stream.recommended": "প্রস্তাবিত",
"stream.faster": "দ্রুততর",
"stream.remember": "আমার পছন্দ মনে রাখুন",
"stream.rememberDetail": "সব PKG-এর জন্য। সেটিংসে বদলান।",
"settings.pkgMode": "PKG ডাউনলোড",
"settings.pkgModeDetail": "PKG গেম কোথায় ডাউনলোড হবে।",
"settings.pkgMode.ask": "জিজ্ঞাসা করুন",
"settings.pkgMode.console": "ব্যাকগ্রাউন্ড",
"settings.pkgMode.overdrive": "Overdrive",
"downloads.streamedBackground": "PS5 ডাউনলোড করছে: এর ডাউনলোডে দেখুন। আপনি Overdrive বন্ধ করতে পারেন।",
"downloads.helperProgress": "PS5-এ: {total}-এর মধ্যে {done} ({percent}%)। এর ডাউনলোডে দেখুন।",
"downloads.installedFreed": "ইনস্টল হয়েছে। PKG মুছে ফেলা হয়েছে: {size} খালি হয়েছে।",
"downloads.notice.complete": "ডাউনলোড সম্পূর্ণ",
"downloads.notice.confirm": "ফাইল পাওয়া গেছে, Overdrive-এ নিশ্চিত করুন",
"downloads.notice.failed": "ডাউনলোড ব্যর্থ",
"downloads.notice.installed": "গেম ইনস্টল হয়েছে",
"downloads.notice.streamed": "PS5-এর ডাউনলোডে পাঠানো হয়েছে",
"downloads.packagesExtracted": "প্যাকেজ {path} এ বের করা হয়েছে। একটি PKG ইনস্টলার প্রয়োজন।",
"downloads.panel.activity": "কার্যকলাপ",
"downloads.panel.average": "গড়",
@@ -392,6 +415,9 @@
"settings.deletePackagesDetail": "কনসোল গেম ইনস্টল করার পর জায়গা খালি করে। ফাইল রাখতে এটি বন্ধ করুন।",
"settings.on": "চালু",
"settings.off": "বন্ধ",
"settings.disk": "ডাউনলোডের ডিস্ক",
"settings.diskAuto": "স্বয়ংক্রিয়",
"settings.diskDetail": "নতুন গেমগুলো কোন ড্রাইভে সংরক্ষিত হবে। পরের বারও এটি নির্বাচিত থাকবে।",
"settings.downloads": "ডাউনলোড",
"settings.speedLimit": "গতির সীমা",
"settings.speedLimitDetail": "অন্য ডিভাইসের জন্য ব্যান্ডউইথ রাখে। অ্যাপ আপডেট কখনো সীমিত হয় না।",
@@ -479,5 +505,63 @@
"versions.recommended": "সুপারিশকৃত · PS5 এ স্থানীয়ভাবে চলে",
"versions.sources": "{count, plural, one {# উৎস} other {# উৎস}}",
"versions.thisPage": "এই পৃষ্ঠা",
"versions.title": "সংস্করণ বেছে নিন"
"versions.title": "সংস্করণ বেছে নিন",
"settings.group.general": "সাধারণ",
"settings.group.downloads": "ডাউনলোড",
"settings.group.services": "সংযুক্ত সেবা",
"settings.group.about": "সম্পর্কে",
"settings.recommended": "প্রস্তাবিত",
"settings.faster": "দ্রুততর",
"settings.unlink": "সংযোগ বিচ্ছিন্ন করুন",
"settings.unlinkTitle": "{service} সংযোগ বিচ্ছিন্ন করবেন?",
"settings.unlinkBody": "এর কী এই কনসোল থেকে মুছে যাবে, এবং আবার সংযুক্ত না করা পর্যন্ত এর মাধ্যমে ডাউনলোড বন্ধ থাকবে।",
"settings.speedUnlimitedDetail": "উৎস যতটা অনুমতি দেয়",
"settings.speedSide": "50 GB-এর একটি গেম",
"settings.speedSideBest": "উৎস যতটা দেয়",
"settings.speedSideNote": "উৎসের পূর্ণ গতিতে সময়। ধীর উৎস সীমা ছাড়াও ধীরই থাকে।",
"settings.minutes": "{n} মিনিট",
"settings.hoursMinutes": "{h} ঘণ্টা {m} মিনিট",
"settings.diskSide": "ডিস্কে জায়গা",
"settings.diskAutoDetail": "গেমের জায়গা আছে এমন প্রথম ডিস্ক",
"settings.diskSideAuto": "স্বয়ংক্রিয় অভ্যন্তরীণ স্টোরেজ থেকে শুরু করে গেমের জায়গা আছে এমন প্রথম ডিস্ক নেয়।",
"settings.diskSideNote": "নতুন গেম এখানে যায়। ডিস্ক না থাকলে বা ভরে গেলে গেমের পাতা অন্য একটি ডিস্ক দেয়।",
"settings.pkgAskDetail": "প্রতিটি ডাউনলোডের আগে, আর উত্তরটি মনে রাখতেও পারে",
"settings.pkgConsoleDetail": "PS5 এটি ডাউনলোড করে: Overdrive বন্ধ করে কনসোল ব্যবহার চালিয়ে যান",
"settings.pkgOverdriveDetail": "এখানেই ডাউনলোড হয়, বিরতি ও পুনরায় শুরুর সুবিধাসহ: Overdrive খোলা থাকে",
"settings.pkgSide": "গেম কোথায় যায়",
"settings.pkgSideConsole": "PS5-এর নিজের ডাউনলোড",
"settings.pkgSideOverdrive": "Overdrive-এর ডাউনলোড পাতা",
"settings.cleanupOffDetail": "PKG downloads/packages-এ থাকে",
"settings.cleanupSide": "ইনস্টলের পরে",
"settings.cleanupSideOn": "ডাউনলোড ডিস্কে PKG যে জায়গা নিয়েছিল তা খালি করে। কনসোল যে PKG নেয় না, সেটি হাতে ইনস্টল করার জন্য থেকে যায়।",
"settings.cleanupSideOff": "ফাইলটি downloads/packages-এ থাকে: আবার ইনস্টল বা কপি করতে সুবিধা, তবে জায়গা নেয়।",
"settings.vibrationSide": "পূর্বরূপ",
"settings.vibrationSideOn": "আইটেমের মধ্যে চলার সময় {level}% কম্পন।",
"settings.vibrationSideOff": "নেভিগেট করার সময় কোনো কম্পন নেই।",
"settings.languageSide": "কেমন দেখায়",
"settings.languageSideNote": "অ্যাপের নিজস্ব অংশ সাথে সাথেই বদলে যায়, রিস্টার্ট ছাড়াই।",
"settings.languageSideRtl": "ডান থেকে বাঁয়ে লেখা ভাষা: ইন্টারফেস উল্টে যায়।",
"settings.torboxLead": "TorBox-এর সার্ভারে লিংক ও ম্যাগনেট ডাউনলোড করে। কী এই কনসোলেই থাকে।",
"settings.realdebridLead": "Real-Debrid-এর সার্ভারে লিংক ও ম্যাগনেট ডাউনলোড করে। টোকেন এই কনসোলেই থাকে।",
"settings.premiumizeLead": "Premiumize-এর সার্ভারে লিংক ও ম্যাগনেট ডাউনলোড করে। এর API কী দিয়ে সেট করা হয়।",
"settings.qbittorrentLead": "বাড়ির নেটওয়ার্কে আপনার qBittorrent টরেন্ট নামায়; কনসোল তৈরি ফাইলটি নেয়।",
"settings.account": "অ্যাকাউন্ট",
"settings.serviceSideLinked": "কী এই কনসোলেই থাকে: রিস্টার্ট ও আপডেটের পরও টিকে থাকে।",
"settings.servicePhone": "ফোনের ক্যামেরায় কোড স্ক্যান করুন, তারপর Overdrive অনুমোদন করুন। কোনো কী কপি করতে হবে না।",
"settings.qbitSideSet": "ঠিকানা ও লগইন এই কনসোলেই থাকে। WebUI-কে কনসোলের নেটওয়ার্কে থাকতে হবে।",
"settings.version": "সংস্করণ",
"settings.versionLead": "চলমান বিল্ড, এবং আপডেট কোথা থেকে আসে।",
"settings.versionBuild": "বিল্ড",
"settings.versionBuilt": "তৈরি",
"settings.versionCheck": "আপডেট দেখুন",
"settings.versionChecking": "দেখা হচ্ছে…",
"settings.versionUpToDate": "v{version} সর্বশেষ।",
"settings.versionAvailable": "v{version} পাওয়া যাচ্ছে: Overdrive এটি দেখাবে।",
"settings.versionFailed": "দেখা যায়নি। একটু পরে আবার চেষ্টা করুন।",
"settings.versionNote": "Overdrive প্রতিবার চালু হলে দেখে এবং এখান থেকেই আপডেট ইনস্টল করে।",
"settings.licenses": "লাইসেন্স",
"settings.licensesValue": "ওপেন সোর্স",
"settings.licensesLead": "Overdrive মুক্ত সফটওয়্যার: এগুলো অ্যাপ ও এর ভিত্তি প্রজেক্টগুলোর লাইসেন্স।",
"settings.licensesSide": "অন্তর্ভুক্ত নোটিশ",
"settings.licensesEmpty": "এই বিল্ডে নোটিশগুলো নেই।"
}
+85 -1
View File
@@ -87,6 +87,7 @@
"downloadState.file-downloaded": "Datei heruntergeladen; Format überprüfen",
"downloadState.finishing": "Wird abgeschlossen…",
"downloadState.installed": "Installiert",
"downloadState.streamed": "An die PS5 gesendet",
"downloadState.installer-required": "Heruntergeladen; bereit zur Installation",
"downloadState.installing": "Wird installiert",
"downloadState.paused": "Pausiert",
@@ -116,11 +117,33 @@
"downloads.installHint": "Schließen Sie Overdrive, um zu beenden: ShadowMount installiert, sobald kein Spiel offen ist.",
"downloads.installRequested": "Installation angefordert. Schließen Sie Overdrive, um zu beenden: ShadowMount installiert, sobald kein Spiel offen ist.",
"downloads.installed": "Installiert. Es ist auf dem Startbildschirm.",
"downloads.streamed": "Die PS5 lädt und installiert es: Verfolge es unter Downloads. Du kannst Overdrive schließen.",
"download.button.streamed": "Auf der PS5",
"downloads.streamedProxy": "Hintergrund nicht gestartet: Overdrive leitet es an die PS5. Lass es bis zum Ende offen.",
"downloads.proxyProgress": "Über Overdrive: {done} von {total} ({percent}%). Lass Overdrive offen.",
"downloads.proxyDone": "Die PS5 hat alles: Sie installiert den Rest unter Downloads.",
"stream.title": "Wie soll es laden?",
"stream.console.title": "Im Hintergrund auf der PS5",
"stream.console.body": "Die PS5 lädt es selbst. Schließe Overdrive und spiel weiter.",
"stream.here.title": "In Overdrive",
"stream.here.body": "Lädt hier, mit Pause und Fortsetzen. Overdrive bleibt offen.",
"stream.recommended": "Empfohlen",
"stream.faster": "Schneller",
"stream.remember": "Auswahl merken",
"stream.rememberDetail": "Für jedes PKG. Änderbar in den Einstellungen.",
"settings.pkgMode": "PKG-Downloads",
"settings.pkgModeDetail": "Wo ein Spiel als PKG lädt.",
"settings.pkgMode.ask": "Fragen",
"settings.pkgMode.console": "Hintergrund",
"settings.pkgMode.overdrive": "Overdrive",
"downloads.streamedBackground": "Die PS5 lädt es: Verfolge es unter Downloads. Du kannst Overdrive schließen.",
"downloads.helperProgress": "Auf der PS5: {done} von {total} ({percent}%). Verfolge es unter Downloads.",
"downloads.installedFreed": "Installiert. Die PKG wurde gelöscht: {size} frei.",
"downloads.notice.complete": "Download abgeschlossen",
"downloads.notice.confirm": "Datei gefunden, bestätige sie in Overdrive",
"downloads.notice.failed": "Download fehlgeschlagen",
"downloads.notice.installed": "Spiel installiert",
"downloads.notice.streamed": "An die PS5-Downloads gesendet",
"downloads.packagesExtracted": "Pakete zu {path} extrahiert. PKG-Installationstool erforderlich.",
"downloads.panel.activity": "AKTIVITÄT",
"downloads.panel.average": "Durchschnitt",
@@ -392,6 +415,9 @@
"settings.deletePackagesDetail": "Gibt den Speicher frei, sobald die Konsole das Spiel installiert hat. Schalte es aus, um die Dateien zu behalten.",
"settings.on": "An",
"settings.off": "Aus",
"settings.disk": "Laufwerk für Downloads",
"settings.diskAuto": "Automatisch",
"settings.diskDetail": "Auf welchem Laufwerk neue Spiele gespeichert werden. Es bleibt für die nächsten Spiele gewählt.",
"settings.downloads": "Downloads",
"settings.speedLimit": "Tempolimit",
"settings.speedLimitDetail": "Lässt Bandbreite für andere Geräte. Updates sind nie begrenzt.",
@@ -479,5 +505,63 @@
"versions.recommended": "Empfohlen · läuft nativ auf PS5",
"versions.sources": "{count, plural, one {# Quelle} other {# Quellen}}",
"versions.thisPage": "Diese Seite",
"versions.title": "Version wählen"
"versions.title": "Version wählen",
"settings.group.general": "Allgemein",
"settings.group.downloads": "Downloads",
"settings.group.services": "Verknüpfte Dienste",
"settings.group.about": "Info",
"settings.recommended": "Empfohlen",
"settings.faster": "Schneller",
"settings.unlink": "Trennen",
"settings.unlinkTitle": "{service} trennen?",
"settings.unlinkBody": "Der Schlüssel wird von dieser Konsole gelöscht, und Downloads darüber stoppen, bis du den Dienst erneut verknüpfst.",
"settings.speedUnlimitedDetail": "So schnell, wie die Quelle es zulässt",
"settings.speedSide": "Ein Spiel mit 50 GB",
"settings.speedSideBest": "so schnell wie die Quelle",
"settings.speedSideNote": "Zeiten bei voller Geschwindigkeit der Quelle. Langsamere Quellen bleiben auch ohne Limit langsam.",
"settings.minutes": "{n} Min.",
"settings.hoursMinutes": "{h} Std. {m} Min.",
"settings.diskSide": "Platz auf den Laufwerken",
"settings.diskAutoDetail": "Das erste Laufwerk mit Platz für das Spiel",
"settings.diskSideAuto": "Automatisch nimmt das erste Laufwerk mit Platz für das Spiel, beginnend mit dem internen Speicher.",
"settings.diskSideNote": "Neue Spiele landen hier. Fehlt das Laufwerk oder ist es voll, bietet die Spielseite ein anderes an.",
"settings.pkgAskDetail": "Vor jedem Download, und es kann sich die Antwort merken",
"settings.pkgConsoleDetail": "Die PS5 lädt sie herunter: schließe Overdrive und nutze die Konsole weiter",
"settings.pkgOverdriveDetail": "Sie wird hier geladen, mit Pause und Fortsetzen: Overdrive bleibt offen",
"settings.pkgSide": "Wohin das Spiel geht",
"settings.pkgSideConsole": "Die Downloads der PS5",
"settings.pkgSideOverdrive": "Overdrives Download-Seite",
"settings.cleanupOffDetail": "Das PKG bleibt in downloads/packages",
"settings.cleanupSide": "Nach der Installation",
"settings.cleanupSideOn": "Gibt den Platz frei, den das PKG auf dem Download-Laufwerk belegt hat. Ein PKG, das die Konsole ablehnt, bleibt zum manuellen Installieren.",
"settings.cleanupSideOff": "Die Datei bleibt in downloads/packages: praktisch zum Neuinstallieren oder Kopieren, kostet aber Platz.",
"settings.vibrationSide": "Vorschau",
"settings.vibrationSideOn": "Ein Impuls mit {level} %, wenn du zwischen den Einträgen wechselst.",
"settings.vibrationSideOff": "Beim Navigieren pulsiert nichts.",
"settings.languageSide": "So sieht es aus",
"settings.languageSideNote": "Die eigenen Teile der App ändern sich sofort, ohne Neustart.",
"settings.languageSideRtl": "Eine Sprache von rechts nach links: die Oberfläche spiegelt sich.",
"settings.torboxLead": "Lädt Links und Magnete auf TorBox' Servern. Der Schlüssel bleibt auf dieser Konsole.",
"settings.realdebridLead": "Lädt Links und Magnete auf Real-Debrids Servern. Das Token bleibt auf dieser Konsole.",
"settings.premiumizeLead": "Lädt Links und Magnete auf Premiumizes Servern. Eingerichtet wird es mit dem API-Schlüssel.",
"settings.qbittorrentLead": "Dein qBittorrent im Heimnetz holt die Torrents; die Konsole nimmt die fertige Datei.",
"settings.account": "Konto",
"settings.serviceSideLinked": "Der Schlüssel bleibt auf dieser Konsole: er übersteht Neustarts und Updates.",
"settings.servicePhone": "Scanne den Code mit der Handykamera und bestätige Overdrive. Kein Schlüssel zum Kopieren.",
"settings.qbitSideSet": "Adresse und Zugang bleiben auf dieser Konsole. Die WebUI muss im Netz der Konsole sein.",
"settings.version": "Version",
"settings.versionLead": "Der laufende Build und woher die Updates kommen.",
"settings.versionBuild": "Build",
"settings.versionBuilt": "Gebaut",
"settings.versionCheck": "Nach Updates suchen",
"settings.versionChecking": "Prüfe…",
"settings.versionUpToDate": "v{version} ist die neueste Version.",
"settings.versionAvailable": "v{version} ist verfügbar: Overdrive wird sie anbieten.",
"settings.versionFailed": "Die Prüfung ist fehlgeschlagen. Versuche es gleich noch einmal.",
"settings.versionNote": "Overdrive prüft bei jedem Start und installiert das Update von hier.",
"settings.licenses": "Lizenzen",
"settings.licensesValue": "Open Source",
"settings.licensesLead": "Overdrive ist freie Software: das sind die Lizenzen der App und der Projekte, auf denen sie aufbaut.",
"settings.licensesSide": "Enthaltene Hinweise",
"settings.licensesEmpty": "Die Hinweise sind in diesem Build nicht enthalten."
}
+153 -1
View File
@@ -1,4 +1,69 @@
{
"add.address": "qBittorrent address",
"add.cancel": "Cancel",
"add.close": "Close",
"add.configuredBody": "Its settings are saved on this console. Change them, or go on.",
"add.configuredTitle": "{service} is already set up",
"add.continue": "Continue",
"add.debrid": "Debrid services",
"add.debridBody": "A link or a magnet. {service} fetches it on its servers, and the console downloads the file from there.",
"add.debridDetail": "TorBox, Real-Debrid, Premiumize",
"add.field.address": "qBittorrent address",
"add.field.password": "qBittorrent password",
"add.field.premiumize": "Premiumize API key",
"add.field.realdebrid": "Real-Debrid API token",
"add.field.torbox": "TorBox API key",
"add.field.user": "qBittorrent user",
"add.fieldBody.address": "The WebUI's address as host:port, for example 192.168.1.10:8080.",
"add.fieldBody.password": "The WebUI's password. Leave it empty when the WebUI asks for none.",
"add.fieldBody.premiumize": "Your own key, from premiumize.me under Account then API. Each user enters their own.",
"add.fieldBody.realdebrid": "Your own token, from real-debrid.com under My account then API. Each user enters their own.",
"add.fieldBody.torbox": "Your own key, from torbox.app under Settings then API. Each user enters their own.",
"add.fieldBody.user": "The WebUI's user name.",
"add.link": "From a link",
"add.linkBody": "A file's own address, or a page of a host Overdrive already handles (VikingFile, DataNodes, FileKeeper). It downloads like a catalog file.",
"add.linkDetail": "An address you already have",
"add.linkTitle": "Paste a link",
"add.magnetOnly": "That is not a magnet link or a .torrent link. Paste one or the other.",
"add.modify": "Change",
"add.notSet": "Not set",
"add.password": "Password",
"add.percent": "{percent}%",
"add.phone.title": "Link it with your phone",
"add.phone.step1": "1. Scan the code with your phone's camera.",
"add.phone.step2": "2. Sign in to {service} and approve Overdrive. This page carries on by itself.",
"add.phone.manual": "No camera? Open {page} and enter",
"add.phone.starting": "Asking {service} for a code…",
"add.phone.waiting": "Waiting for you to approve it on the phone…",
"add.phone.expires": "New code in {time}",
"add.phone.linked": "Linked",
"add.phone.linkedBody": "Overdrive can use the account now.",
"add.phone.until": "until {date}",
"add.phone.failed": "No code right now. Check the console's connection, then Retry, or enter the key by hand.",
"add.phone.retry": "Retry",
"add.phone.or": "Or enter the key by hand, as before:",
"add.serviceLink": "Link with your phone",
"add.premiumizeName": "Premiumize",
"add.qbittorrentName": "qBittorrent",
"add.realdebridName": "Real-Debrid",
"add.serviceNeeds": "Needs its key",
"add.serviceReady": "Configured",
"add.servicesBody": "Pick the service to fetch the file. Each has its own account and key, entered once on this console.",
"add.servicesTitle": "Debrid services",
"add.setupBody": "Entered once on this console. Nothing is sent anywhere else, and every user has their own.",
"add.setupClientHint": "No qBittorrent? A debrid service takes magnets too: send one from its card.",
"add.setupTitle": "Set up {service}",
"add.title": "ADD A DOWNLOAD",
"add.torboxName": "TorBox",
"add.torrent": "From a torrent",
"add.torrentBody": "Paste a magnet, or a link ending in .torrent. Your qBittorrent fetches it, and the console downloads the finished file from there.",
"add.torrentDetail": "A magnet or a .torrent link, through qBittorrent",
"add.torrentLinkNeedsClient": "A .torrent link needs qBittorrent: the debrid services take magnets only.",
"add.torrentSetup": "Needs qBittorrent",
"add.torrentTitle": "From a torrent",
"add.user": "User",
"add.waitDetail": "{service} is fetching the file. The download starts here as soon as it is ready.",
"add.waitTitle": "Waiting for {service}",
"browse.addToFavorites": "Add to favorites",
"browse.continueDownloading": "Continue downloading",
"browse.continueFailed": "Failed \u00B7 open to retry",
@@ -87,6 +152,7 @@
"downloadState.file-downloaded": "File downloaded; inspect its format",
"downloadState.finishing": "Finishing up\u2026",
"downloadState.installed": "Installed",
"downloadState.streamed": "Sent to the PS5",
"downloadState.installer-required": "Downloaded; ready to install",
"downloadState.installing": "Installing",
"downloadState.paused": "Paused",
@@ -118,12 +184,34 @@
"downloads.titleMismatch": "The files are {found}, the game is {expected}: another game, or another region?",
"downloads.installRequested": "Install requested. Close Overdrive to finish: ShadowMount installs once no game is open.",
"downloads.installed": "Installed. It's on the home screen.",
"downloads.streamed": "The PS5 downloads and installs it: follow it in its Downloads. You can close Overdrive.",
"download.button.streamed": "On the PS5",
"downloads.streamedProxy": "Background didn't start: Overdrive passes it to the PS5. Keep it open until it's done.",
"stream.title": "How should it download?",
"stream.console.title": "In the background on PS5",
"stream.console.body": "Your PS5 downloads it. Close Overdrive and keep playing.",
"stream.here.title": "In Overdrive",
"stream.here.body": "Downloads here, with pause and resume. Keep Overdrive open.",
"stream.recommended": "Recommended",
"stream.faster": "Faster",
"stream.remember": "Remember my choice",
"stream.rememberDetail": "For every PKG. Change it in Settings.",
"settings.pkgMode": "PKG downloads",
"settings.pkgModeDetail": "Where a game in PKG downloads.",
"settings.pkgMode.ask": "Ask",
"settings.pkgMode.console": "Background",
"settings.pkgMode.overdrive": "Overdrive",
"downloads.streamedBackground": "Your PS5 is downloading it: follow it in its Downloads. You can close Overdrive.",
"downloads.helperProgress": "On the PS5: {done} of {total} ({percent}%). Follow it in its Downloads.",
"downloads.proxyProgress": "Through Overdrive: {done} of {total} ({percent}%). Keep Overdrive open.",
"downloads.proxyDone": "The PS5 has it all: it finishes installing in its Downloads.",
"downloads.installedFreed": "Installed. The PKG was deleted: {size} freed.",
"downloads.notice.captured": "Link captured, return to Overdrive",
"downloads.notice.confirm": "File found, confirm it in Overdrive",
"downloads.notice.complete": "Download complete",
"downloads.notice.failed": "Download failed",
"downloads.notice.installed": "Game installed",
"downloads.notice.streamed": "Sent to the PS5's downloads",
"downloads.packagesExtracted": "Packages extracted to {path}. A PKG installer is required.",
"downloads.panel.activity": "ACTIVITY",
"downloads.panel.average": "Average",
@@ -157,6 +245,9 @@
"downloads.writingToDisk": "Writing to disk\u2026",
"drives.extended": "Extended storage {number}",
"drives.internal": "Internal storage",
"drives.short.extended": "Extended {number}",
"drives.short.internal": "Internal",
"drives.short.usb": "USB {number}",
"drives.thisDevice": "This device",
"drives.usb": "USB drive {number}",
"errors.action.checkConnection": "Check the console's internet connection, then Retry.",
@@ -401,6 +492,9 @@
"settings.deletePackagesDetail": "Frees the space once the console has installed the game. Turn it off to keep the files.",
"settings.on": "On",
"settings.off": "Off",
"settings.disk": "Disk for downloads",
"settings.diskAuto": "Automatic",
"settings.diskDetail": "The drive new games are saved to. It stays chosen for the games after them.",
"settings.downloads": "Downloads",
"settings.speedLimit": "Speed limit",
"settings.speedLimitDetail": "Leaves bandwidth for other devices. App updates are never limited.",
@@ -511,5 +605,63 @@
"capture.confirm.detail": "The host doesn't say which game this file is. Download it only if it's the game you chose.",
"capture.confirm.noName": "File with no name",
"capture.confirm.accept": "Download it",
"capture.confirm.refuse": "Keep looking"
"capture.confirm.refuse": "Keep looking",
"settings.group.general": "General",
"settings.group.downloads": "Downloads",
"settings.group.services": "Linked services",
"settings.group.about": "About",
"settings.recommended": "Recommended",
"settings.faster": "Faster",
"settings.unlink": "Unlink",
"settings.unlinkTitle": "Unlink {service}?",
"settings.unlinkBody": "Its key is deleted from this console, and downloads through it stop until you link it again.",
"settings.speedUnlimitedDetail": "As fast as the source allows",
"settings.speedSide": "A 50 GB game",
"settings.speedSideBest": "as fast as the source allows",
"settings.speedSideNote": "Times with the source at full speed. A slower source stays slow even without a limit.",
"settings.minutes": "{n} min",
"settings.hoursMinutes": "{h} h {m} min",
"settings.diskSide": "Space on the drives",
"settings.diskAutoDetail": "The first drive with room for the game",
"settings.diskSideAuto": "Automatic takes the first drive with room for the game, starting with internal storage.",
"settings.diskSideNote": "New games go here. If the drive is missing or full, the game's page offers another.",
"settings.pkgAskDetail": "Before each download, and it can remember the answer",
"settings.pkgConsoleDetail": "The PS5 downloads it: close Overdrive and keep using the console",
"settings.pkgOverdriveDetail": "It downloads here, with pause and resume: Overdrive stays open",
"settings.pkgSide": "Where the game goes",
"settings.pkgSideConsole": "The PS5's own Downloads",
"settings.pkgSideOverdrive": "Overdrive's Downloads page",
"settings.cleanupOffDetail": "The PKG stays in downloads/packages",
"settings.cleanupSide": "After installing",
"settings.cleanupSideOn": "Frees the space the PKG took on the download drive. A PKG the console refuses stays, to install by hand.",
"settings.cleanupSideOff": "The file stays in downloads/packages: useful to reinstall or copy, but it takes space.",
"settings.vibrationSide": "Preview",
"settings.vibrationSideOn": "A pulse at {level}% as you move between items.",
"settings.vibrationSideOff": "Nothing pulses while browsing.",
"settings.languageSide": "How it looks",
"settings.languageSideNote": "The app's own parts change at once, without restarting.",
"settings.languageSideRtl": "A right-to-left language: the interface mirrors.",
"settings.torboxLead": "Downloads links and magnets on TorBox's servers. The key stays on this console.",
"settings.realdebridLead": "Downloads links and magnets on Real-Debrid's servers. The token stays on this console.",
"settings.premiumizeLead": "Downloads links and magnets on Premiumize's servers. It is set up with its API key.",
"settings.qbittorrentLead": "Your qBittorrent, on the home network, fetches the torrents; the console takes the finished file.",
"settings.account": "Account",
"settings.serviceSideLinked": "The key stays on this console: it survives restarts and updates.",
"settings.servicePhone": "Scan the code with your phone's camera, then approve Overdrive. No key to copy.",
"settings.qbitSideSet": "The address and login stay on this console. The WebUI must be on the console's network.",
"settings.version": "Version",
"settings.versionLead": "The build that's running, and where updates come from.",
"settings.versionBuild": "Build",
"settings.versionBuilt": "Built",
"settings.versionCheck": "Check for updates",
"settings.versionChecking": "Checking…",
"settings.versionUpToDate": "v{version} is the latest.",
"settings.versionAvailable": "v{version} is available: Overdrive will offer it.",
"settings.versionFailed": "The check failed. Try again in a moment.",
"settings.versionNote": "Overdrive checks at every launch, and installs the update from here.",
"settings.licenses": "Licenses",
"settings.licensesValue": "Open source",
"settings.licensesLead": "Overdrive is free software: these are the licenses of the app and of the projects it builds on.",
"settings.licensesSide": "Included notices",
"settings.licensesEmpty": "The notices aren't in this build."
}
+85 -1
View File
@@ -87,6 +87,7 @@
"downloadState.file-downloaded": "Archivo descargado; inspecciona su formato",
"downloadState.finishing": "Finalizando…",
"downloadState.installed": "Instalado",
"downloadState.streamed": "Enviado a la PS5",
"downloadState.installer-required": "Descargado; listo para instalar",
"downloadState.installing": "Instalando",
"downloadState.paused": "Pausado",
@@ -116,11 +117,33 @@
"downloads.installHint": "Cierra Overdrive para terminar: ShadowMount instala cuando ningún juego está abierto.",
"downloads.installRequested": "Instalación solicitada. Cierra Overdrive para terminar: ShadowMount instala cuando ningún juego está abierto.",
"downloads.installed": "Instalado. Ya está en la pantalla de inicio.",
"downloads.streamed": "La PS5 lo descarga e instala: síguelo en sus Descargas. Puedes cerrar Overdrive.",
"download.button.streamed": "En la PS5",
"downloads.streamedProxy": "El segundo plano no arrancó: Overdrive se lo pasa a la PS5. Déjalo abierto hasta que termine.",
"downloads.proxyProgress": "Por Overdrive: {done} de {total} ({percent}%). Deja Overdrive abierto.",
"downloads.proxyDone": "La PS5 ya lo tiene todo: termina de instalarlo en sus Descargas.",
"stream.title": "¿Cómo quieres descargarlo?",
"stream.console.title": "En segundo plano en la PS5",
"stream.console.body": "Lo descarga la PS5. Cierra Overdrive y sigue jugando.",
"stream.here.title": "En Overdrive",
"stream.here.body": "Se descarga aquí, con pausa y reanudación. Overdrive sigue abierto.",
"stream.recommended": "Recomendado",
"stream.faster": "Más rápido",
"stream.remember": "Recordar mi elección",
"stream.rememberDetail": "Para todos los PKG. Cámbialo en Ajustes.",
"settings.pkgMode": "Descargas de PKG",
"settings.pkgModeDetail": "Dónde se descarga un juego en PKG.",
"settings.pkgMode.ask": "Preguntar",
"settings.pkgMode.console": "Segundo plano",
"settings.pkgMode.overdrive": "Overdrive",
"downloads.streamedBackground": "La PS5 lo está descargando: síguelo en sus Descargas. Puedes cerrar Overdrive.",
"downloads.helperProgress": "En la PS5: {done} de {total} ({percent}%). Síguelo en sus Descargas.",
"downloads.installedFreed": "Instalado. Se eliminó el PKG: {size} liberados.",
"downloads.notice.complete": "Descarga completada",
"downloads.notice.confirm": "Archivo encontrado, confírmalo en Overdrive",
"downloads.notice.failed": "Descarga fallida",
"downloads.notice.installed": "Juego instalado",
"downloads.notice.streamed": "Enviado a las descargas de la PS5",
"downloads.packagesExtracted": "Paquetes extraídos a {path}. Se requiere un instalador PKG.",
"downloads.panel.activity": "ACTIVIDAD",
"downloads.panel.average": "Promedio",
@@ -392,6 +415,9 @@
"settings.deletePackagesDetail": "Libera el espacio cuando la consola ha instalado el juego. Desactívalo para conservar los archivos.",
"settings.on": "Activado",
"settings.off": "Apagada",
"settings.disk": "Disco para descargas",
"settings.diskAuto": "Automático",
"settings.diskDetail": "La unidad donde se guardan los juegos nuevos. Sigue elegida para los siguientes.",
"settings.downloads": "Descargas",
"settings.speedLimit": "Límite de velocidad",
"settings.speedLimitDetail": "Deja ancho de banda a otros dispositivos. Las actualizaciones de la app nunca se limitan.",
@@ -479,5 +505,63 @@
"versions.recommended": "Recomendado · se ejecuta de forma nativa en PS5",
"versions.sources": "{count, plural, one {# fuente} many {# millones de fuentes} other {# fuentes}}",
"versions.thisPage": "Esta página",
"versions.title": "Elegir versión"
"versions.title": "Elegir versión",
"settings.group.general": "General",
"settings.group.downloads": "Descargas",
"settings.group.services": "Servicios vinculados",
"settings.group.about": "Información",
"settings.recommended": "Recomendado",
"settings.faster": "Más rápido",
"settings.unlink": "Desvincular",
"settings.unlinkTitle": "¿Desvincular {service}?",
"settings.unlinkBody": "Se borra su clave de esta consola y las descargas a través de él se detienen hasta que lo vincules de nuevo.",
"settings.speedUnlimitedDetail": "Tan rápido como permita la fuente",
"settings.speedSide": "Un juego de 50 GB",
"settings.speedSideBest": "lo que aguante la fuente",
"settings.speedSideNote": "Tiempos con la fuente a plena velocidad. Una fuente lenta sigue lenta aunque no haya límite.",
"settings.minutes": "{n} min",
"settings.hoursMinutes": "{h} h {m} min",
"settings.diskSide": "Espacio en los discos",
"settings.diskAutoDetail": "El primer disco con espacio para el juego",
"settings.diskSideAuto": "Automático toma el primer disco con espacio para el juego, empezando por el almacenamiento interno.",
"settings.diskSideNote": "Los juegos nuevos van aquí. Si el disco falta o está lleno, la página del juego ofrece otro.",
"settings.pkgAskDetail": "Antes de cada descarga, y puede recordar la respuesta",
"settings.pkgConsoleDetail": "La descarga la PS5: cierra Overdrive y sigue usando la consola",
"settings.pkgOverdriveDetail": "Se descarga aquí, con pausa y reanudación: Overdrive sigue abierto",
"settings.pkgSide": "Adónde va el juego",
"settings.pkgSideConsole": "Las Descargas de la PS5",
"settings.pkgSideOverdrive": "La página Descargas de Overdrive",
"settings.cleanupOffDetail": "El PKG se queda en downloads/packages",
"settings.cleanupSide": "Tras instalar",
"settings.cleanupSideOn": "Libera el espacio que ocupaba el PKG en el disco de descargas. Un PKG que la consola rechaza se queda, para instalarlo a mano.",
"settings.cleanupSideOff": "El archivo se queda en downloads/packages: útil para reinstalar o copiar, pero ocupa espacio.",
"settings.vibrationSide": "Vista previa",
"settings.vibrationSideOn": "Un impulso al {level}% al moverte entre las opciones.",
"settings.vibrationSideOff": "Nada vibra al navegar.",
"settings.languageSide": "Así se ve",
"settings.languageSideNote": "Las partes de la app cambian al momento, sin reiniciar.",
"settings.languageSideRtl": "Idioma de derecha a izquierda: la interfaz se refleja.",
"settings.torboxLead": "Descarga enlaces e imanes en los servidores de TorBox. La clave se queda en esta consola.",
"settings.realdebridLead": "Descarga enlaces e imanes en los servidores de Real-Debrid. El token se queda en esta consola.",
"settings.premiumizeLead": "Descarga enlaces e imanes en los servidores de Premiumize. Se configura con su clave API.",
"settings.qbittorrentLead": "Tu qBittorrent, en la red de casa, baja los torrents; la consola toma el archivo terminado.",
"settings.account": "Cuenta",
"settings.serviceSideLinked": "La clave se queda en esta consola: sobrevive a reinicios y actualizaciones.",
"settings.servicePhone": "Escanea el código con la cámara del móvil y aprueba Overdrive. Sin claves que copiar.",
"settings.qbitSideSet": "La dirección y el acceso se quedan en esta consola. La WebUI debe estar en la red de la consola.",
"settings.version": "Versión",
"settings.versionLead": "La compilación en marcha y de dónde llegan las actualizaciones.",
"settings.versionBuild": "Compilación",
"settings.versionBuilt": "Compilada",
"settings.versionCheck": "Buscar actualizaciones",
"settings.versionChecking": "Comprobando…",
"settings.versionUpToDate": "v{version} es la última.",
"settings.versionAvailable": "v{version} está disponible: Overdrive la ofrecerá.",
"settings.versionFailed": "La comprobación falló. Inténtalo de nuevo en un momento.",
"settings.versionNote": "Overdrive comprueba en cada inicio e instala la actualización desde aquí.",
"settings.licenses": "Licencias",
"settings.licensesValue": "Código abierto",
"settings.licensesLead": "Overdrive es software libre: estas son las licencias de la app y de los proyectos en los que se basa.",
"settings.licensesSide": "Avisos incluidos",
"settings.licensesEmpty": "Los avisos no están en esta compilación."
}
+85 -1
View File
@@ -87,6 +87,7 @@
"downloadState.file-downloaded": "فایل دانلود شد؛ فرمتش را بررسی کنید",
"downloadState.finishing": "در حال اتمام…",
"downloadState.installed": "نصب شد",
"downloadState.streamed": "به PS5 فرستاده شد",
"downloadState.installer-required": "PKG را نصب کنید",
"downloadState.installing": "در حال نصب…",
"downloadState.paused": "متوقف‌شده",
@@ -116,12 +117,34 @@
"downloads.installHint": "Overdrive را ببندید تا تمام شود: ShadowMount وقتی هیچ بازی باز نباشد نصب می‌کند.",
"downloads.installRequested": "نصب درخواست شد. Overdrive را ببندید تا تمام شود: ShadowMount وقتی هیچ بازی باز نباشد نصب می‌کند.",
"downloads.installed": "نصب شد. روی صفحهٔ اصلی است.",
"downloads.streamed": "PS5 آن را دانلود و نصب می‌کند: در بخش دانلودها دنبالش کنید. می‌توانید Overdrive را ببندید.",
"download.button.streamed": "روی PS5",
"downloads.streamedProxy": "دانلود پس‌زمینه شروع نشد: Overdrive آن را به PS5 می‌دهد. تا پایان باز بماند.",
"downloads.proxyProgress": "از طریق Overdrive: {done} از {total} ({percent}%). Overdrive را باز نگه دارید.",
"downloads.proxyDone": "PS5 همه را دریافت کرد: نصب در بخش دانلودها تمام می‌شود.",
"stream.title": "چطور دانلود شود؟",
"stream.console.title": "در پس‌زمینه روی PS5",
"stream.console.body": "PS5 خودش دانلود می‌کند. Overdrive را ببندید و بازی کنید.",
"stream.here.title": "در Overdrive",
"stream.here.body": "همین‌جا دانلود می‌شود، با توقف و ادامه. Overdrive باز می‌ماند.",
"stream.recommended": "پیشنهادی",
"stream.faster": "سریع‌تر",
"stream.remember": "انتخابم را به خاطر بسپار",
"stream.rememberDetail": "برای همه PKGها. در تنظیمات قابل تغییر است.",
"settings.pkgMode": "دانلود PKG",
"settings.pkgModeDetail": "محل دانلود بازی PKG.",
"settings.pkgMode.ask": "بپرس",
"settings.pkgMode.console": "پس‌زمینه",
"settings.pkgMode.overdrive": "Overdrive",
"downloads.streamedBackground": "PS5 در حال دانلود است: در بخش دانلودها دنبالش کنید. می‌توانید Overdrive را ببندید.",
"downloads.helperProgress": "روی PS5: {done} از {total} ({percent}%). در بخش دانلودها دنبالش کنید.",
"downloads.installedFreed": "نصب شد. فایل PKG حذف شد: {size} آزاد شد.",
"downloads.notice.captured": "لینک گرفته شد، به Overdrive برگردید",
"downloads.notice.confirm": "فایل پیدا شد، آن را در Overdrive تأیید کنید",
"downloads.notice.complete": "دانلود کامل شد",
"downloads.notice.failed": "دانلود ناموفق بود",
"downloads.notice.installed": "بازی نصب شد",
"downloads.notice.streamed": "به دانلودهای PS5 فرستاده شد",
"downloads.packagesExtracted": "پکیج‌ها به {path} استخراج شدند. نصب‌کنندهٔ PKG لازم است.",
"downloads.panel.activity": "فعالیت",
"downloads.panel.average": "میانگین",
@@ -393,6 +416,9 @@
"settings.deletePackagesDetail": "پس از نصب بازی توسط کنسول، فضا را آزاد می‌کند. برای نگه داشتن فایل‌ها خاموشش کنید.",
"settings.on": "روشن",
"settings.off": "خاموش",
"settings.disk": "دیسک دانلودها",
"settings.diskAuto": "خودکار",
"settings.diskDetail": "درایوی که بازی‌های جدید در آن ذخیره می‌شوند. برای دفعات بعدی نیز انتخاب‌شده می‌ماند.",
"settings.downloads": "دانلودها",
"settings.speedLimit": "محدودیت سرعت",
"settings.speedLimitDetail": "پهنای باند را برای دستگاه‌های دیگر نگه می‌دارد. به‌روزرسانی‌های برنامه هرگز محدود نمی‌شوند.",
@@ -496,5 +522,63 @@
"capture.preparing": "آماده‌سازی تأیید فایل…",
"capture.capturing": "تأیید را کامل کنید، سپس صبر کنید",
"capture.validating": "در حال بررسی فایل ضبط‌شده…",
"capture.detail": "روی Download یا هیچ تبلیغی نزنید: لینک خودش گرفته می‌شود. سپس به اینجا برگردید تا دانلود خودکار شروع شود. گرفتن لینک تا ۳ دقیقه طول می‌کشد."
"capture.detail": "روی Download یا هیچ تبلیغی نزنید: لینک خودش گرفته می‌شود. سپس به اینجا برگردید تا دانلود خودکار شروع شود. گرفتن لینک تا ۳ دقیقه طول می‌کشد.",
"settings.group.general": "عمومی",
"settings.group.downloads": "دانلودها",
"settings.group.services": "سرویس‌های متصل",
"settings.group.about": "درباره",
"settings.recommended": "پیشنهادی",
"settings.faster": "سریع‌تر",
"settings.unlink": "قطع اتصال",
"settings.unlinkTitle": "اتصال {service} قطع شود؟",
"settings.unlinkBody": "کلیدش از این کنسول حذف می‌شود و دانلودها از طریق آن تا وقتی دوباره متصلش نکنید متوقف می‌مانند.",
"settings.speedUnlimitedDetail": "به سرعتی که منبع اجازه می‌دهد",
"settings.speedSide": "یک بازی ۵۰ گیگابایتی",
"settings.speedSideBest": "بسته به منبع",
"settings.speedSideNote": "زمان‌ها با سرعت کامل منبع. منبع کند حتی بدون محدودیت کند می‌ماند.",
"settings.minutes": "{n} دقیقه",
"settings.hoursMinutes": "{h} ساعت {m} دقیقه",
"settings.diskSide": "فضای دیسک‌ها",
"settings.diskAutoDetail": "نخستین دیسکی که جا برای بازی دارد",
"settings.diskSideAuto": "خودکار از حافظه داخلی شروع می‌کند و نخستین دیسکی را می‌گیرد که جا برای بازی دارد.",
"settings.diskSideNote": "بازی‌های جدید اینجا می‌روند. اگر دیسک نباشد یا پر باشد، صفحه بازی دیسک دیگری پیشنهاد می‌دهد.",
"settings.pkgAskDetail": "پیش از هر دانلود، و می‌تواند پاسخ را به یاد بسپارد",
"settings.pkgConsoleDetail": "PS5 دانلودش می‌کند: Overdrive را ببندید و به استفاده از کنسول ادامه دهید",
"settings.pkgOverdriveDetail": "همین‌جا دانلود می‌شود، با توقف و ادامه: Overdrive باز می‌ماند",
"settings.pkgSide": "بازی کجا می‌رود",
"settings.pkgSideConsole": "دانلودهای خود PS5",
"settings.pkgSideOverdrive": "صفحه دانلود Overdrive",
"settings.cleanupOffDetail": "فایل PKG در downloads/packages می‌ماند",
"settings.cleanupSide": "پس از نصب",
"settings.cleanupSideOn": "فضایی را که PKG روی دیسک دانلود گرفته بود آزاد می‌کند. PKG‌ای که کنسول رد کند می‌ماند تا دستی نصب شود.",
"settings.cleanupSideOff": "فایل در downloads/packages می‌ماند: برای نصب دوباره یا کپی به‌کار می‌آید، اما فضا می‌گیرد.",
"settings.vibrationSide": "پیش‌نمایش",
"settings.vibrationSideOn": "با جابه‌جایی میان گزینه‌ها، لرزشی با {level}%.",
"settings.vibrationSideOff": "هنگام گشت‌وگذار لرزشی حس نمی‌شود.",
"settings.languageSide": "چگونه دیده می‌شود",
"settings.languageSideNote": "بخش‌های خود برنامه بی‌درنگ تغییر می‌کنند، بدون راه‌اندازی دوباره.",
"settings.languageSideRtl": "زبانی راست‌به‌چپ: رابط برعکس می‌شود.",
"settings.torboxLead": "پیوندها و مگنت‌ها را روی سرورهای TorBox دانلود می‌کند. کلید روی همین کنسول می‌ماند.",
"settings.realdebridLead": "پیوندها و مگنت‌ها را روی سرورهای Real-Debrid دانلود می‌کند. توکن روی همین کنسول می‌ماند.",
"settings.premiumizeLead": "پیوندها و مگنت‌ها را روی سرورهای Premiumize دانلود می‌کند. با کلید API آن تنظیم می‌شود.",
"settings.qbittorrentLead": "qBittorrent شما در شبکه خانه تورنت‌ها را می‌گیرد؛ کنسول فایل آماده را برمی‌دارد.",
"settings.account": "حساب",
"settings.serviceSideLinked": "کلید روی همین کنسول می‌ماند: پس از راه‌اندازی دوباره و به‌روزرسانی‌ها هم باقی می‌ماند.",
"settings.servicePhone": "کد را با دوربین گوشی اسکن کنید و سپس Overdrive را تأیید کنید. نیازی به کپی کلید نیست.",
"settings.qbitSideSet": "نشانی و ورود روی همین کنسول می‌مانند. WebUI باید در شبکه کنسول باشد.",
"settings.version": "نسخه",
"settings.versionLead": "ساخت در حال اجرا، و اینکه به‌روزرسانی‌ها از کجا می‌آیند.",
"settings.versionBuild": "ساخت",
"settings.versionBuilt": "زمان ساخت",
"settings.versionCheck": "بررسی به‌روزرسانی",
"settings.versionChecking": "در حال بررسی…",
"settings.versionUpToDate": "v{version} آخرین نسخه است.",
"settings.versionAvailable": "v{version} موجود است: Overdrive آن را پیشنهاد می‌دهد.",
"settings.versionFailed": "بررسی انجام نشد. کمی بعد دوباره امتحان کنید.",
"settings.versionNote": "Overdrive در هر اجرا بررسی می‌کند و به‌روزرسانی را از همین‌جا نصب می‌کند.",
"settings.licenses": "مجوزها",
"settings.licensesValue": "متن‌باز",
"settings.licensesLead": "Overdrive نرم‌افزار آزاد است: این‌ها مجوزهای برنامه و پروژه‌هایی است که بر آن‌ها بنا شده.",
"settings.licensesSide": "اعلان‌های همراه",
"settings.licensesEmpty": "این اعلان‌ها در این ساخت نیستند."
}
+85 -1
View File
@@ -87,6 +87,7 @@
"downloadState.file-downloaded": "Fichier téléchargé ; inspectez son format",
"downloadState.finishing": "Finalisation…",
"downloadState.installed": "Installé",
"downloadState.streamed": "Envoyé à la PS5",
"downloadState.installer-required": "Téléchargé ; prêt à installer",
"downloadState.installing": "Installation",
"downloadState.paused": "En pause",
@@ -116,11 +117,33 @@
"downloads.installHint": "Fermez Overdrive pour terminer : ShadowMount installe quand aucun jeu n'est ouvert.",
"downloads.installRequested": "Installation demandée. Fermez Overdrive pour terminer : ShadowMount installe quand aucun jeu n'est ouvert.",
"downloads.installed": "Installé. Il est sur l'écran d'accueil.",
"downloads.streamed": "La PS5 le télécharge et l'installe : suivez-le dans ses Téléchargements. Vous pouvez fermer Overdrive.",
"download.button.streamed": "Sur la PS5",
"downloads.streamedProxy": "L'arrière-plan n'a pas démarré : Overdrive le transmet à la PS5. Laissez-le ouvert jusqu'à la fin.",
"downloads.proxyProgress": "Via Overdrive : {done} sur {total} ({percent} %). Laissez Overdrive ouvert.",
"downloads.proxyDone": "La PS5 a tout reçu : elle termine l'installation dans ses Téléchargements.",
"stream.title": "Comment le télécharger ?",
"stream.console.title": "En arrière-plan sur la PS5",
"stream.console.body": "La PS5 le télécharge. Fermez Overdrive et continuez à jouer.",
"stream.here.title": "Dans Overdrive",
"stream.here.body": "Téléchargé ici, avec pause et reprise. Overdrive reste ouvert.",
"stream.recommended": "Recommandé",
"stream.faster": "Plus rapide",
"stream.remember": "Mémoriser mon choix",
"stream.rememberDetail": "Pour tous les PKG. Modifiable dans les Paramètres.",
"settings.pkgMode": "Téléchargements PKG",
"settings.pkgModeDetail": "Où se télécharge un jeu en PKG.",
"settings.pkgMode.ask": "Demander",
"settings.pkgMode.console": "Arrière-plan",
"settings.pkgMode.overdrive": "Overdrive",
"downloads.streamedBackground": "La PS5 le télécharge : suivez-le dans ses Téléchargements. Vous pouvez fermer Overdrive.",
"downloads.helperProgress": "Sur la PS5 : {done} sur {total} ({percent} %). Suivez-le dans ses Téléchargements.",
"downloads.installedFreed": "Installé. Le PKG a été supprimé : {size} libérés.",
"downloads.notice.complete": "Téléchargement terminé",
"downloads.notice.confirm": "Fichier trouvé, confirmez-le dans Overdrive",
"downloads.notice.failed": "Téléchargement échoué",
"downloads.notice.installed": "Jeu installé",
"downloads.notice.streamed": "Envoyé aux téléchargements de la PS5",
"downloads.packagesExtracted": "Paquets extraits vers {path}. Un installateur PKG est nécessaire.",
"downloads.panel.activity": "ACTIVITÉ",
"downloads.panel.average": "Moyenne",
@@ -392,6 +415,9 @@
"settings.deletePackagesDetail": "Libère l'espace une fois le jeu installé par la console. Désactivez-le pour garder les fichiers.",
"settings.on": "Activé",
"settings.off": "Désactivée",
"settings.disk": "Disque des téléchargements",
"settings.diskAuto": "Automatique",
"settings.diskDetail": "Le lecteur où sont enregistrés les nouveaux jeux. Il reste sélectionné pour les suivants.",
"settings.downloads": "Téléchargements",
"settings.speedLimit": "Limite de vitesse",
"settings.speedLimitDetail": "Laisse de la bande passante aux autres appareils. Les mises à jour de l'app ne sont jamais limitées.",
@@ -479,5 +505,63 @@
"versions.recommended": "Recommandé · fonctionne nativement sur PS5",
"versions.sources": "{count, plural, one {# source} many {# millions de sources} other {# sources}}",
"versions.thisPage": "Cette page",
"versions.title": "Choisir la version"
"versions.title": "Choisir la version",
"settings.group.general": "Général",
"settings.group.downloads": "Téléchargements",
"settings.group.services": "Services liés",
"settings.group.about": "À propos",
"settings.recommended": "Recommandé",
"settings.faster": "Plus rapide",
"settings.unlink": "Dissocier",
"settings.unlinkTitle": "Dissocier {service} ?",
"settings.unlinkBody": "Sa clé est supprimée de cette console et les téléchargements passant par ce service s'arrêtent jusqu'à ce que vous le reliiez.",
"settings.speedUnlimitedDetail": "Aussi vite que la source le permet",
"settings.speedSide": "Un jeu de 50 GB",
"settings.speedSideBest": "ce que la source permet",
"settings.speedSideNote": "Durées avec la source à pleine vitesse. Une source lente reste lente même sans limite.",
"settings.minutes": "{n} min",
"settings.hoursMinutes": "{h} h {m} min",
"settings.diskSide": "Espace sur les disques",
"settings.diskAutoDetail": "Le premier disque avec la place pour le jeu",
"settings.diskSideAuto": "Automatique prend le premier disque avec la place pour le jeu, en commençant par le stockage interne.",
"settings.diskSideNote": "Les nouveaux jeux vont ici. Si le disque manque ou est plein, la page du jeu en propose un autre.",
"settings.pkgAskDetail": "Avant chaque téléchargement, avec la possibilité de s'en souvenir",
"settings.pkgConsoleDetail": "C'est la PS5 qui le télécharge : fermez Overdrive et continuez à utiliser la console",
"settings.pkgOverdriveDetail": "Il se télécharge ici, avec pause et reprise : Overdrive reste ouvert",
"settings.pkgSide": "Où va le jeu",
"settings.pkgSideConsole": "Les Téléchargements de la PS5",
"settings.pkgSideOverdrive": "La page Téléchargements d'Overdrive",
"settings.cleanupOffDetail": "Le PKG reste dans downloads/packages",
"settings.cleanupSide": "Après l'installation",
"settings.cleanupSideOn": "Libère l'espace que le PKG occupait sur le disque des téléchargements. Un PKG que la console refuse reste, à installer à la main.",
"settings.cleanupSideOff": "Le fichier reste dans downloads/packages : utile pour réinstaller ou copier, mais il prend de la place.",
"settings.vibrationSide": "Aperçu",
"settings.vibrationSideOn": "Une impulsion à {level} % quand vous passez d'un élément à l'autre.",
"settings.vibrationSideOff": "Aucune impulsion pendant la navigation.",
"settings.languageSide": "À quoi ça ressemble",
"settings.languageSideNote": "Les parties de l'app changent aussitôt, sans redémarrer.",
"settings.languageSideRtl": "Langue de droite à gauche : l'interface s'inverse.",
"settings.torboxLead": "Télécharge liens et magnets sur les serveurs de TorBox. La clé reste sur cette console.",
"settings.realdebridLead": "Télécharge liens et magnets sur les serveurs de Real-Debrid. Le jeton reste sur cette console.",
"settings.premiumizeLead": "Télécharge liens et magnets sur les serveurs de Premiumize. Il se configure avec sa clé API.",
"settings.qbittorrentLead": "Votre qBittorrent, sur le réseau de la maison, récupère les torrents ; la console prend le fichier fini.",
"settings.account": "Compte",
"settings.serviceSideLinked": "La clé reste sur cette console : elle survit aux redémarrages et aux mises à jour.",
"settings.servicePhone": "Scannez le code avec l'appareil photo du téléphone, puis approuvez Overdrive. Aucune clé à copier.",
"settings.qbitSideSet": "L'adresse et l'identifiant restent sur cette console. La WebUI doit être sur le réseau de la console.",
"settings.version": "Version",
"settings.versionLead": "La version en cours et d'où viennent les mises à jour.",
"settings.versionBuild": "Build",
"settings.versionBuilt": "Compilée",
"settings.versionCheck": "Rechercher des mises à jour",
"settings.versionChecking": "Recherche…",
"settings.versionUpToDate": "v{version} est la dernière version.",
"settings.versionAvailable": "v{version} est disponible : Overdrive la proposera.",
"settings.versionFailed": "La vérification a échoué. Réessayez dans un instant.",
"settings.versionNote": "Overdrive vérifie à chaque lancement et installe la mise à jour depuis ici.",
"settings.licenses": "Licences",
"settings.licensesValue": "Open source",
"settings.licensesLead": "Overdrive est un logiciel libre : voici les licences de l'app et des projets sur lesquels elle repose.",
"settings.licensesSide": "Mentions incluses",
"settings.licensesEmpty": "Les mentions ne sont pas dans cette version."
}
+85 -1
View File
@@ -87,6 +87,7 @@
"downloadState.file-downloaded": "फ़ाइल डाउनलोड हुई; इसके प्रारूप का निरीक्षण करें",
"downloadState.finishing": "पूरा कर रहे हैं…",
"downloadState.installed": "इंस्टॉल हो गया",
"downloadState.streamed": "PS5 को भेजा गया",
"downloadState.installer-required": "डाउनलोड हो गया; इंस्टॉल के लिए तैयार",
"downloadState.installing": "इंस्टॉल हो रहा है",
"downloadState.paused": "रोका गया",
@@ -116,11 +117,33 @@
"downloads.installHint": "पूरा करने के लिए Overdrive बंद करें: कोई गेम खुला न होने पर ShadowMount इंस्टॉल करता है।",
"downloads.installRequested": "इंस्टॉल का अनुरोध भेजा गया। Overdrive बंद करें: कोई गेम खुला न होने पर ShadowMount इंस्टॉल करता है।",
"downloads.installed": "इंस्टॉल हो गया। यह होम स्क्रीन पर है।",
"downloads.streamed": "PS5 इसे डाउनलोड और इंस्टॉल करता है: डाउनलोड में देखें। आप Overdrive बंद कर सकते हैं।",
"download.button.streamed": "PS5 पर",
"downloads.streamedProxy": "बैकग्राउंड शुरू नहीं हुआ: Overdrive इसे PS5 को दे रहा है। पूरा होने तक खुला रखें।",
"downloads.proxyProgress": "Overdrive से: {total} में से {done} ({percent}%)। Overdrive खुला रखें।",
"downloads.proxyDone": "PS5 को सब मिल गया: इंस्टॉल इसके डाउनलोड में पूरा होगा।",
"stream.title": "कैसे डाउनलोड करें?",
"stream.console.title": "PS5 पर बैकग्राउंड में",
"stream.console.body": "PS5 खुद डाउनलोड करेगा। Overdrive बंद करें और खेलते रहें।",
"stream.here.title": "Overdrive में",
"stream.here.body": "यहीं डाउनलोड होगा, रोकने और फिर शुरू करने के साथ। Overdrive खुला रहेगा।",
"stream.recommended": "सुझाया गया",
"stream.faster": "तेज़",
"stream.remember": "मेरी पसंद याद रखें",
"stream.rememberDetail": "हर PKG के लिए। सेटिंग्स में बदलें।",
"settings.pkgMode": "PKG डाउनलोड",
"settings.pkgModeDetail": "PKG गेम कहाँ डाउनलोड हो।",
"settings.pkgMode.ask": "पूछें",
"settings.pkgMode.console": "बैकग्राउंड",
"settings.pkgMode.overdrive": "Overdrive",
"downloads.streamedBackground": "PS5 डाउनलोड कर रहा है: इसके डाउनलोड में देखें। आप Overdrive बंद कर सकते हैं।",
"downloads.helperProgress": "PS5 पर: {total} में से {done} ({percent}%)। इसके डाउनलोड में देखें।",
"downloads.installedFreed": "इंस्टॉल हो गया। PKG हटा दिया गया: {size} खाली हुआ।",
"downloads.notice.complete": "डाउनलोड पूर्ण",
"downloads.notice.confirm": "फ़ाइल मिली, Overdrive में इसकी पुष्टि करें",
"downloads.notice.failed": "डाउनलोड विफल",
"downloads.notice.installed": "गेम इंस्टॉल हो गया",
"downloads.notice.streamed": "PS5 के डाउनलोड में भेजा गया",
"downloads.packagesExtracted": "पैकेज {path} में निकाले गए। एक PKG इंस्टॉलर की आवश्यकता है।",
"downloads.panel.activity": "गतिविधि",
"downloads.panel.average": "औसत",
@@ -392,6 +415,9 @@
"settings.deletePackagesDetail": "कंसोल के गेम इंस्टॉल करने पर जगह खाली करता है। फ़ाइलें रखने के लिए इसे बंद करें।",
"settings.on": "चालू",
"settings.off": "बंद",
"settings.disk": "डाउनलोड के लिए डिस्क",
"settings.diskAuto": "स्वचालित",
"settings.diskDetail": "नए गेम किस ड्राइव में सहेजे जाएँ। अगले गेम के लिए भी यही चुना रहेगा।",
"settings.downloads": "डाउनलोड",
"settings.speedLimit": "स्पीड सीमा",
"settings.speedLimitDetail": "दूसरे डिवाइसों के लिए बैंडविड्थ छोड़ता है। ऐप अपडेट कभी सीमित नहीं होते।",
@@ -479,5 +505,63 @@
"versions.recommended": "सुझाया गया · PS5 पर मूल रूप से चलता है",
"versions.sources": "{count, plural, one {# स्रोत} other {# स्रोत}}",
"versions.thisPage": "यह पृष्ठ",
"versions.title": "संस्करण चुनें"
"versions.title": "संस्करण चुनें",
"settings.group.general": "सामान्य",
"settings.group.downloads": "डाउनलोड",
"settings.group.services": "जुड़ी सेवाएँ",
"settings.group.about": "जानकारी",
"settings.recommended": "अनुशंसित",
"settings.faster": "तेज़",
"settings.unlink": "अलग करें",
"settings.unlinkTitle": "{service} को अलग करें?",
"settings.unlinkBody": "इसकी कुंजी इस कंसोल से हट जाती है, और दोबारा जोड़ने तक इसके ज़रिए डाउनलोड रुक जाते हैं।",
"settings.speedUnlimitedDetail": "जितनी तेज़ स्रोत अनुमति दे",
"settings.speedSide": "50 GB का गेम",
"settings.speedSideBest": "स्रोत जितना दे",
"settings.speedSideNote": "स्रोत की पूरी गति पर लगने वाला समय। धीमा स्रोत बिना सीमा के भी धीमा ही रहता है।",
"settings.minutes": "{n} मिनट",
"settings.hoursMinutes": "{h} घंटे {m} मिनट",
"settings.diskSide": "डिस्क पर जगह",
"settings.diskAutoDetail": "गेम के लिए जगह वाली पहली डिस्क",
"settings.diskSideAuto": "स्वचालित आंतरिक संग्रहण से शुरू करके गेम के लिए जगह वाली पहली डिस्क लेता है।",
"settings.diskSideNote": "नए गेम यहाँ जाते हैं। अगर डिस्क नहीं है या भरी है, तो गेम का पेज दूसरी डिस्क सुझाता है।",
"settings.pkgAskDetail": "हर डाउनलोड से पहले, और जवाब याद भी रख सकता है",
"settings.pkgConsoleDetail": "PS5 इसे डाउनलोड करता है: Overdrive बंद करें और कंसोल इस्तेमाल करते रहें",
"settings.pkgOverdriveDetail": "यहीं डाउनलोड होता है, रोकने और फिर शुरू करने के साथ: Overdrive खुला रहता है",
"settings.pkgSide": "गेम कहाँ जाता है",
"settings.pkgSideConsole": "PS5 के अपने डाउनलोड",
"settings.pkgSideOverdrive": "Overdrive का डाउनलोड पेज",
"settings.cleanupOffDetail": "PKG downloads/packages में रहता है",
"settings.cleanupSide": "इंस्टॉल के बाद",
"settings.cleanupSideOn": "डाउनलोड डिस्क पर PKG ने जो जगह ली थी उसे खाली करता है। जिस PKG को कंसोल नामंज़ूर कर दे, वह हाथ से इंस्टॉल करने के लिए रहता है।",
"settings.cleanupSideOff": "फ़ाइल downloads/packages में रहती है: दोबारा इंस्टॉल या कॉपी करने में काम आती है, पर जगह लेती है।",
"settings.vibrationSide": "पूर्वावलोकन",
"settings.vibrationSideOn": "वस्तुओं के बीच जाने पर {level}% कंपन।",
"settings.vibrationSideOff": "नेविगेट करते समय कोई कंपन नहीं।",
"settings.languageSide": "यह कैसा दिखता है",
"settings.languageSideNote": "ऐप के अपने हिस्से तुरंत बदल जाते हैं, बिना रीस्टार्ट किए।",
"settings.languageSideRtl": "दाएँ से बाएँ लिखी जाने वाली भाषा: इंटरफ़ेस उलट जाता है।",
"settings.torboxLead": "TorBox के सर्वर पर लिंक और मैग्नेट डाउनलोड करता है। कुंजी इस कंसोल पर रहती है।",
"settings.realdebridLead": "Real-Debrid के सर्वर पर लिंक और मैग्नेट डाउनलोड करता है। टोकन इस कंसोल पर रहता है।",
"settings.premiumizeLead": "Premiumize के सर्वर पर लिंक और मैग्नेट डाउनलोड करता है। इसकी API कुंजी से सेट होता है।",
"settings.qbittorrentLead": "घर के नेटवर्क पर आपका qBittorrent टोरेंट लाता है; कंसोल तैयार फ़ाइल ले लेता है।",
"settings.account": "खाता",
"settings.serviceSideLinked": "कुंजी इस कंसोल पर रहती है: रीस्टार्ट और अपडेट के बाद भी बनी रहती है।",
"settings.servicePhone": "फ़ोन के कैमरे से कोड स्कैन करें, फिर Overdrive को मंज़ूरी दें। कोई कुंजी कॉपी नहीं करनी।",
"settings.qbitSideSet": "पता और लॉगिन इस कंसोल पर रहते हैं। WebUI कंसोल के नेटवर्क पर होना चाहिए।",
"settings.version": "संस्करण",
"settings.versionLead": "चल रही बिल्ड, और अपडेट कहाँ से आते हैं।",
"settings.versionBuild": "बिल्ड",
"settings.versionBuilt": "बनी",
"settings.versionCheck": "अपडेट जाँचें",
"settings.versionChecking": "जाँच हो रही है…",
"settings.versionUpToDate": "v{version} नवीनतम है।",
"settings.versionAvailable": "v{version} उपलब्ध है: Overdrive इसे पेश करेगा।",
"settings.versionFailed": "जाँच विफल रही। थोड़ी देर में फिर कोशिश करें।",
"settings.versionNote": "Overdrive हर बार खुलने पर जाँचता है और यहीं से अपडेट इंस्टॉल करता है।",
"settings.licenses": "लाइसेंस",
"settings.licensesValue": "ओपन सोर्स",
"settings.licensesLead": "Overdrive मुक्त सॉफ़्टवेयर है: ये ऐप और उसके आधार बने प्रोजेक्ट्स के लाइसेंस हैं।",
"settings.licensesSide": "शामिल सूचनाएँ",
"settings.licensesEmpty": "इस बिल्ड में सूचनाएँ नहीं हैं।"
}
+85 -1
View File
@@ -87,6 +87,7 @@
"downloadState.file-downloaded": "File diunduh; periksa formatnya",
"downloadState.finishing": "Menyelesaikan…",
"downloadState.installed": "Terpasang",
"downloadState.streamed": "Dikirim ke PS5",
"downloadState.installer-required": "Terunduh; siap dipasang",
"downloadState.installing": "Memasang",
"downloadState.paused": "Dijeda",
@@ -116,11 +117,33 @@
"downloads.installHint": "Tutup Overdrive untuk menyelesaikan: ShadowMount memasang setelah tidak ada game yang terbuka.",
"downloads.installRequested": "Pemasangan diminta. Tutup Overdrive untuk menyelesaikan: ShadowMount memasang setelah tidak ada game yang terbuka.",
"downloads.installed": "Terpasang. Sudah ada di layar beranda.",
"downloads.streamed": "PS5 mengunduh dan memasangnya: pantau di Unduhan. Overdrive boleh ditutup.",
"download.button.streamed": "Di PS5",
"downloads.streamedProxy": "Latar belakang tidak berjalan: Overdrive meneruskannya ke PS5. Biarkan terbuka sampai selesai.",
"downloads.proxyProgress": "Lewat Overdrive: {done} dari {total} ({percent}%). Biarkan Overdrive terbuka.",
"downloads.proxyDone": "PS5 sudah menerima semuanya: pemasangan selesai di Unduhan.",
"stream.title": "Mau unduh di mana?",
"stream.console.title": "Di latar belakang PS5",
"stream.console.body": "PS5 yang mengunduhnya. Tutup Overdrive dan terus bermain.",
"stream.here.title": "Di Overdrive",
"stream.here.body": "Diunduh di sini, bisa dijeda dan dilanjutkan. Overdrive tetap terbuka.",
"stream.recommended": "Disarankan",
"stream.faster": "Lebih cepat",
"stream.remember": "Ingat pilihan",
"stream.rememberDetail": "Untuk semua PKG. Ubah di Pengaturan.",
"settings.pkgMode": "Unduhan PKG",
"settings.pkgModeDetail": "Tempat game PKG diunduh.",
"settings.pkgMode.ask": "Tanya",
"settings.pkgMode.console": "Latar belakang",
"settings.pkgMode.overdrive": "Overdrive",
"downloads.streamedBackground": "PS5 sedang mengunduhnya: pantau di Unduhan. Overdrive boleh ditutup.",
"downloads.helperProgress": "Di PS5: {done} dari {total} ({percent}%). Pantau di Unduhan.",
"downloads.installedFreed": "Terpasang. PKG sudah dihapus: {size} dikosongkan.",
"downloads.notice.complete": "Unduhan selesai",
"downloads.notice.confirm": "File ditemukan, konfirmasi di Overdrive",
"downloads.notice.failed": "Unduhan gagal",
"downloads.notice.installed": "Game terpasang",
"downloads.notice.streamed": "Dikirim ke unduhan PS5",
"downloads.packagesExtracted": "Paket diekstrak ke {path}. Installer PKG diperlukan.",
"downloads.panel.activity": "AKTIVITAS",
"downloads.panel.average": "Rata-rata",
@@ -392,6 +415,9 @@
"settings.deletePackagesDetail": "Mengosongkan ruang setelah konsol memasang game. Matikan untuk menyimpan berkasnya.",
"settings.on": "Aktif",
"settings.off": "Mati",
"settings.disk": "Disk untuk unduhan",
"settings.diskAuto": "Otomatis",
"settings.diskDetail": "Drive tempat game baru disimpan. Pilihan ini tetap dipakai untuk game berikutnya.",
"settings.downloads": "Unduhan",
"settings.speedLimit": "Batas kecepatan",
"settings.speedLimitDetail": "Menyisakan bandwidth untuk perangkat lain. Pembaruan aplikasi tidak pernah dibatasi.",
@@ -479,5 +505,63 @@
"versions.recommended": "Direkomendasikan · berjalan native di PS5",
"versions.sources": "{count, plural, other {# sumber}}",
"versions.thisPage": "Halaman ini",
"versions.title": "Pilih versi"
"versions.title": "Pilih versi",
"settings.group.general": "Umum",
"settings.group.downloads": "Unduhan",
"settings.group.services": "Layanan tertaut",
"settings.group.about": "Tentang",
"settings.recommended": "Disarankan",
"settings.faster": "Lebih cepat",
"settings.unlink": "Putuskan tautan",
"settings.unlinkTitle": "Putuskan tautan {service}?",
"settings.unlinkBody": "Kuncinya dihapus dari konsol ini, dan unduhan melaluinya berhenti sampai kamu menautkannya lagi.",
"settings.speedUnlimitedDetail": "Secepat yang diizinkan sumbernya",
"settings.speedSide": "Game 50 GB",
"settings.speedSideBest": "secepat sumbernya",
"settings.speedSideNote": "Waktu dengan sumber berkecepatan penuh. Sumber yang lambat tetap lambat meski tanpa batas.",
"settings.minutes": "{n} mnt",
"settings.hoursMinutes": "{h} j {m} mnt",
"settings.diskSide": "Ruang di disk",
"settings.diskAutoDetail": "Disk pertama yang punya ruang untuk game",
"settings.diskSideAuto": "Otomatis memakai disk pertama yang punya ruang, mulai dari penyimpanan internal.",
"settings.diskSideNote": "Game baru masuk ke sini. Jika disk hilang atau penuh, halaman game menawarkan disk lain.",
"settings.pkgAskDetail": "Sebelum setiap unduhan, dan bisa mengingat jawabannya",
"settings.pkgConsoleDetail": "PS5 yang mengunduh: tutup Overdrive dan tetap pakai konsol",
"settings.pkgOverdriveDetail": "Diunduh di sini, dengan jeda dan lanjutkan: Overdrive tetap terbuka",
"settings.pkgSide": "Ke mana game pergi",
"settings.pkgSideConsole": "Unduhan milik PS5",
"settings.pkgSideOverdrive": "Halaman Unduhan Overdrive",
"settings.cleanupOffDetail": "PKG tetap di downloads/packages",
"settings.cleanupSide": "Setelah pemasangan",
"settings.cleanupSideOn": "Membebaskan ruang yang dipakai PKG di disk unduhan. PKG yang ditolak konsol tetap ada, untuk dipasang manual.",
"settings.cleanupSideOff": "Berkasnya tetap di downloads/packages: berguna untuk memasang ulang atau menyalin, tetapi memakan ruang.",
"settings.vibrationSide": "Pratinjau",
"settings.vibrationSideOn": "Getaran {level}% saat berpindah antar item.",
"settings.vibrationSideOff": "Tidak ada getaran saat menelusuri.",
"settings.languageSide": "Tampilannya",
"settings.languageSideNote": "Bagian-bagian aplikasi berubah saat itu juga, tanpa memulai ulang.",
"settings.languageSideRtl": "Bahasa kanan-ke-kiri: antarmuka dicerminkan.",
"settings.torboxLead": "Mengunduh tautan dan magnet di server TorBox. Kuncinya tetap di konsol ini.",
"settings.realdebridLead": "Mengunduh tautan dan magnet di server Real-Debrid. Tokennya tetap di konsol ini.",
"settings.premiumizeLead": "Mengunduh tautan dan magnet di server Premiumize. Diatur dengan kunci API-nya.",
"settings.qbittorrentLead": "qBittorrent kamu di jaringan rumah mengambil torrentnya; konsol menerima berkas yang sudah jadi.",
"settings.account": "Akun",
"settings.serviceSideLinked": "Kuncinya tetap di konsol ini: bertahan setelah mulai ulang dan pembaruan.",
"settings.servicePhone": "Pindai kode dengan kamera ponsel, lalu setujui Overdrive. Tanpa kunci yang harus disalin.",
"settings.qbitSideSet": "Alamat dan login tetap di konsol ini. WebUI harus ada di jaringan konsol.",
"settings.version": "Versi",
"settings.versionLead": "Build yang sedang berjalan, dan dari mana pembaruannya datang.",
"settings.versionBuild": "Build",
"settings.versionBuilt": "Dibangun",
"settings.versionCheck": "Periksa pembaruan",
"settings.versionChecking": "Memeriksa…",
"settings.versionUpToDate": "v{version} adalah yang terbaru.",
"settings.versionAvailable": "v{version} tersedia: Overdrive akan menawarkannya.",
"settings.versionFailed": "Pemeriksaan gagal. Coba lagi sebentar lagi.",
"settings.versionNote": "Overdrive memeriksa setiap kali dibuka dan memasang pembaruannya dari sini.",
"settings.licenses": "Lisensi",
"settings.licensesValue": "Sumber terbuka",
"settings.licensesLead": "Overdrive adalah perangkat lunak bebas: ini lisensi aplikasi dan proyek yang menjadi dasarnya.",
"settings.licensesSide": "Pemberitahuan yang disertakan",
"settings.licensesEmpty": "Pemberitahuannya tidak ada di build ini."
}
+153 -1
View File
@@ -1,4 +1,69 @@
{
"add.address": "Indirizzo qBittorrent",
"add.cancel": "Annulla",
"add.close": "Chiudi",
"add.configuredBody": "Le impostazioni sono già salvate su questa console. Puoi modificarle o continuare.",
"add.configuredTitle": "{service} è già configurato",
"add.continue": "Continua",
"add.debrid": "Servizi debrid",
"add.debridBody": "Un link o un magnet. {service} lo scarica sui suoi server e la console prende il file da lì.",
"add.debridDetail": "TorBox, Real-Debrid, Premiumize",
"add.field.address": "Indirizzo qBittorrent",
"add.field.password": "Password qBittorrent",
"add.field.premiumize": "Chiave API Premiumize",
"add.field.realdebrid": "Token API Real-Debrid",
"add.field.torbox": "Chiave API TorBox",
"add.field.user": "Utente qBittorrent",
"add.fieldBody.address": "L'indirizzo della WebUI come host:porta, per esempio 192.168.1.10:8080.",
"add.fieldBody.password": "La password della WebUI. Lasciala vuota se la WebUI non ne chiede.",
"add.fieldBody.premiumize": "La tua chiave, da premiumize.me in Account e poi API. Ognuno inserisce la sua.",
"add.fieldBody.realdebrid": "Il tuo token, da real-debrid.com in My account e poi API. Ognuno inserisce il suo.",
"add.fieldBody.torbox": "La tua chiave, da torbox.app in Impostazioni e poi API. Ognuno inserisce la sua.",
"add.fieldBody.user": "Il nome utente della WebUI.",
"add.link": "Da un link",
"add.linkBody": "L'indirizzo di un file, o la pagina di un host che Overdrive già conosce (VikingFile, DataNodes, FileKeeper). Si scarica come un file del catalogo.",
"add.linkDetail": "Un indirizzo che hai già",
"add.linkTitle": "Incolla un link",
"add.magnetOnly": "Non è un magnet né un link .torrent. Incollane uno dei due.",
"add.modify": "Modifica",
"add.notSet": "Non impostato",
"add.password": "Password",
"add.percent": "{percent}%",
"add.phone.title": "Collegalo con il telefono",
"add.phone.step1": "1. Inquadra il codice con la fotocamera del telefono.",
"add.phone.step2": "2. Accedi a {service} e approva Overdrive. Questa pagina va avanti da sola.",
"add.phone.manual": "Niente fotocamera? Apri {page} e inserisci",
"add.phone.starting": "Chiedo un codice a {service}…",
"add.phone.waiting": "In attesa che lo approvi sul telefono…",
"add.phone.expires": "Nuovo codice tra {time}",
"add.phone.linked": "Collegato",
"add.phone.linkedBody": "Overdrive ora può usare l'account.",
"add.phone.until": "fino al {date}",
"add.phone.failed": "Nessun codice per ora. Controlla la connessione della console, poi Riprova, o inserisci la chiave a mano.",
"add.phone.retry": "Riprova",
"add.phone.or": "Oppure inserisci la chiave a mano, come prima:",
"add.serviceLink": "Collega col telefono",
"add.premiumizeName": "Premiumize",
"add.qbittorrentName": "qBittorrent",
"add.realdebridName": "Real-Debrid",
"add.serviceNeeds": "Serve la chiave",
"add.serviceReady": "Configurato",
"add.servicesBody": "Scegli il servizio che deve recuperare il file. Ognuno ha il suo account e la sua chiave, da inserire una volta su questa console.",
"add.servicesTitle": "Servizi debrid",
"add.setupBody": "Si inseriscono una volta su questa console. Non vengono mandati da nessun'altra parte.",
"add.setupClientHint": "Non hai qBittorrent? Anche un servizio debrid prende i magnet: mandalo dalla sua card.",
"add.setupTitle": "Configura {service}",
"add.title": "AGGIUNGI UN DOWNLOAD",
"add.torboxName": "TorBox",
"add.torrent": "Da un torrent",
"add.torrentBody": "Incolla un magnet, o un link che finisce in .torrent. Lo scarica il tuo qBittorrent, e la console prende il file finito da lì.",
"add.torrentDetail": "Un magnet o un link .torrent, col tuo qBittorrent",
"add.torrentLinkNeedsClient": "Un link .torrent richiede qBittorrent: i servizi debrid prendono solo magnet.",
"add.torrentSetup": "Serve qBittorrent",
"add.torrentTitle": "Da un torrent",
"add.user": "Utente",
"add.waitDetail": "{service} sta recuperando il file. Il download parte qui appena è pronto.",
"add.waitTitle": "In attesa di {service}",
"browse.addToFavorites": "Aggiungi ai preferiti",
"browse.continueDownloading": "Continua il download",
"browse.continueFailed": "Non riuscito · apri per riprovare",
@@ -99,6 +164,7 @@
"downloadState.file-downloaded": "File scaricato; controllane il formato",
"downloadState.finishing": "Ultimi ritocchi…",
"downloadState.installed": "Installato",
"downloadState.streamed": "Inviato alla PS5",
"downloadState.installer-required": "Scaricato; pronto da installare",
"downloadState.installing": "Installazione",
"downloadState.paused": "In pausa",
@@ -130,12 +196,34 @@
"downloads.titleMismatch": "I file sono {found}, il gioco è {expected}: un altro gioco o un'altra regione?",
"downloads.installRequested": "Installazione richiesta. Chiudi Overdrive per completare: ShadowMount installa quando nessun gioco è aperto.",
"downloads.installed": "Installato. Lo trovi nella schermata Home.",
"downloads.streamed": "La PS5 lo scarica e lo installa: seguilo nei suoi Download. Puoi chiudere Overdrive.",
"download.button.streamed": "Sulla PS5",
"downloads.streamedProxy": "Il background non è partito: lo passa Overdrive alla PS5. Tienilo aperto finché finisce.",
"stream.title": "Come vuoi scaricarlo?",
"stream.console.title": "In background sulla PS5",
"stream.console.body": "Lo scarica la PS5. Chiudi Overdrive e continua a giocare.",
"stream.here.title": "In Overdrive",
"stream.here.body": "Si scarica qui, con pausa e ripresa. Overdrive resta aperto.",
"stream.recommended": "Consigliato",
"stream.faster": "Più veloce",
"stream.remember": "Ricorda la scelta",
"stream.rememberDetail": "Per tutti i PKG. Puoi cambiarla nelle Impostazioni.",
"settings.pkgMode": "Download dei PKG",
"settings.pkgModeDetail": "Dove si scarica un gioco in PKG.",
"settings.pkgMode.ask": "Chiedi",
"settings.pkgMode.console": "In background",
"settings.pkgMode.overdrive": "Overdrive",
"downloads.streamedBackground": "La PS5 lo sta scaricando: seguilo nei suoi Download. Puoi chiudere Overdrive.",
"downloads.helperProgress": "Sulla PS5: {done} di {total} ({percent}%). Seguilo nei suoi Download.",
"downloads.proxyProgress": "Tramite Overdrive: {done} di {total} ({percent}%). Tieni aperto Overdrive.",
"downloads.proxyDone": "La PS5 ha ricevuto tutto: finisce l'installazione nei suoi Download.",
"downloads.installedFreed": "Installato. Il PKG è stato eliminato: {size} liberati.",
"downloads.notice.captured": "Link acquisito, torna su Overdrive",
"downloads.notice.confirm": "File trovato, confermalo su Overdrive",
"downloads.notice.complete": "Download completato",
"downloads.notice.failed": "Download non riuscito",
"downloads.notice.installed": "Gioco installato",
"downloads.notice.streamed": "Inviato ai download della PS5",
"downloads.packagesExtracted": "Pacchetti estratti in {path}. Serve un installer di PKG.",
"downloads.panel.activity": "ATTIVITÀ",
"downloads.panel.average": "Media",
@@ -169,6 +257,9 @@
"downloads.writingToDisk": "Scrittura su disco…",
"drives.extended": "Archiviazione estesa {number}",
"drives.internal": "Archiviazione interna",
"drives.short.extended": "Estesa {number}",
"drives.short.internal": "Interno",
"drives.short.usb": "USB {number}",
"drives.thisDevice": "Questo dispositivo",
"drives.usb": "Unità USB {number}",
"errors.action.checkConnection": "Controlla la connessione a Internet della console, poi Riprova.",
@@ -407,6 +498,9 @@
"settings.deletePackagesDetail": "Libera lo spazio quando la console ha installato il gioco. Disattivalo per tenere i file.",
"settings.on": "Attivo",
"settings.off": "Spenta",
"settings.disk": "Disco per i download",
"settings.diskAuto": "Automatico",
"settings.diskDetail": "L'unità in cui vengono salvati i nuovi giochi. Resta scelta per i giochi successivi.",
"settings.downloads": "Download",
"settings.speedLimit": "Limite di velocità",
"settings.speedLimitDetail": "Lascia banda agli altri dispositivi. Gli aggiornamenti dell'app non sono mai limitati.",
@@ -505,5 +599,63 @@
"versions.recommended": "Consigliata · gira in modo nativo su PS5",
"versions.sources": "{count, plural, one {# fonte} other {# fonti}}",
"versions.thisPage": "Questa pagina",
"versions.title": "Scegli la versione"
"versions.title": "Scegli la versione",
"settings.group.general": "Generali",
"settings.group.downloads": "Download",
"settings.group.services": "Servizi collegati",
"settings.group.about": "Informazioni",
"settings.recommended": "Consigliato",
"settings.faster": "Più veloce",
"settings.unlink": "Scollega",
"settings.unlinkTitle": "Scollegare {service}?",
"settings.unlinkBody": "La chiave viene eliminata da questa console e i download tramite questo servizio si fermano finché non lo colleghi di nuovo.",
"settings.speedUnlimitedDetail": "Alla velocità che la sorgente permette",
"settings.speedSide": "Un gioco da 50 GB",
"settings.speedSideBest": "quanto regge la sorgente",
"settings.speedSideNote": "Tempi con la sorgente al massimo. Le sorgenti più lente restano lente anche senza limite.",
"settings.minutes": "{n} min",
"settings.hoursMinutes": "{h} h {m} min",
"settings.diskSide": "Spazio sui dischi",
"settings.diskAutoDetail": "Il primo disco con spazio per il gioco",
"settings.diskSideAuto": "Automatico prende il primo disco con spazio per il gioco, partendo dall'archiviazione interna.",
"settings.diskSideNote": "I giochi nuovi vanno qui. Se il disco manca o è pieno, la pagina del gioco ne propone un altro.",
"settings.pkgAskDetail": "Prima di ogni download, con l'opzione di ricordare la scelta",
"settings.pkgConsoleDetail": "La scarica la PS5. Chiudi Overdrive e continua a usare la console.",
"settings.pkgOverdriveDetail": "Si scarica qui, con pausa e ripresa. Overdrive resta aperto.",
"settings.pkgSide": "Dove va il gioco",
"settings.pkgSideConsole": "I Download della PS5",
"settings.pkgSideOverdrive": "La pagina Download di Overdrive",
"settings.cleanupOffDetail": "Il PKG resta in downloads/packages",
"settings.cleanupSide": "Dopo l'installazione",
"settings.cleanupSideOn": "Libera lo spazio che il PKG occupava sul disco dei download. Un PKG che la console rifiuta resta, da installare a mano.",
"settings.cleanupSideOff": "Il file resta in downloads/packages: utile per reinstallarlo o copiarlo, ma occupa spazio.",
"settings.vibrationSide": "Anteprima",
"settings.vibrationSideOn": "Impulso al {level}%: lo senti spostandoti tra le voci.",
"settings.vibrationSideOff": "Nessun impulso durante la navigazione.",
"settings.languageSide": "Così appare",
"settings.languageSideNote": "Le parti dell'app cambiano subito, senza riavviare.",
"settings.languageSideRtl": "Lingua da destra a sinistra: l'interfaccia si specchia.",
"settings.torboxLead": "Scarica link e magnet sui server di TorBox. La chiave resta su questa console.",
"settings.realdebridLead": "Scarica link e magnet sui server di Real-Debrid. Il token resta su questa console.",
"settings.premiumizeLead": "Scarica link e magnet sui server di Premiumize. Si collega con la chiave API.",
"settings.qbittorrentLead": "Il tuo qBittorrent, sulla rete di casa, scarica i torrent; la console prende il file finito.",
"settings.account": "Account",
"settings.serviceSideLinked": "La chiave resta su questa console: sopravvive a riavvii e aggiornamenti.",
"settings.servicePhone": "Inquadra il codice con la fotocamera del telefono e approva Overdrive. Nessuna chiave da copiare.",
"settings.qbitSideSet": "Indirizzo e accesso restano su questa console. La WebUI dev'essere sulla rete della console.",
"settings.version": "Versione",
"settings.versionLead": "La build che sta girando, e da dove arrivano gli aggiornamenti.",
"settings.versionBuild": "Build",
"settings.versionBuilt": "Compilata",
"settings.versionCheck": "Cerca aggiornamenti",
"settings.versionChecking": "Controllo…",
"settings.versionUpToDate": "v{version} è l'ultima.",
"settings.versionAvailable": "È disponibile la v{version}: Overdrive la proporrà.",
"settings.versionFailed": "Il controllo non è riuscito. Riprova tra un momento.",
"settings.versionNote": "Overdrive controlla a ogni avvio e installa l'aggiornamento da qui.",
"settings.licenses": "Licenze",
"settings.licensesValue": "Codice aperto",
"settings.licensesLead": "Overdrive è software libero: queste sono le licenze dell'app e dei progetti su cui si basa.",
"settings.licensesSide": "Avvisi inclusi",
"settings.licensesEmpty": "Gli avvisi non sono in questa build."
}
+85 -1
View File
@@ -87,6 +87,7 @@
"downloadState.file-downloaded": "ダウンロード済み・形式を確認してください",
"downloadState.finishing": "最終処理中…",
"downloadState.installed": "インストール済み",
"downloadState.streamed": "PS5に送信済み",
"downloadState.installer-required": "ダウンロード済み・インストール可能",
"downloadState.installing": "インストール中",
"downloadState.paused": "一時停止中",
@@ -116,11 +117,33 @@
"downloads.installHint": "Overdriveを終了すると完了します。ShadowMountはゲームが開いていないときにインストールします。",
"downloads.installRequested": "インストールを受け付けました。Overdriveを終了すると完了します。ShadowMountはゲームが開いていないときにインストールします。",
"downloads.installed": "インストールしました。ホーム画面にあります。",
"downloads.streamed": "PS5がダウンロードしてインストールします。ダウンロード画面で確認できます。Overdriveは閉じてもかまいません。",
"download.button.streamed": "PS5で処理中",
"downloads.streamedProxy": "バックグラウンドを開始できませんでした。OverdriveがPS5へ転送します。完了まで開いたままにしてください。",
"downloads.proxyProgress": "Overdrive経由:{done} / {total}({percent}%)。Overdriveを開いたままにしてください。",
"downloads.proxyDone": "PS5がすべて受け取りました。インストールはPS5のダウンロード画面で完了します。",
"stream.title": "どこでダウンロードしますか?",
"stream.console.title": "PS5でバックグラウンド",
"stream.console.body": "PS5本体がダウンロードします。Overdriveを閉じてプレイを続けられます。",
"stream.here.title": "Overdriveで",
"stream.here.body": "ここでダウンロード。一時停止と再開ができます。Overdriveは開いたままです。",
"stream.recommended": "おすすめ",
"stream.faster": "高速",
"stream.remember": "選択を記憶する",
"stream.rememberDetail": "すべてのPKGに適用。設定で変更できます。",
"settings.pkgMode": "PKGのダウンロード",
"settings.pkgModeDetail": "PKGのゲームをダウンロードする場所。",
"settings.pkgMode.ask": "毎回確認",
"settings.pkgMode.console": "バックグラウンド",
"settings.pkgMode.overdrive": "Overdrive",
"downloads.streamedBackground": "PS5がダウンロード中です。PS5のダウンロード画面で確認できます。Overdriveは閉じてもかまいません。",
"downloads.helperProgress": "PS5で処理中:{done} / {total}({percent}%)。PS5のダウンロード画面で確認できます。",
"downloads.installedFreed": "インストールしました。PKGを削除し、{size}を空けました。",
"downloads.notice.complete": "ダウンロード完了",
"downloads.notice.confirm": "ファイルが見つかりました。Overdrive で確認してください",
"downloads.notice.failed": "ダウンロード失敗",
"downloads.notice.installed": "ゲームをインストールしました",
"downloads.notice.streamed": "PS5のダウンロードに送信しました",
"downloads.packagesExtracted": "パッケージを{path}に展開しました。PKGインストーラーが必要です。",
"downloads.panel.activity": "アクティビティ",
"downloads.panel.average": "平均",
@@ -392,6 +415,9 @@
"settings.deletePackagesDetail": "本体がゲームをインストールしたら容量を空けます。ファイルを残す場合はオフにしてください。",
"settings.on": "オン",
"settings.off": "オフ",
"settings.disk": "ダウンロード先のディスク",
"settings.diskAuto": "自動",
"settings.diskDetail": "新しいゲームを保存するドライブです。次のゲームでも選択されたままになります。",
"settings.downloads": "ダウンロード",
"settings.speedLimit": "速度制限",
"settings.speedLimitDetail": "ほかの機器に帯域を残します。アプリのアップデートは制限されません。",
@@ -479,5 +505,63 @@
"versions.recommended": "おすすめ · PS5でネイティブ動作",
"versions.sources": "{count, plural, other {#件の入手元}}",
"versions.thisPage": "このページ",
"versions.title": "バージョンを選ぶ"
"versions.title": "バージョンを選ぶ",
"settings.group.general": "一般",
"settings.group.downloads": "ダウンロード",
"settings.group.services": "連携サービス",
"settings.group.about": "情報",
"settings.recommended": "おすすめ",
"settings.faster": "高速",
"settings.unlink": "連携解除",
"settings.unlinkTitle": "{service}の連携を解除しますか?",
"settings.unlinkBody": "このコンソールからキーが削除され、再び連携するまでこのサービスでのダウンロードは止まります。",
"settings.speedUnlimitedDetail": "配信元が許す最速で",
"settings.speedSide": "50 GB のゲーム",
"settings.speedSideBest": "配信元しだい",
"settings.speedSideNote": "配信元が最高速のときの時間です。遅い配信元は制限なしでも遅いままです。",
"settings.minutes": "{n} 分",
"settings.hoursMinutes": "{h} 時間 {m} 分",
"settings.diskSide": "ディスクの空き容量",
"settings.diskAutoDetail": "ゲームの入る最初のディスク",
"settings.diskSideAuto": "自動は内蔵ストレージから順に、ゲームの入る最初のディスクを使います。",
"settings.diskSideNote": "新しいゲームはここに入ります。ディスクがない、またはいっぱいのときは、ゲームのページが別のディスクを提案します。",
"settings.pkgAskDetail": "ダウンロードのたびに確認し、答えを覚えておくこともできます",
"settings.pkgConsoleDetail": "PS5 がダウンロードします。Overdrive を閉じてコンソールを使い続けられます",
"settings.pkgOverdriveDetail": "ここでダウンロードします。一時停止と再開ができ、Overdrive は開いたままです",
"settings.pkgSide": "ゲームの保存先",
"settings.pkgSideConsole": "PS5 本体のダウンロード",
"settings.pkgSideOverdrive": "Overdrive のダウンロード画面",
"settings.cleanupOffDetail": "PKG は downloads/packages に残ります",
"settings.cleanupSide": "インストール後",
"settings.cleanupSideOn": "PKG がダウンロード先のディスクで使っていた容量を空けます。コンソールが受け付けなかった PKG は残り、手動でインストールできます。",
"settings.cleanupSideOff": "ファイルは downloads/packages に残ります。再インストールやコピーに便利ですが、容量を使います。",
"settings.vibrationSide": "プレビュー",
"settings.vibrationSideOn": "項目を移動するたびに {level}% の振動。",
"settings.vibrationSideOff": "操作中は振動しません。",
"settings.languageSide": "表示のされ方",
"settings.languageSideNote": "アプリ自身の表示は、再起動せずにすぐ変わります。",
"settings.languageSideRtl": "右から左へ書く言語です。画面は左右反転します。",
"settings.torboxLead": "リンクやマグネットを TorBox のサーバーでダウンロードします。キーはこのコンソールに残ります。",
"settings.realdebridLead": "リンクやマグネットを Real-Debrid のサーバーでダウンロードします。トークンはこのコンソールに残ります。",
"settings.premiumizeLead": "リンクやマグネットを Premiumize のサーバーでダウンロードします。API キーで設定します。",
"settings.qbittorrentLead": "自宅ネットワークの qBittorrent がトレントを取得し、コンソールが完成したファイルを受け取ります。",
"settings.account": "アカウント",
"settings.serviceSideLinked": "キーはこのコンソールに残り、再起動や更新を経ても保持されます。",
"settings.servicePhone": "スマートフォンのカメラでコードを読み取り、Overdrive を承認します。キーのコピーは不要です。",
"settings.qbitSideSet": "アドレスとログイン情報はこのコンソールに残ります。WebUI はコンソールと同じネットワークにある必要があります。",
"settings.version": "バージョン",
"settings.versionLead": "実行中のビルドと、更新の入手元です。",
"settings.versionBuild": "ビルド",
"settings.versionBuilt": "ビルド日時",
"settings.versionCheck": "アップデートを確認",
"settings.versionChecking": "確認中…",
"settings.versionUpToDate": "v{version} が最新です。",
"settings.versionAvailable": "v{version} が出ています。Overdrive が通知します。",
"settings.versionFailed": "確認できませんでした。しばらくしてからもう一度お試しください。",
"settings.versionNote": "Overdrive は起動のたびに確認し、ここから更新をインストールします。",
"settings.licenses": "ライセンス",
"settings.licensesValue": "オープンソース",
"settings.licensesLead": "Overdrive はフリーソフトウェアです。ここにはアプリと、その土台となるプロジェクトのライセンスがあります。",
"settings.licensesSide": "同梱の通知",
"settings.licensesEmpty": "このビルドには通知が含まれていません。"
}
+85 -1
View File
@@ -87,6 +87,7 @@
"downloadState.file-downloaded": "Bestand gedownload; controleer het bestandstype",
"downloadState.finishing": "Afronden\u2026",
"downloadState.installed": "Geïnstalleerd",
"downloadState.streamed": "Naar de PS5 gestuurd",
"downloadState.installer-required": "Gedownload; klaar om te installeren",
"downloadState.installing": "Installeren",
"downloadState.paused": "Gepauzeerd",
@@ -118,12 +119,34 @@
"downloads.titleMismatch": "De bestanden zijn {found}, maar de game is {expected}: een andere game of een andere regio?",
"downloads.installRequested": "Installatie aangevraagd. Sluit Overdrive om de installatie af te ronden. ShadowMount installeert zodra er geen game geopend is.",
"downloads.installed": "Geïnstalleerd. De game staat op het startscherm.",
"downloads.streamed": "De PS5 downloadt en installeert het: volg het bij Downloads. Je kunt Overdrive sluiten.",
"download.button.streamed": "Op de PS5",
"downloads.streamedProxy": "Achtergrond niet gestart: Overdrive geeft het door aan de PS5. Laat het open tot het klaar is.",
"downloads.proxyProgress": "Via Overdrive: {done} van {total} ({percent}%). Laat Overdrive open.",
"downloads.proxyDone": "De PS5 heeft alles: hij installeert de rest bij Downloads.",
"stream.title": "Hoe wil je downloaden?",
"stream.console.title": "Op de achtergrond op de PS5",
"stream.console.body": "De PS5 downloadt het zelf. Sluit Overdrive en speel verder.",
"stream.here.title": "In Overdrive",
"stream.here.body": "Downloadt hier, met pauzeren en hervatten. Overdrive blijft open.",
"stream.recommended": "Aanbevolen",
"stream.faster": "Sneller",
"stream.remember": "Keuze onthouden",
"stream.rememberDetail": "Voor elke PKG. Wijzig het in Instellingen.",
"settings.pkgMode": "PKG-downloads",
"settings.pkgModeDetail": "Waar een PKG-game downloadt.",
"settings.pkgMode.ask": "Vragen",
"settings.pkgMode.console": "Achtergrond",
"settings.pkgMode.overdrive": "Overdrive",
"downloads.streamedBackground": "De PS5 downloadt het: volg het bij Downloads. Je kunt Overdrive sluiten.",
"downloads.helperProgress": "Op de PS5: {done} van {total} ({percent}%). Volg het bij Downloads.",
"downloads.installedFreed": "Geïnstalleerd. Het PKG is verwijderd: {size} vrijgemaakt.",
"downloads.notice.captured": "Link vastgelegd, ga terug naar Overdrive",
"downloads.notice.confirm": "Bestand gevonden, bevestig het in Overdrive",
"downloads.notice.complete": "Download voltooid",
"downloads.notice.failed": "Download mislukt",
"downloads.notice.installed": "Game geïnstalleerd",
"downloads.notice.streamed": "Naar de PS5-downloads gestuurd",
"downloads.packagesExtracted": "Pakketten uitgepakt naar {path}. Een PKG\u2013installatieprogramma is vereist.",
"downloads.panel.activity": "ACTIVITEIT",
"downloads.panel.average": "Gemiddeld",
@@ -397,6 +420,9 @@
"settings.deletePackagesDetail": "Maakt ruimte vrij zodra de console het spel heeft geïnstalleerd. Zet het uit om de bestanden te houden.",
"settings.on": "Aan",
"settings.off": "Uit",
"settings.disk": "Schijf voor downloads",
"settings.diskAuto": "Automatisch",
"settings.diskDetail": "De schijf waar nieuwe games worden opgeslagen. Hij blijft gekozen voor de volgende games.",
"settings.downloads": "Downloads",
"settings.speedLimit": "Snelheidslimiet",
"settings.speedLimitDetail": "Houdt bandbreedte vrij voor andere apparaten. App-updates worden nooit beperkt.",
@@ -507,5 +533,63 @@
"capture.confirm.detail": "De host vermeldt niet bij welke game dit bestand hoort. Download het alleen als het de game is die je hebt gekozen.",
"capture.confirm.noName": "Bestand zonder naam",
"capture.confirm.accept": "Downloaden",
"capture.confirm.refuse": "Verder zoeken"
"capture.confirm.refuse": "Verder zoeken",
"settings.group.general": "Algemeen",
"settings.group.downloads": "Downloads",
"settings.group.services": "Gekoppelde diensten",
"settings.group.about": "Over",
"settings.recommended": "Aanbevolen",
"settings.faster": "Sneller",
"settings.unlink": "Ontkoppelen",
"settings.unlinkTitle": "{service} ontkoppelen?",
"settings.unlinkBody": "De sleutel wordt van deze console verwijderd en downloads erdoor stoppen tot je de dienst opnieuw koppelt.",
"settings.speedUnlimitedDetail": "Zo snel als de bron toelaat",
"settings.speedSide": "Een game van 50 GB",
"settings.speedSideBest": "zo snel als de bron kan",
"settings.speedSideNote": "Tijden bij volle snelheid van de bron. Een langzame bron blijft langzaam, ook zonder limiet.",
"settings.minutes": "{n} min",
"settings.hoursMinutes": "{h} u {m} min",
"settings.diskSide": "Ruimte op de schijven",
"settings.diskAutoDetail": "De eerste schijf met ruimte voor de game",
"settings.diskSideAuto": "Automatisch neemt de eerste schijf met ruimte voor de game, te beginnen met de interne opslag.",
"settings.diskSideNote": "Nieuwe games komen hier. Ontbreekt de schijf of is hij vol, dan biedt de gamepagina een andere aan.",
"settings.pkgAskDetail": "Voor elke download, en het kan de keuze onthouden",
"settings.pkgConsoleDetail": "De PS5 downloadt het: sluit Overdrive en gebruik de console verder",
"settings.pkgOverdriveDetail": "Het downloadt hier, met pauze en hervatten: Overdrive blijft open",
"settings.pkgSide": "Waar de game heen gaat",
"settings.pkgSideConsole": "De Downloads van de PS5",
"settings.pkgSideOverdrive": "De Downloadpagina van Overdrive",
"settings.cleanupOffDetail": "De PKG blijft in downloads/packages",
"settings.cleanupSide": "Na het installeren",
"settings.cleanupSideOn": "Maakt de ruimte vrij die de PKG op de downloadschijf innam. Een PKG die de console weigert blijft staan, om handmatig te installeren.",
"settings.cleanupSideOff": "Het bestand blijft in downloads/packages: handig om opnieuw te installeren of te kopiëren, maar het kost ruimte.",
"settings.vibrationSide": "Voorbeeld",
"settings.vibrationSideOn": "Een puls op {level}% als je tussen items beweegt.",
"settings.vibrationSideOff": "Niets pulseert tijdens het navigeren.",
"settings.languageSide": "Zo ziet het eruit",
"settings.languageSideNote": "De eigen delen van de app veranderen meteen, zonder herstart.",
"settings.languageSideRtl": "Een taal van rechts naar links: de interface spiegelt.",
"settings.torboxLead": "Downloadt links en magnets op TorBox' servers. De sleutel blijft op deze console.",
"settings.realdebridLead": "Downloadt links en magnets op Real-Debrids servers. Het token blijft op deze console.",
"settings.premiumizeLead": "Downloadt links en magnets op Premiumizes servers. Het wordt met de API-sleutel ingesteld.",
"settings.qbittorrentLead": "Je qBittorrent op het thuisnetwerk haalt de torrents op; de console neemt het voltooide bestand.",
"settings.account": "Account",
"settings.serviceSideLinked": "De sleutel blijft op deze console: hij overleeft herstarts en updates.",
"settings.servicePhone": "Scan de code met de camera van je telefoon en keur Overdrive goed. Geen sleutel om te kopiëren.",
"settings.qbitSideSet": "Het adres en de inloggegevens blijven op deze console. De WebUI moet op het netwerk van de console zitten.",
"settings.version": "Versie",
"settings.versionLead": "De build die nu draait, en waar updates vandaan komen.",
"settings.versionBuild": "Build",
"settings.versionBuilt": "Gebouwd",
"settings.versionCheck": "Zoek naar updates",
"settings.versionChecking": "Controleren…",
"settings.versionUpToDate": "v{version} is de nieuwste.",
"settings.versionAvailable": "v{version} is beschikbaar: Overdrive biedt hem aan.",
"settings.versionFailed": "De controle is mislukt. Probeer het zo opnieuw.",
"settings.versionNote": "Overdrive controleert bij elke start en installeert de update vanaf hier.",
"settings.licenses": "Licenties",
"settings.licensesValue": "Open source",
"settings.licensesLead": "Overdrive is vrije software: dit zijn de licenties van de app en van de projecten waarop hij bouwt.",
"settings.licensesSide": "Meegeleverde kennisgevingen",
"settings.licensesEmpty": "De kennisgevingen zitten niet in deze build."
}
+85 -1
View File
@@ -87,6 +87,7 @@
"downloadState.file-downloaded": "Arquivo baixado; inspecione seu formato",
"downloadState.finishing": "Finalizando…",
"downloadState.installed": "Instalado",
"downloadState.streamed": "Enviado ao PS5",
"downloadState.installer-required": "Baixado; pronto para instalar",
"downloadState.installing": "Instalando",
"downloadState.paused": "Pausado",
@@ -116,11 +117,33 @@
"downloads.installHint": "Feche Overdrive para terminar: ShadowMount instala quando nenhum jogo está aberto.",
"downloads.installRequested": "Instalação solicitada. Feche Overdrive para terminar: ShadowMount instala quando nenhum jogo está aberto.",
"downloads.installed": "Instalado. Já está na tela inicial.",
"downloads.streamed": "O PS5 baixa e instala: acompanhe em Downloads. Você pode fechar o Overdrive.",
"download.button.streamed": "No PS5",
"downloads.streamedProxy": "O segundo plano não iniciou: o Overdrive repassa ao PS5. Deixe-o aberto até terminar.",
"downloads.proxyProgress": "Pelo Overdrive: {done} de {total} ({percent}%). Deixe o Overdrive aberto.",
"downloads.proxyDone": "O PS5 recebeu tudo: termina a instalação em Downloads.",
"stream.title": "Como você quer baixar?",
"stream.console.title": "Em segundo plano no PS5",
"stream.console.body": "O PS5 baixa sozinho. Feche o Overdrive e continue jogando.",
"stream.here.title": "No Overdrive",
"stream.here.body": "Baixa aqui, com pausa e retomada. O Overdrive fica aberto.",
"stream.recommended": "Recomendado",
"stream.faster": "Mais rápido",
"stream.remember": "Lembrar a escolha",
"stream.rememberDetail": "Para todo PKG. Mude em Configurações.",
"settings.pkgMode": "Downloads de PKG",
"settings.pkgModeDetail": "Onde um jogo em PKG é baixado.",
"settings.pkgMode.ask": "Perguntar",
"settings.pkgMode.console": "Segundo plano",
"settings.pkgMode.overdrive": "Overdrive",
"downloads.streamedBackground": "O PS5 está baixando: acompanhe em Downloads. Você pode fechar o Overdrive.",
"downloads.helperProgress": "No PS5: {done} de {total} ({percent}%). Acompanhe em Downloads.",
"downloads.installedFreed": "Instalado. O PKG foi excluído: {size} liberados.",
"downloads.notice.complete": "Download concluído",
"downloads.notice.confirm": "Arquivo encontrado, confirme no Overdrive",
"downloads.notice.failed": "Download falhou",
"downloads.notice.installed": "Jogo instalado",
"downloads.notice.streamed": "Enviado aos downloads do PS5",
"downloads.packagesExtracted": "Pacotes extraídos para {path}. Um instalador PKG é necessário.",
"downloads.panel.activity": "ATIVIDADE",
"downloads.panel.average": "Médio",
@@ -392,6 +415,9 @@
"settings.deletePackagesDetail": "Libera o espaço depois que o console instala o jogo. Desative para manter os arquivos.",
"settings.on": "Ativado",
"settings.off": "Desligada",
"settings.disk": "Disco para downloads",
"settings.diskAuto": "Automático",
"settings.diskDetail": "A unidade onde os novos jogos são salvos. Ela continua escolhida para os próximos.",
"settings.downloads": "Downloads",
"settings.speedLimit": "Limite de velocidade",
"settings.speedLimitDetail": "Deixa banda para outros dispositivos. Atualizações do app nunca são limitadas.",
@@ -495,5 +521,63 @@
"capture.preparing": "Preparando a verificação do arquivo…",
"capture.capturing": "Conclua a verificação e aguarde",
"capture.validating": "Conferindo o arquivo capturado…",
"capture.detail": "Não aperte Download nem anúncios: o link é capturado sozinho. Depois volte aqui e o download começa automaticamente. A captura dura até 3 minutos."
"capture.detail": "Não aperte Download nem anúncios: o link é capturado sozinho. Depois volte aqui e o download começa automaticamente. A captura dura até 3 minutos.",
"settings.group.general": "Geral",
"settings.group.downloads": "Downloads",
"settings.group.services": "Serviços vinculados",
"settings.group.about": "Sobre",
"settings.recommended": "Recomendado",
"settings.faster": "Mais rápido",
"settings.unlink": "Desvincular",
"settings.unlinkTitle": "Desvincular {service}?",
"settings.unlinkBody": "A chave é apagada desta console e os downloads por ele param até você vinculá-lo de novo.",
"settings.speedUnlimitedDetail": "Tão rápido quanto a fonte permitir",
"settings.speedSide": "Um jogo de 50 GB",
"settings.speedSideBest": "o quanto a fonte aguentar",
"settings.speedSideNote": "Tempos com a fonte na velocidade máxima. Uma fonte lenta continua lenta mesmo sem limite.",
"settings.minutes": "{n} min",
"settings.hoursMinutes": "{h} h {m} min",
"settings.diskSide": "Espaço nos discos",
"settings.diskAutoDetail": "O primeiro disco com espaço para o jogo",
"settings.diskSideAuto": "Automático pega o primeiro disco com espaço para o jogo, começando pelo armazenamento interno.",
"settings.diskSideNote": "Jogos novos vão aqui. Se o disco faltar ou estiver cheio, a página do jogo oferece outro.",
"settings.pkgAskDetail": "Antes de cada download, e pode lembrar a resposta",
"settings.pkgConsoleDetail": "A PS5 baixa: feche o Overdrive e continue usando o console",
"settings.pkgOverdriveDetail": "Baixa aqui, com pausa e retomada: o Overdrive continua aberto",
"settings.pkgSide": "Para onde o jogo vai",
"settings.pkgSideConsole": "Os Downloads do PS5",
"settings.pkgSideOverdrive": "A página Downloads do Overdrive",
"settings.cleanupOffDetail": "O PKG fica em downloads/packages",
"settings.cleanupSide": "Depois de instalar",
"settings.cleanupSideOn": "Libera o espaço que o PKG ocupava no disco de downloads. Um PKG que o console recusa fica, para instalar à mão.",
"settings.cleanupSideOff": "O arquivo fica em downloads/packages: útil para reinstalar ou copiar, mas ocupa espaço.",
"settings.vibrationSide": "Prévia",
"settings.vibrationSideOn": "Um impulso a {level}% ao se mover entre os itens.",
"settings.vibrationSideOff": "Nada vibra ao navegar.",
"settings.languageSide": "Como fica",
"settings.languageSideNote": "As partes do app mudam na hora, sem reiniciar.",
"settings.languageSideRtl": "Idioma da direita para a esquerda: a interface espelha.",
"settings.torboxLead": "Baixa links e magnets nos servidores do TorBox. A chave fica nesta console.",
"settings.realdebridLead": "Baixa links e magnets nos servidores do Real-Debrid. O token fica nesta console.",
"settings.premiumizeLead": "Baixa links e magnets nos servidores do Premiumize. É configurado com a chave de API.",
"settings.qbittorrentLead": "Seu qBittorrent, na rede de casa, baixa os torrents; o console pega o arquivo pronto.",
"settings.account": "Conta",
"settings.serviceSideLinked": "A chave fica nesta console: sobrevive a reinícios e atualizações.",
"settings.servicePhone": "Escaneie o código com a câmera do celular e aprove o Overdrive. Nenhuma chave para copiar.",
"settings.qbitSideSet": "O endereço e o acesso ficam nesta console. A WebUI precisa estar na rede do console.",
"settings.version": "Versão",
"settings.versionLead": "A build que está rodando e de onde vêm as atualizações.",
"settings.versionBuild": "Build",
"settings.versionBuilt": "Compilada",
"settings.versionCheck": "Procurar atualizações",
"settings.versionChecking": "Verificando…",
"settings.versionUpToDate": "v{version} é a mais recente.",
"settings.versionAvailable": "v{version} está disponível: o Overdrive vai oferecê-la.",
"settings.versionFailed": "A verificação falhou. Tente de novo em instantes.",
"settings.versionNote": "O Overdrive verifica a cada início e instala a atualização daqui.",
"settings.licenses": "Licenças",
"settings.licensesValue": "Código aberto",
"settings.licensesLead": "O Overdrive é software livre: estas são as licenças do app e dos projetos em que ele se baseia.",
"settings.licensesSide": "Avisos incluídos",
"settings.licensesEmpty": "Os avisos não estão nesta build."
}
+85 -1
View File
@@ -87,6 +87,7 @@
"downloadState.file-downloaded": "Ficheiro descarregado; inspecione o seu formato",
"downloadState.finishing": "A concluir\u2026",
"downloadState.installed": "Instalado",
"downloadState.streamed": "Enviado para a PS5",
"downloadState.installer-required": "Descarregado; pronto a instalar",
"downloadState.installing": "A instalar",
"downloadState.paused": "Pausado",
@@ -118,12 +119,34 @@
"downloads.titleMismatch": "Os ficheiros são {found}, o jogo é {expected}: outro jogo ou outra região?",
"downloads.installRequested": "Instalação solicitada. Feche o Overdrive para concluir: o ShadowMount instala assim que nenhum jogo estiver aberto.",
"downloads.installed": "Instalado. Está no ecrã inicial.",
"downloads.streamed": "A PS5 transfere-o e instala-o: acompanhe nas Transferências. Pode fechar o Overdrive.",
"download.button.streamed": "Na PS5",
"downloads.streamedProxy": "O segundo plano não arrancou: o Overdrive passa-o à PS5. Deixe-o aberto até terminar.",
"downloads.proxyProgress": "Pelo Overdrive: {done} de {total} ({percent}%). Deixe o Overdrive aberto.",
"downloads.proxyDone": "A PS5 recebeu tudo: termina a instalação nas Transferências.",
"stream.title": "Como quer transferir?",
"stream.console.title": "Em segundo plano na PS5",
"stream.console.body": "A PS5 transfere-o. Feche o Overdrive e continue a jogar.",
"stream.here.title": "No Overdrive",
"stream.here.body": "Transfere aqui, com pausa e retoma. O Overdrive fica aberto.",
"stream.recommended": "Recomendado",
"stream.faster": "Mais rápido",
"stream.remember": "Lembrar a escolha",
"stream.rememberDetail": "Para todos os PKG. Altere nas Definições.",
"settings.pkgMode": "Transferências de PKG",
"settings.pkgModeDetail": "Onde se transfere um jogo em PKG.",
"settings.pkgMode.ask": "Perguntar",
"settings.pkgMode.console": "Segundo plano",
"settings.pkgMode.overdrive": "Overdrive",
"downloads.streamedBackground": "A PS5 está a transferi-lo: acompanhe nas Transferências. Pode fechar o Overdrive.",
"downloads.helperProgress": "Na PS5: {done} de {total} ({percent}%). Acompanhe nas Transferências.",
"downloads.installedFreed": "Instalado. O PKG foi eliminado: {size} libertados.",
"downloads.notice.captured": "Link capturado, regresse ao Overdrive",
"downloads.notice.confirm": "Ficheiro encontrado, confirme no Overdrive",
"downloads.notice.complete": "Transferência concluída",
"downloads.notice.failed": "Transferência falhou",
"downloads.notice.installed": "Jogo instalado",
"downloads.notice.streamed": "Enviado para as transferências da PS5",
"downloads.packagesExtracted": "Pacotes extraídos para {path}. É necessário um instalador de PKG.",
"downloads.panel.activity": "ATIVIDADE",
"downloads.panel.average": "Média",
@@ -401,6 +424,9 @@
"settings.deletePackagesDetail": "Liberta o espaço assim que a consola instala o jogo. Desative para manter os ficheiros.",
"settings.on": "Ligado",
"settings.off": "Desligado",
"settings.disk": "Disco para transferências",
"settings.diskAuto": "Automático",
"settings.diskDetail": "A unidade onde os novos jogos são guardados. Continua escolhida para os seguintes.",
"settings.downloads": "Transferências",
"settings.speedLimit": "Limite de velocidade",
"settings.speedLimitDetail": "Deixa largura de banda para outros dispositivos. As atualizações de aplicações nunca são limitadas.",
@@ -511,5 +537,63 @@
"capture.confirm.detail": "O anfitrião não indica a que jogo pertence este ficheiro. Descarregue-o apenas se for o jogo que escolheu.",
"capture.confirm.noName": "Ficheiro sem nome",
"capture.confirm.accept": "Descarregar",
"capture.confirm.refuse": "Continuar a procurar"
"capture.confirm.refuse": "Continuar a procurar",
"settings.group.general": "Geral",
"settings.group.downloads": "Transferências",
"settings.group.services": "Serviços associados",
"settings.group.about": "Acerca",
"settings.recommended": "Recomendado",
"settings.faster": "Mais rápido",
"settings.unlink": "Desassociar",
"settings.unlinkTitle": "Desassociar {service}?",
"settings.unlinkBody": "A chave é apagada desta consola e as transferências por ele param até o associar de novo.",
"settings.speedUnlimitedDetail": "Tão rápido quanto a fonte permitir",
"settings.speedSide": "Um jogo de 50 GB",
"settings.speedSideBest": "o quanto a fonte aguentar",
"settings.speedSideNote": "Tempos com a fonte à velocidade máxima. Uma fonte lenta continua lenta mesmo sem limite.",
"settings.minutes": "{n} min",
"settings.hoursMinutes": "{h} h {m} min",
"settings.diskSide": "Espaço nos discos",
"settings.diskAutoDetail": "O primeiro disco com espaço para o jogo",
"settings.diskSideAuto": "Automático escolhe o primeiro disco com espaço para o jogo, começando pelo armazenamento interno.",
"settings.diskSideNote": "Os jogos novos vão para aqui. Se o disco faltar ou estiver cheio, a página do jogo propõe outro.",
"settings.pkgAskDetail": "Antes de cada transferência, e pode lembrar a resposta",
"settings.pkgConsoleDetail": "É a PS5 que transfere: fecha o Overdrive e continua a usar a consola",
"settings.pkgOverdriveDetail": "Transfere aqui, com pausa e retoma: o Overdrive fica aberto",
"settings.pkgSide": "Para onde vai o jogo",
"settings.pkgSideConsole": "As Transferências da PS5",
"settings.pkgSideOverdrive": "A página Transferências do Overdrive",
"settings.cleanupOffDetail": "O PKG fica em downloads/packages",
"settings.cleanupSide": "Depois de instalar",
"settings.cleanupSideOn": "Liberta o espaço que o PKG ocupava no disco das transferências. Um PKG que a consola recusa fica, para instalar à mão.",
"settings.cleanupSideOff": "O ficheiro fica em downloads/packages: útil para reinstalar ou copiar, mas ocupa espaço.",
"settings.vibrationSide": "Pré-visualização",
"settings.vibrationSideOn": "Um impulso a {level}% ao mover-se entre os itens.",
"settings.vibrationSideOff": "Nada vibra ao navegar.",
"settings.languageSide": "Como fica",
"settings.languageSideNote": "As partes da app mudam na hora, sem reiniciar.",
"settings.languageSideRtl": "Idioma da direita para a esquerda: a interface inverte-se.",
"settings.torboxLead": "Transfere links e magnets nos servidores do TorBox. A chave fica nesta consola.",
"settings.realdebridLead": "Transfere links e magnets nos servidores do Real-Debrid. O token fica nesta consola.",
"settings.premiumizeLead": "Transfere links e magnets nos servidores do Premiumize. Configura-se com a chave de API.",
"settings.qbittorrentLead": "O seu qBittorrent, na rede de casa, transfere os torrents; a consola recebe o ficheiro terminado.",
"settings.account": "Conta",
"settings.serviceSideLinked": "A chave fica nesta consola: sobrevive a reinícios e atualizações.",
"settings.servicePhone": "Leia o código com a câmara do telemóvel e aprove o Overdrive. Nenhuma chave para copiar.",
"settings.qbitSideSet": "O endereço e o acesso ficam nesta consola. A WebUI tem de estar na rede da consola.",
"settings.version": "Versão",
"settings.versionLead": "A build que está a correr e de onde vêm as atualizações.",
"settings.versionBuild": "Build",
"settings.versionBuilt": "Compilada",
"settings.versionCheck": "Procurar atualizações",
"settings.versionChecking": "A verificar…",
"settings.versionUpToDate": "v{version} é a mais recente.",
"settings.versionAvailable": "v{version} está disponível: o Overdrive vai propô-la.",
"settings.versionFailed": "A verificação falhou. Tenta de novo daqui a pouco.",
"settings.versionNote": "O Overdrive verifica a cada arranque e instala a atualização daqui.",
"settings.licenses": "Licenças",
"settings.licensesValue": "Código aberto",
"settings.licensesLead": "O Overdrive é software livre: estas são as licenças da app e dos projetos em que se baseia.",
"settings.licensesSide": "Avisos incluídos",
"settings.licensesEmpty": "Os avisos não estão nesta build."
}
+85 -1
View File
@@ -87,6 +87,7 @@
"downloadState.file-downloaded": "Файл загружен; проверить его формат",
"downloadState.finishing": "Завершение…",
"downloadState.installed": "Установлено",
"downloadState.streamed": "Отправлено на PS5",
"downloadState.installer-required": "Загружено; готово к установке",
"downloadState.installing": "Установка",
"downloadState.paused": "Пауза",
@@ -116,12 +117,34 @@
"downloads.installHint": "Закройте Overdrive для завершения: ShadowMount установит, когда не будет открытых игр.",
"downloads.installRequested": "Установка запрошена. Закройте Overdrive для завершения: ShadowMount установит, когда не будет открытых игр.",
"downloads.installed": "Установлено. Игра на главном экране.",
"downloads.streamed": "PS5 скачивает и устанавливает игру: следите в разделе «Загрузки». Overdrive можно закрыть.",
"download.button.streamed": "На PS5",
"downloads.streamedProxy": "Фоновая загрузка не запустилась: Overdrive передаёт игру на PS5. Не закрывайте его до конца.",
"downloads.proxyProgress": "Через Overdrive: {done} из {total} ({percent}%). Не закрывайте Overdrive.",
"downloads.proxyDone": "PS5 получила всё: установка завершится в разделе «Загрузки».",
"stream.title": "Как скачать?",
"stream.console.title": "В фоне на PS5",
"stream.console.body": "Скачивает сама PS5. Закройте Overdrive и играйте.",
"stream.here.title": "В Overdrive",
"stream.here.body": "Скачивается здесь, с паузой и продолжением. Overdrive остаётся открытым.",
"stream.recommended": "Рекомендуем",
"stream.faster": "Быстрее",
"stream.remember": "Запомнить выбор",
"stream.rememberDetail": "Для всех PKG. Можно изменить в настройках.",
"settings.pkgMode": "Загрузка PKG",
"settings.pkgModeDetail": "Где скачивается игра в PKG.",
"settings.pkgMode.ask": "Спрашивать",
"settings.pkgMode.console": "В фоне",
"settings.pkgMode.overdrive": "Overdrive",
"downloads.streamedBackground": "Скачивает PS5: следите в разделе «Загрузки». Overdrive можно закрыть.",
"downloads.helperProgress": "На PS5: {done} из {total} ({percent}%). Следите в разделе «Загрузки».",
"downloads.installedFreed": "Установлено. PKG удалён: освобождено {size}.",
"downloads.notice.captured": "Ссылка захвачена — вернитесь в Overdrive",
"downloads.notice.confirm": "Файл найден — подтвердите его в Overdrive",
"downloads.notice.complete": "Загрузка завершена",
"downloads.notice.failed": "Загрузка не удалась",
"downloads.notice.installed": "Игра установлена",
"downloads.notice.streamed": "Отправлено в загрузки PS5",
"downloads.packagesExtracted": "Пакеты распакованы в {path}. Требуется инсталлятор PKG.",
"downloads.panel.activity": "АКТИВНОСТЬ",
"downloads.panel.average": "Средняя",
@@ -393,6 +416,9 @@
"settings.deletePackagesDetail": "Освобождает место, когда консоль установила игру. Выключите, чтобы сохранить файлы.",
"settings.on": "Вкл.",
"settings.off": "Выкл.",
"settings.disk": "Диск для загрузок",
"settings.diskAuto": "Авто",
"settings.diskDetail": "Диск, на который сохраняются новые игры. Он остаётся выбранным и для следующих.",
"settings.downloads": "Загрузки",
"settings.speedLimit": "Ограничение скорости",
"settings.speedLimitDetail": "Оставляет канал другим устройствам. Обновления приложения не ограничиваются.",
@@ -496,5 +522,63 @@
"capture.preparing": "Подготовка проверки файла…",
"capture.capturing": "Пройдите проверку и подождите",
"capture.validating": "Проверка захваченного файла…",
"capture.detail": "Не нажимайте «Скачать» и рекламу: ссылка будет перехвачена сама, и загрузка начнётся автоматически. У вас есть 3 минуты."
"capture.detail": "Не нажимайте «Скачать» и рекламу: ссылка будет перехвачена сама, и загрузка начнётся автоматически. У вас есть 3 минуты.",
"settings.group.general": "Общие",
"settings.group.downloads": "Загрузки",
"settings.group.services": "Подключённые сервисы",
"settings.group.about": "О приложении",
"settings.recommended": "Рекомендуется",
"settings.faster": "Быстрее",
"settings.unlink": "Отключить",
"settings.unlinkTitle": "Отключить {service}?",
"settings.unlinkBody": "Ключ будет удалён с этой консоли, а загрузки через сервис остановятся, пока вы снова его не подключите.",
"settings.speedUnlimitedDetail": "Настолько быстро, насколько позволяет источник",
"settings.speedSide": "Игра на 50 ГБ",
"settings.speedSideBest": "сколько даёт источник",
"settings.speedSideNote": "Время при полной скорости источника. Медленный источник останется медленным даже без лимита.",
"settings.minutes": "{n} мин",
"settings.hoursMinutes": "{h} ч {m} мин",
"settings.diskSide": "Место на дисках",
"settings.diskAutoDetail": "Первый диск, где хватает места",
"settings.diskSideAuto": "Авто берёт первый диск, где хватает места, начиная с внутреннего накопителя.",
"settings.diskSideNote": "Новые игры попадают сюда. Если диска нет или он полон, страница игры предложит другой.",
"settings.pkgAskDetail": "Перед каждой загрузкой, с возможностью запомнить ответ",
"settings.pkgConsoleDetail": "Скачивает PS5: закройте Overdrive и продолжайте пользоваться консолью",
"settings.pkgOverdriveDetail": "Загрузка идёт здесь, с паузой и продолжением: Overdrive остаётся открыт",
"settings.pkgSide": "Куда попадает игра",
"settings.pkgSideConsole": "«Загрузки» самой PS5",
"settings.pkgSideOverdrive": "Страница «Загрузки» Overdrive",
"settings.cleanupOffDetail": "PKG остаётся в downloads/packages",
"settings.cleanupSide": "После установки",
"settings.cleanupSideOn": "Освобождает место, которое PKG занимал на диске загрузок. PKG, который консоль отклонила, остаётся для установки вручную.",
"settings.cleanupSideOff": "Файл остаётся в downloads/packages: удобно переустановить или скопировать, но он занимает место.",
"settings.vibrationSide": "Предпросмотр",
"settings.vibrationSideOn": "Импульс на {level}%, когда вы переходите между пунктами.",
"settings.vibrationSideOff": "При навигации ничего не вибрирует.",
"settings.languageSide": "Как это выглядит",
"settings.languageSideNote": "Части самого приложения меняются сразу, без перезапуска.",
"settings.languageSideRtl": "Язык справа налево: интерфейс зеркалится.",
"settings.torboxLead": "Скачивает ссылки и magnet на серверах TorBox. Ключ остаётся на этой консоли.",
"settings.realdebridLead": "Скачивает ссылки и magnet на серверах Real-Debrid. Токен остаётся на этой консоли.",
"settings.premiumizeLead": "Скачивает ссылки и magnet на серверах Premiumize. Настраивается по ключу API.",
"settings.qbittorrentLead": "Ваш qBittorrent в домашней сети забирает торренты; консоль получает готовый файл.",
"settings.account": "Аккаунт",
"settings.serviceSideLinked": "Ключ остаётся на этой консоли: он переживает перезапуски и обновления.",
"settings.servicePhone": "Отсканируйте код камерой телефона и подтвердите Overdrive. Ключ копировать не нужно.",
"settings.qbitSideSet": "Адрес и логин остаются на этой консоли. WebUI должен быть в сети консоли.",
"settings.version": "Версия",
"settings.versionLead": "Текущая сборка и откуда приходят обновления.",
"settings.versionBuild": "Сборка",
"settings.versionBuilt": "Собрана",
"settings.versionCheck": "Проверить обновления",
"settings.versionChecking": "Проверяю…",
"settings.versionUpToDate": "v{version} — последняя версия.",
"settings.versionAvailable": "Доступна v{version}: Overdrive предложит обновление.",
"settings.versionFailed": "Проверка не удалась. Попробуйте ещё раз через минуту.",
"settings.versionNote": "Overdrive проверяет при каждом запуске и ставит обновление отсюда.",
"settings.licenses": "Лицензии",
"settings.licensesValue": "Открытый код",
"settings.licensesLead": "Overdrive — свободное ПО: здесь лицензии приложения и проектов, на которых оно построено.",
"settings.licensesSide": "Включённые уведомления",
"settings.licensesEmpty": "В этой сборке уведомлений нет."
}
+85 -1
View File
@@ -87,6 +87,7 @@
"downloadState.file-downloaded": "فائل ڈاؤن لوڈ ہو گئی؛ اس کی شکل کا معائنہ کریں",
"downloadState.finishing": "مکمل کیا جا رہا ہے…",
"downloadState.installed": "انسٹال ہو گیا",
"downloadState.streamed": "PS5 کو بھیج دیا گیا",
"downloadState.installer-required": "ڈاؤن لوڈ ہو گیا؛ انسٹال کے لیے تیار",
"downloadState.installing": "انسٹال ہو رہا ہے",
"downloadState.paused": "توقف دی گئی",
@@ -116,11 +117,33 @@
"downloads.installHint": "ختم کرنے کے لیے Overdrive بند کریں: ShadowMount انسٹال کرتا ہے جب کوئی گیم کھلی نہ ہو۔",
"downloads.installRequested": "انسٹال درخواست دی گئی۔ ختم کرنے کے لیے Overdrive بند کریں: ShadowMount انسٹال کرتا ہے جب کوئی گیم کھلی نہ ہو۔",
"downloads.installed": "انسٹال ہو گیا۔ یہ ہوم اسکرین پر ہے۔",
"downloads.streamed": "PS5 اسے ڈاؤن لوڈ اور انسٹال کرتا ہے: ڈاؤن لوڈز میں دیکھیں۔ آپ Overdrive بند کر سکتے ہیں۔",
"download.button.streamed": "PS5 پر",
"downloads.streamedProxy": "پس منظر شروع نہیں ہوا: Overdrive اسے PS5 کو دے رہا ہے۔ مکمل ہونے تک کھلا رکھیں۔",
"downloads.proxyProgress": "Overdrive کے ذریعے: {total} میں سے {done} ({percent}%)۔ Overdrive کھلا رکھیں۔",
"downloads.proxyDone": "PS5 کو سب مل گیا: انسٹال اس کے ڈاؤن لوڈز میں مکمل ہوگا۔",
"stream.title": "کیسے ڈاؤن لوڈ کریں؟",
"stream.console.title": "PS5 پر پس منظر میں",
"stream.console.body": "PS5 خود ڈاؤن لوڈ کرے گا۔ Overdrive بند کریں اور کھیلتے رہیں۔",
"stream.here.title": "Overdrive میں",
"stream.here.body": "یہیں ڈاؤن لوڈ ہوگا، روکنے اور جاری رکھنے کے ساتھ۔ Overdrive کھلا رہے گا۔",
"stream.recommended": "تجویز کردہ",
"stream.faster": "زیادہ تیز",
"stream.remember": "میرا انتخاب یاد رکھیں",
"stream.rememberDetail": "ہر PKG کے لیے۔ ترتیبات میں بدلیں۔",
"settings.pkgMode": "PKG ڈاؤن لوڈز",
"settings.pkgModeDetail": "PKG گیم کہاں ڈاؤن لوڈ ہو۔",
"settings.pkgMode.ask": "پوچھیں",
"settings.pkgMode.console": "پس منظر",
"settings.pkgMode.overdrive": "Overdrive",
"downloads.streamedBackground": "PS5 ڈاؤن لوڈ کر رہا ہے: اس کے ڈاؤن لوڈز میں دیکھیں۔ آپ Overdrive بند کر سکتے ہیں۔",
"downloads.helperProgress": "PS5 پر: {total} میں سے {done} ({percent}%)۔ اس کے ڈاؤن لوڈز میں دیکھیں۔",
"downloads.installedFreed": "انسٹال ہو گیا۔ PKG حذف کر دیا گیا: {size} خالی ہوا۔",
"downloads.notice.complete": "ڈاؤن لوڈ مکمل ہو گیا",
"downloads.notice.confirm": "فائل مل گئی، Overdrive میں اس کی تصدیق کریں",
"downloads.notice.failed": "ڈاؤن لوڈ ناکام",
"downloads.notice.installed": "گیم انسٹال ہو گئی",
"downloads.notice.streamed": "PS5 کے ڈاؤن لوڈز میں بھیج دیا گیا",
"downloads.packagesExtracted": "حزمے {path} میں نکالے گئے۔ PKG انسٹالر درکار ہے۔",
"downloads.panel.activity": "سرگرمی",
"downloads.panel.average": "اوسط",
@@ -392,6 +415,9 @@
"settings.deletePackagesDetail": "کنسول کے گیم انسٹال کرنے پر جگہ خالی کرتا ہے۔ فائلیں رکھنے کے لیے اسے بند کریں۔",
"settings.on": "آن",
"settings.off": "بند",
"settings.disk": "ڈاؤن لوڈ کے لیے ڈسک",
"settings.diskAuto": "خودکار",
"settings.diskDetail": "نئی گیمز کس ڈرائیو میں محفوظ ہوں گی۔ اگلی گیمز کے لیے بھی یہی منتخب رہے گی۔",
"settings.downloads": "ڈاؤن لوڈز",
"settings.speedLimit": "رفتار کی حد",
"settings.speedLimitDetail": "دوسرے آلات کے لیے بینڈوڈتھ چھوڑتا ہے۔ ایپ اپڈیٹس کبھی محدود نہیں ہوتیں۔",
@@ -479,5 +505,63 @@
"versions.recommended": "سفارش کی جاتی ہے · PS5 پر اصل میں چلتا ہے",
"versions.sources": "{count, plural, one {# ذریعہ} other {# ذرائع}}",
"versions.thisPage": "یہ صفحہ",
"versions.title": "ورژن منتخب کریں"
"versions.title": "ورژن منتخب کریں",
"settings.group.general": "عمومی",
"settings.group.downloads": "ڈاؤن لوڈز",
"settings.group.services": "منسلک سروسز",
"settings.group.about": "تعارف",
"settings.recommended": "تجویز کردہ",
"settings.faster": "زیادہ تیز",
"settings.unlink": "منسلک ہٹائیں",
"settings.unlinkTitle": "{service} کو منسلک ہٹائیں؟",
"settings.unlinkBody": "اس کی کلید اس کنسول سے حذف ہو جاتی ہے، اور دوبارہ منسلک کرنے تک اس کے ذریعے ڈاؤن لوڈز رک جاتے ہیں۔",
"settings.speedUnlimitedDetail": "جتنی تیزی ماخذ اجازت دے",
"settings.speedSide": "50 GB کا ایک گیم",
"settings.speedSideBest": "ماخذ جتنا دے",
"settings.speedSideNote": "ماخذ کی پوری رفتار پر اوقات۔ سست ماخذ بغیر حد کے بھی سست رہتا ہے۔",
"settings.minutes": "{n} منٹ",
"settings.hoursMinutes": "{h} گھنٹے {m} منٹ",
"settings.diskSide": "ڈسکس پر جگہ",
"settings.diskAutoDetail": "گیم کے لیے جگہ والی پہلی ڈسک",
"settings.diskSideAuto": "خودکار اندرونی اسٹوریج سے شروع کر کے گیم کے لیے جگہ والی پہلی ڈسک لیتا ہے۔",
"settings.diskSideNote": "نئے گیمز یہاں جاتے ہیں۔ اگر ڈسک موجود نہ ہو یا بھری ہو تو گیم کا صفحہ دوسری ڈسک تجویز کرتا ہے۔",
"settings.pkgAskDetail": "ہر ڈاؤن لوڈ سے پہلے، اور جواب یاد بھی رکھ سکتا ہے",
"settings.pkgConsoleDetail": "PS5 اسے ڈاؤن لوڈ کرتا ہے: Overdrive بند کریں اور کنسول استعمال کرتے رہیں",
"settings.pkgOverdriveDetail": "یہیں ڈاؤن لوڈ ہوتا ہے، وقفہ اور دوبارہ شروع کے ساتھ: Overdrive کھلا رہتا ہے",
"settings.pkgSide": "گیم کہاں جاتا ہے",
"settings.pkgSideConsole": "PS5 کے اپنے ڈاؤن لوڈز",
"settings.pkgSideOverdrive": "Overdrive کا ڈاؤن لوڈ صفحہ",
"settings.cleanupOffDetail": "PKG فائل downloads/packages میں رہتی ہے",
"settings.cleanupSide": "انسٹال کے بعد",
"settings.cleanupSideOn": "ڈاؤن لوڈ ڈسک پر PKG کی لی گئی جگہ خالی کرتا ہے۔ جس PKG کو کنسول مسترد کر دے وہ ہاتھ سے انسٹال کرنے کے لیے رہتا ہے۔",
"settings.cleanupSideOff": "فائل downloads/packages میں رہتی ہے: دوبارہ انسٹال یا کاپی کرنے کے لیے مفید، مگر جگہ لیتی ہے۔",
"settings.vibrationSide": "جھلک",
"settings.vibrationSideOn": "اشیاء کے درمیان جانے پر {level}% کمپن۔",
"settings.vibrationSideOff": "نیویگیشن کے دوران کوئی کمپن نہیں۔",
"settings.languageSide": "کیسا دکھتا ہے",
"settings.languageSideNote": "ایپ کے اپنے حصے فوراً بدل جاتے ہیں، بغیر دوبارہ شروع کیے۔",
"settings.languageSideRtl": "دائیں سے بائیں لکھی جانے والی زبان: انٹرفیس اُلٹ جاتا ہے۔",
"settings.torboxLead": "TorBox کے سرورز پر لنکس اور میگنٹ ڈاؤن لوڈ کرتا ہے۔ کلید اسی کنسول پر رہتی ہے۔",
"settings.realdebridLead": "Real-Debrid کے سرورز پر لنکس اور میگنٹ ڈاؤن لوڈ کرتا ہے۔ ٹوکن اسی کنسول پر رہتا ہے۔",
"settings.premiumizeLead": "Premiumize کے سرورز پر لنکس اور میگنٹ ڈاؤن لوڈ کرتا ہے۔ اس کی API کلید سے سیٹ ہوتا ہے۔",
"settings.qbittorrentLead": "گھر کے نیٹ ورک پر آپ کا qBittorrent ٹورنٹ لاتا ہے؛ کنسول تیار فائل لے لیتا ہے۔",
"settings.account": "اکاؤنٹ",
"settings.serviceSideLinked": "کلید اسی کنسول پر رہتی ہے: دوبارہ شروع اور اپ ڈیٹس کے بعد بھی باقی رہتی ہے۔",
"settings.servicePhone": "فون کے کیمرے سے کوڈ اسکین کریں، پھر Overdrive کی منظوری دیں۔ کوئی کلید کاپی کرنے کی ضرورت نہیں۔",
"settings.qbitSideSet": "پتہ اور لاگ اِن اسی کنسول پر رہتے ہیں۔ WebUI کا کنسول کے نیٹ ورک پر ہونا ضروری ہے۔",
"settings.version": "ورژن",
"settings.versionLead": "چل رہی بلڈ، اور اپ ڈیٹس کہاں سے آتی ہیں۔",
"settings.versionBuild": "بلڈ",
"settings.versionBuilt": "بنائی",
"settings.versionCheck": "اپ ڈیٹس چیک کریں",
"settings.versionChecking": "چیک ہو رہا ہے…",
"settings.versionUpToDate": "v{version} تازہ ترین ہے۔",
"settings.versionAvailable": "v{version} دستیاب ہے: Overdrive اسے پیش کرے گا۔",
"settings.versionFailed": "چیک ناکام رہا۔ تھوڑی دیر بعد دوبارہ کوشش کریں۔",
"settings.versionNote": "Overdrive ہر بار کھلنے پر چیک کرتا ہے اور یہیں سے اپ ڈیٹ انسٹال کرتا ہے۔",
"settings.licenses": "لائسنسز",
"settings.licensesValue": "اوپن سورس",
"settings.licensesLead": "Overdrive آزاد سافٹ ویئر ہے: یہ ایپ اور ان منصوبوں کے لائسنس ہیں جن پر یہ بنی ہے۔",
"settings.licensesSide": "شامل نوٹسز",
"settings.licensesEmpty": "اس بلڈ میں یہ نوٹسز موجود نہیں ہیں۔"
}
+85 -1
View File
@@ -87,6 +87,7 @@
"downloadState.file-downloaded": "文件已下载;请检查其格式",
"downloadState.finishing": "正在完成…",
"downloadState.installed": "已安装",
"downloadState.streamed": "已发送到 PS5",
"downloadState.installer-required": "已下载,可以安装",
"downloadState.installing": "正在安装",
"downloadState.paused": "已暂停",
@@ -116,11 +117,33 @@
"downloads.installHint": "请关闭 Overdrive 以完成:没有游戏运行时 ShadowMount 才会安装。",
"downloads.installRequested": "已请求安装。请关闭 Overdrive 以完成:没有游戏运行时 ShadowMount 才会安装。",
"downloads.installed": "已安装,已在主屏幕上。",
"downloads.streamed": "PS5 会下载并安装:可在其“下载”中查看。可以关闭 Overdrive。",
"download.button.streamed": "在 PS5 上",
"downloads.streamedProxy": "后台未能启动:由 Overdrive 传给 PS5。请保持打开直到完成。",
"downloads.proxyProgress": "经由 Overdrive:{done} / {total}({percent}%)。请保持 Overdrive 打开。",
"downloads.proxyDone": "PS5 已全部收到:将在其“下载”中完成安装。",
"stream.title": "要怎么下载?",
"stream.console.title": "在 PS5 后台下载",
"stream.console.body": "由 PS5 自己下载。可以关闭 Overdrive 继续玩。",
"stream.here.title": "在 Overdrive 中",
"stream.here.body": "在这里下载,可暂停和继续。Overdrive 需保持打开。",
"stream.recommended": "推荐",
"stream.faster": "更快",
"stream.remember": "记住我的选择",
"stream.rememberDetail": "适用于所有 PKG,可在设置中更改。",
"settings.pkgMode": "PKG 下载",
"settings.pkgModeDetail": "PKG 游戏的下载方式。",
"settings.pkgMode.ask": "每次询问",
"settings.pkgMode.console": "后台",
"settings.pkgMode.overdrive": "Overdrive",
"downloads.streamedBackground": "PS5 正在下载:请在其“下载”中查看。可以关闭 Overdrive。",
"downloads.helperProgress": "在 PS5 上:{done} / {total}({percent}%)。请在其“下载”中查看。",
"downloads.installedFreed": "已安装。PKG 已删除:释放了 {size}。",
"downloads.notice.complete": "下载完成",
"downloads.notice.confirm": "已找到文件,请在 Overdrive 中确认",
"downloads.notice.failed": "下载失败",
"downloads.notice.installed": "游戏已安装",
"downloads.notice.streamed": "已发送到 PS5 的下载",
"downloads.packagesExtracted": "安装包已解压到 {path}。需要 PKG 安装程序。",
"downloads.panel.activity": "活动",
"downloads.panel.average": "平均",
@@ -392,6 +415,9 @@
"settings.deletePackagesDetail": "主机安装好游戏后释放空间。关闭此项可保留文件。",
"settings.on": "开",
"settings.off": "关闭",
"settings.disk": "下载磁盘",
"settings.diskAuto": "自动",
"settings.diskDetail": "保存新游戏的驱动器。之后下载的游戏仍会使用它。",
"settings.downloads": "下载",
"settings.speedLimit": "限速",
"settings.speedLimitDetail": "为其他设备保留带宽。应用更新始终不限速。",
@@ -479,5 +505,63 @@
"versions.recommended": "推荐 · 在 PS5 上原生运行",
"versions.sources": "{count, plural, other {# 个来源}}",
"versions.thisPage": "本页",
"versions.title": "选择版本"
"versions.title": "选择版本",
"settings.group.general": "常规",
"settings.group.downloads": "下载",
"settings.group.services": "已关联的服务",
"settings.group.about": "关于",
"settings.recommended": "推荐",
"settings.faster": "更快",
"settings.unlink": "取消关联",
"settings.unlinkTitle": "取消关联{service}?",
"settings.unlinkBody": "密钥将从这台主机上删除,在重新关联之前,通过它的下载会停止。",
"settings.speedUnlimitedDetail": "以来源允许的最快速度",
"settings.speedSide": "一个 50 GB 的游戏",
"settings.speedSideBest": "取决于来源",
"settings.speedSideNote": "按来源满速计算的时间。较慢的来源即使不限速也依然慢。",
"settings.minutes": "{n} 分钟",
"settings.hoursMinutes": "{h} 小时 {m} 分",
"settings.diskSide": "磁盘空间",
"settings.diskAutoDetail": "第一个放得下游戏的磁盘",
"settings.diskSideAuto": "自动会从内置存储开始,选第一个放得下游戏的磁盘。",
"settings.diskSideNote": "新游戏会放在这里。如果磁盘不存在或已满,游戏页面会提供另一个。",
"settings.pkgAskDetail": "每次下载前询问,也可以记住选择",
"settings.pkgConsoleDetail": "由 PS5 下载:关闭 Overdrive,主机照常使用",
"settings.pkgOverdriveDetail": "在这里下载,可暂停和继续:Overdrive 保持打开",
"settings.pkgSide": "游戏去哪里",
"settings.pkgSideConsole": "PS5 自带的下载",
"settings.pkgSideOverdrive": "Overdrive 的下载页面",
"settings.cleanupOffDetail": "PKG 留在 downloads/packages",
"settings.cleanupSide": "安装之后",
"settings.cleanupSideOn": "释放 PKG 在下载磁盘上占用的空间。主机拒绝的 PKG 会留着,供手动安装。",
"settings.cleanupSideOff": "文件留在 downloads/packages:方便重装或复制,但会占用空间。",
"settings.vibrationSide": "预览",
"settings.vibrationSideOn": "在条目间移动时以 {level}% 振动。",
"settings.vibrationSideOff": "浏览时不会振动。",
"settings.languageSide": "显示效果",
"settings.languageSideNote": "应用自身的部分会立即改变,无需重启。",
"settings.languageSideRtl": "从右往左书写的语言:界面会镜像。",
"settings.torboxLead": "在 TorBox 的服务器上下载链接和磁力链接。密钥留在本主机上。",
"settings.realdebridLead": "在 Real-Debrid 的服务器上下载链接和磁力链接。令牌留在本主机上。",
"settings.premiumizeLead": "在 Premiumize 的服务器上下载链接和磁力链接。用它的 API 密钥设置。",
"settings.qbittorrentLead": "家庭网络里的 qBittorrent 负责下载种子文件,主机取回完成的文件。",
"settings.account": "账户",
"settings.serviceSideLinked": "密钥留在本主机上:重启和更新后依然保留。",
"settings.servicePhone": "用手机相机扫描代码,然后批准 Overdrive。无需复制密钥。",
"settings.qbitSideSet": "地址和登录信息留在本主机上。WebUI 必须位于主机所在的网络中。",
"settings.version": "版本",
"settings.versionLead": "正在运行的构建,以及更新的来源。",
"settings.versionBuild": "构建",
"settings.versionBuilt": "编译于",
"settings.versionCheck": "检查更新",
"settings.versionChecking": "检查中…",
"settings.versionUpToDate": "v{version} 已是最新。",
"settings.versionAvailable": "有 v{version} 可用:Overdrive 会提示更新。",
"settings.versionFailed": "检查失败。请稍后再试。",
"settings.versionNote": "Overdrive 每次启动都会检查,并从这里安装更新。",
"settings.licenses": "许可",
"settings.licensesValue": "开源",
"settings.licensesLead": "Overdrive 是自由软件:这些是应用及它所依赖的项目的许可证。",
"settings.licensesSide": "随附的声明",
"settings.licensesEmpty": "此构建中没有这些声明。"
}
+85 -1
View File
@@ -87,6 +87,7 @@
"downloadState.file-downloaded": "檔案已下載;請檢查檔案格式",
"downloadState.finishing": "正在完成…",
"downloadState.installed": "已安裝",
"downloadState.streamed": "已傳送到 PS5",
"downloadState.installer-required": "已下載,可以安裝",
"downloadState.installing": "正在安裝",
"downloadState.paused": "已暫停",
@@ -118,12 +119,34 @@
"downloads.titleMismatch": "檔案是 {found},遊戲是 {expected}:可能是其他遊戲,或不同地區版本?",
"downloads.installRequested": "已送出安裝要求。請關閉 Overdrive 以完成安裝:沒有遊戲執行時,ShadowMount 才會開始安裝。",
"downloads.installed": "已安裝,可在主畫面找到。",
"downloads.streamed": "PS5 會下載並安裝:可在其「下載」中查看。可以關閉 Overdrive。",
"download.button.streamed": "在 PS5 上",
"downloads.streamedProxy": "背景未能啟動:由 Overdrive 傳給 PS5。請保持開啟直到完成。",
"downloads.proxyProgress": "經由 Overdrive:{done} / {total}({percent}%)。請保持 Overdrive 開啟。",
"downloads.proxyDone": "PS5 已全部收到:將在其「下載」中完成安裝。",
"stream.title": "要怎麼下載?",
"stream.console.title": "在 PS5 背景下載",
"stream.console.body": "由 PS5 自己下載。可以關閉 Overdrive 繼續玩。",
"stream.here.title": "在 Overdrive 中",
"stream.here.body": "在這裡下載,可暫停和繼續。Overdrive 需保持開啟。",
"stream.recommended": "推薦",
"stream.faster": "更快",
"stream.remember": "記住我的選擇",
"stream.rememberDetail": "適用於所有 PKG,可在設定中更改。",
"settings.pkgMode": "PKG 下載",
"settings.pkgModeDetail": "PKG 遊戲的下載方式。",
"settings.pkgMode.ask": "每次詢問",
"settings.pkgMode.console": "背景",
"settings.pkgMode.overdrive": "Overdrive",
"downloads.streamedBackground": "PS5 正在下載:請在其「下載」中查看。可以關閉 Overdrive。",
"downloads.helperProgress": "在 PS5 上:{done} / {total}({percent}%)。請在其「下載」中查看。",
"downloads.installedFreed": "已安裝。PKG 已刪除,已釋放 {size}。",
"downloads.notice.captured": "已擷取連結,請返回 Overdrive",
"downloads.notice.confirm": "已找到檔案,請在 Overdrive 中確認",
"downloads.notice.complete": "下載完成",
"downloads.notice.failed": "下載失敗",
"downloads.notice.installed": "遊戲已安裝",
"downloads.notice.streamed": "已傳送到 PS5 的下載",
"downloads.packagesExtracted": "安裝檔已解壓縮至 {path}。需要 PKG 安裝程式。",
"downloads.panel.activity": "活動",
"downloads.panel.average": "平均",
@@ -401,6 +424,9 @@
"settings.deletePackagesDetail": "主機安裝遊戲後會釋放空間。若要保留檔案,請關閉此選項。",
"settings.on": "開啟",
"settings.off": "關閉",
"settings.disk": "下載磁碟",
"settings.diskAuto": "自動",
"settings.diskDetail": "儲存新遊戲的磁碟機。之後下載的遊戲仍會使用它。",
"settings.downloads": "下載",
"settings.speedLimit": "限速",
"settings.speedLimitDetail": "為其他裝置保留頻寬。應用程式更新不受此限速影響。",
@@ -511,5 +537,63 @@
"capture.confirm.detail": "此檔案來源沒有標示這是哪一款遊戲。只有在確定是你選擇的遊戲時才下載。",
"capture.confirm.noName": "未命名檔案",
"capture.confirm.accept": "下載",
"capture.confirm.refuse": "繼續尋找"
"capture.confirm.refuse": "繼續尋找",
"settings.group.general": "一般",
"settings.group.downloads": "下載",
"settings.group.services": "已連結的服務",
"settings.group.about": "關於",
"settings.recommended": "推薦",
"settings.faster": "更快",
"settings.unlink": "取消連結",
"settings.unlinkTitle": "取消連結{service}?",
"settings.unlinkBody": "金鑰將從這台主機上刪除,在重新連結之前,透過它的下載會停止。",
"settings.speedUnlimitedDetail": "以來源允許的最快速度",
"settings.speedSide": "一個 50 GB 的遊戲",
"settings.speedSideBest": "取決於來源",
"settings.speedSideNote": "按來源滿速計算的時間。較慢的來源即使不限速也依然慢。",
"settings.minutes": "{n} 分鐘",
"settings.hoursMinutes": "{h} 小時 {m} 分",
"settings.diskSide": "磁碟空間",
"settings.diskAutoDetail": "第一個放得下遊戲的磁碟",
"settings.diskSideAuto": "自動會從內建儲存空間開始,選第一個放得下遊戲的磁碟。",
"settings.diskSideNote": "新遊戲會放在這裡。如果磁碟不存在或已滿,遊戲頁面會提供另一個。",
"settings.pkgAskDetail": "每次下載前詢問,也可以記住選擇",
"settings.pkgConsoleDetail": "由 PS5 下載:關閉 Overdrive,主機照常使用",
"settings.pkgOverdriveDetail": "在這裡下載,可暫停和繼續:Overdrive 保持開啟",
"settings.pkgSide": "遊戲去哪裡",
"settings.pkgSideConsole": "PS5 內建的下載",
"settings.pkgSideOverdrive": "Overdrive 的下載頁面",
"settings.cleanupOffDetail": "PKG 留在 downloads/packages",
"settings.cleanupSide": "安裝之後",
"settings.cleanupSideOn": "釋放 PKG 在下載磁碟上佔用的空間。主機拒絕的 PKG 會留著,供手動安裝。",
"settings.cleanupSideOff": "檔案留在 downloads/packages:方便重裝或複製,但會佔用空間。",
"settings.vibrationSide": "預覽",
"settings.vibrationSideOn": "在項目間移動時以 {level}% 振動。",
"settings.vibrationSideOff": "瀏覽時不會振動。",
"settings.languageSide": "顯示效果",
"settings.languageSideNote": "應用程式自身的部分會立即改變,無需重新啟動。",
"settings.languageSideRtl": "從右往左書寫的語言:介面會鏡像。",
"settings.torboxLead": "在 TorBox 的伺服器上下載連結和磁力連結。金鑰留在本主機上。",
"settings.realdebridLead": "在 Real-Debrid 的伺服器上下載連結和磁力連結。權杖留在本主機上。",
"settings.premiumizeLead": "在 Premiumize 的伺服器上下載連結和磁力連結。用它的 API 金鑰設定。",
"settings.qbittorrentLead": "家庭網路裡的 qBittorrent 負責下載種子檔案,主機取回完成的檔案。",
"settings.account": "帳戶",
"settings.serviceSideLinked": "金鑰留在本主機上:重新啟動和更新後依然保留。",
"settings.servicePhone": "用手機相機掃描代碼,然後批准 Overdrive。無需複製金鑰。",
"settings.qbitSideSet": "位址和登入資訊留在本主機上。WebUI 必須位於主機所在的網路中。",
"settings.version": "版本",
"settings.versionLead": "正在執行的版本,以及更新的來源。",
"settings.versionBuild": "組建",
"settings.versionBuilt": "建置於",
"settings.versionCheck": "檢查更新",
"settings.versionChecking": "檢查中…",
"settings.versionUpToDate": "v{version} 已是最新。",
"settings.versionAvailable": "有 v{version} 可用:Overdrive 會提示更新。",
"settings.versionFailed": "檢查失敗。請稍後再試。",
"settings.versionNote": "Overdrive 每次啟動都會檢查,並從這裡安裝更新。",
"settings.licenses": "授權",
"settings.licensesValue": "開放原始碼",
"settings.licensesLead": "Overdrive 是自由軟體:這些是應用程式及它所依賴的專案的授權。",
"settings.licensesSide": "隨附的聲明",
"settings.licensesEmpty": "此版本中沒有這些聲明。"
}
+8 -2
View File
@@ -5,6 +5,7 @@ import { jsx, jsxs } from 'react/jsx-runtime';
import { useEffect, useState } from 'react';
import { AppRegistry, FocusScope, ReleaseImages, View } from '@ps5-react/core';
import { BrowsePage } from './browse/BrowsePage.jsx';
import { FavoritesScreen } from './browse/FavoritesScreen.jsx';
import { DownloadsScreen } from './downloads/DownloadsScreen.jsx';
import { FilesScreen } from './files/FilesScreen.jsx';
import { GameScreen } from './game/index.jsx';
@@ -18,12 +19,14 @@ import { NavProvider, useIsImmersive, useNavActions, useNavState } from './state
import { StoreFreeze, StoreProvider } from './state/store.jsx';
import { Header } from './ui/Header.jsx';
import { Legend } from './ui/Legend.jsx';
import { SettingsScreen } from './ui/SettingsScreen.jsx';
import { Splash } from './ui/Splash.jsx';
import { UpdateDialog } from './ui/UpdateDialog.jsx';
import { useInterfaceHaptics } from './ui/haptics.js';
import { useInterfaceSounds } from './ui/sounds.js';
import { useShowLibrary } from './ui/useSections.js';
import { VERSION } from './version.js';
import { BUILD } from './build.js';
function Screen({ screen: screen2 }) {
const { openGame, pop } = useNavActions();
@@ -36,6 +39,8 @@ import { VERSION } from './version.js';
if (screen2.name === "game") return /* @__PURE__ */ (0, jsx)(GameScreen, { game: getGame(screen2.id), download: screen2.download, onBack: pop, onOpenGame: openCard });
if (screen2.name === "downloads") return /* @__PURE__ */ (0, jsx)(DownloadsScreen, { onBack: pop, onBrowse: () => showLibrary(NO_FILTERS, "newest", "spotlight:cta", "discover"), onOpenGame: openGame });
if (screen2.name === "files") return /* @__PURE__ */ (0, jsx)(FilesScreen, { onBack: pop });
if (screen2.name === "settings") return /* @__PURE__ */ (0, jsx)(SettingsScreen, { onBack: pop });
if (screen2.name === "favorites") return /* @__PURE__ */ (0, jsx)(FavoritesScreen, { onBack: pop, onBrowse: () => showLibrary(NO_FILTERS, "newest", "spotlight:cta", "discover") });
return /* @__PURE__ */ (0, jsx)(SearchScreen, { onBack: pop, onOpenGame: openCard });
}
// The store front stays mounted under every other screen; while hidden it lets its images go (the
@@ -102,6 +107,7 @@ import { VERSION } from './version.js';
? /* @__PURE__ */ (0, jsx)(LocaleProvider, { children: /* @__PURE__ */ (0, jsx)(StoreProvider, { children: /* @__PURE__ */ (0, jsx)(NavProvider, { children: /* @__PURE__ */ (0, jsx)(App, {}) }) }) })
: /* @__PURE__ */ (0, jsx)(Splash, { onDone: () => setBooted(true) }) });
}
// Which build wrote this session of app.log.
console.log(`[overdrive] version ${VERSION}`);
// Which build wrote this session of app.log: a draft rebuilt with the same version is otherwise
// indistinguishable from the one before it.
console.log(`[overdrive] version ${VERSION} build ${BUILD.commit}${BUILD.built ? ` ${BUILD.built}` : ""}`);
AppRegistry.registerComponent("overdrive", () => () => /* @__PURE__ */ (0, jsx)(Boot, {}));
+30 -7
View File
@@ -15,6 +15,10 @@ import { FocusScope, Text, View, motion, useIsFocused } from '@ps5-react/core';
var COLUMNS2 = 6;
var LTR2 = { direction: "ltr" };
var CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".split("");
// What the letters miss, for a password or a key: the URL keyboard had no "! ( ) [ ] { } ; , < > '"
// and the like, so a qBittorrent password with one could not be typed at all. A second page,
// since 32 more keys on the first would not fit the dialog.
var SYMBOLS = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~".split("");
var MAX_QUERY = 40;
var TO_RESULTS = "search-results";
var TYPE2 = { button: "cross", label: "hints.type" };
@@ -51,19 +55,22 @@ import { FocusScope, Text, View, motion, useIsFocused } from '@ps5-react/core';
const { rtl } = useLocale();
const edge = rtl ? 0 : COLUMNS2 - 1;
const [upper, setUpper] = (0, useState)(false);
const characters = url ? [...CHARACTERS, ...":/._-?=&%+#@~".split("")] : CHARACTERS;
const [symbols, setSymbols] = (0, useState)(false);
const characters = symbols ? SYMBOLS : url ? [...CHARACTERS, ...":/._-?=&%+#@~".split("")] : CHARACTERS;
const onKey = (0, useCallback)(
(value) => value === "case" ? setUpper((value2) => !value2) : value === "done" ? onSubmit() : onChange((query) => edit(query, value, url ? 8192 : MAX_QUERY)),
(value) => value === "case" ? setUpper((value2) => !value2) : value === "page" ? setSymbols((shown) => !shown) : value === "done" ? onSubmit() : onChange((query) => edit(query, value, url ? 8192 : MAX_QUERY)),
[onChange, onSubmit, url]
);
const onAction = (action) => {
if (action === "square") onKey("delete");
else if (action === "triangle" && !url) onKey(" ");
else if (action === "triangle" && (!url || symbols)) onKey(" ");
else
return false;
return true;
};
return /* @__PURE__ */ (0, jsx)(FocusScope, { wrap: true, focusKey, onAction, children: /* @__PURE__ */ (0, jsxs)(View, { style: [{ width: 564, flexDirection: "row", flexWrap: "wrap", gap: 12 }, LTR2], children: [
// The URL dialog's own Confirm and Cancel sit to the right of the keys: wrapping at the last
// column would keep focus here, so that dialog's keyboard lets the move leave.
return /* @__PURE__ */ (0, jsx)(FocusScope, { wrap: !url, focusKey, onAction, children: /* @__PURE__ */ (0, jsxs)(View, { style: [{ width: 564, flexDirection: "row", flexWrap: "wrap", gap: 12 }, LTR2], children: [
characters.map(
(character, i) => /* @__PURE__ */ (0, jsx)(
Key,
@@ -77,14 +84,30 @@ import { FocusScope, Text, View, motion, useIsFocused } from '@ps5-react/core';
character
)
),
// The URL keyboard's second page: the toggle stays mounted across the two pages, so the focus
// stays on it (press again to go back). Search needs no punctuation, and the key moved the
// rows there, so it has none.
url ? /* @__PURE__ */ (0, jsx)(
Key,
{
compact: url,
// The label names what sits behind it, the characters a URL or a password asks for: "&123"
// once promised the digits the first page already has. On "ABC" the letters come back.
label: symbols ? "ABC" : "()@?~",
value: "page",
onKey
},
"page"
) : null,
/* @__PURE__ */ (0, jsx)(
Key,
{
wide: true,
compact: url,
label: url ? upper ? t2("keyboard.lowercase") : t2("keyboard.uppercase") : t2("keyboard.space"),
value: url ? "case" : " ",
button: url ? void 0 : "triangle",
// On the symbols page the wide key is the space, which a URL keyboard otherwise has nowhere.
label: !url || symbols ? t2("keyboard.space") : upper ? t2("keyboard.lowercase") : t2("keyboard.uppercase"),
value: !url || symbols ? " " : "case",
button: !url || symbols ? "triangle" : void 0,
onKey,
toResults: url || !rtl ? void 0 : TO_RESULTS
}
+2 -1
View File
@@ -6,6 +6,7 @@ import test from 'node:test';
register(new URL('./download-queue-test-loader.mjs', import.meta.url), import.meta.url);
const {applyUpdate, fetchUpdate, loadUpdate, valid} = await import('./catalog-update.js');
const {FileSystem, Http} = await import('@ps5-react/core');
const {VERSION} = await import('../version.js');
const line = (id, extra = {}) => ({
id, title: 'Demo ' + id, platform: id.startsWith('PPSA') ? 'ps5' : 'ps4', providers: ['vikingfile'], direct: false,
@@ -13,7 +14,7 @@ const line = (id, extra = {}) => ({
art: {cover: 'https://example.com/c.png', tint: '#112233'}, added: '2026-10-21', source: 'superpsx', ...extra,
});
const update = (extra = {}) => ({
format: 1, version: '1.0.9', base: 'abc', generated: '2026-10-22T02:00:00Z',
format: 1, version: VERSION, base: 'abc', generated: '2026-10-22T02:00:00Z',
games: [{block: 'games', line: line('CUSA00001')}, {block: 'twins', line: line('PPSA00002', {work: 7})}],
removed: ['CUSA00009'],
releases: {CUSA00001: [{id: 'r1', filename: 'CUSA00001-game-1-r1.pkg', format: 'pkg', url: 'https://example.com'}], CUSA00003: null},
+194
View File
@@ -0,0 +1,194 @@
// Linking a debrid account with the phone instead of typing its key with the controller: the console
// asks the service for a short code, shows it with a QR code of the service's own approval page, and
// waits while the player signs in on the phone and approves Overdrive. The service then hands the
// console a token, saved like a typed key (download-services.js). TorBox and Real-Debrid both have
// this "device flow"; Premiumize's needs a client registered with it, so it keeps the typed key.
//
// TorBox: GET /v1/api/user/auth/device/start?app=Overdrive -> device_code, code, interval,
// expires_in, verification_url(_complete), friendly_verification_url
// POST /v1/api/user/auth/device/token {"device_code"} -> DEVICE_CODE_NOT_USED until
// approved, then the token (answers checked against the live API, 2026-10-25)
// Real-Debrid: GET /oauth/v2/device/code?client_id=X245A4XAIBGVM&new_credentials=yes -> device_code,
// user_code, interval, expires_in, verification_url, direct_verification_url
// GET /oauth/v2/device/credentials?client_id=…&code=<device_code> -> 403 until approved,
// then a client_id and client_secret of this user's own
// POST /oauth/v2/token (client_id, client_secret, code, grant_type) -> access_token,
// expires_in, refresh_token; the same call with the refresh token as `code` renews it
// X245A4XAIBGVM is the client Real-Debrid publishes for open-source apps (api.real-debrid.com).
var TORBOX = "https://api.torbox.app/v1/api/user/auth/device";
var REALDEBRID = "https://api.real-debrid.com/oauth/v2";
var OPEN_SOURCE_CLIENT = "X245A4XAIBGVM";
var DEVICE_GRANT = "http://oauth.net/grant_type/device/1.0";
var APP = "Overdrive";
class Expired extends Error {}
async function json(response) {
const text = await response.text();
try {
return text ? JSON.parse(text) : null;
} catch {
return null;
}
}
// "https://tor.box/link" reads better on a TV without its scheme.
var bare = (url) => String(url || "").replace(/^https?:\/\//, "").replace(/\/$/, "");
function createDeviceLogin({ http, service, now = Date.now, wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) }) {
if (service !== "torbox" && service !== "realdebrid") throw new Error(`${service} has no phone login.`);
const request = (url, options = {}) => http.request(url, { maxBytes: 64 * 1024, ...options });
/** Asks for a code: { code, qr, page, expiresAt, interval, device }. */
async function start({ signal } = {}) {
if (service === "torbox") {
const response = await request(`${TORBOX}/start?app=${APP}`, { signal });
const body = await json(response);
const data = body?.data;
if (!body?.success || !data?.device_code || !data.code) throw new Error(body?.detail || `TorBox answered HTTP ${response.status}.`);
return {
code: String(data.code),
qr: data.verification_url_complete || data.verification_url,
page: bare(data.friendly_verification_url || data.verification_url),
expiresAt: now() + (Number(data.expires_in) || 600) * 1e3,
interval: Math.max(2, Number(data.interval) || 5) * 1e3,
device: data.device_code
};
}
const response = await request(`${REALDEBRID}/device/code?client_id=${OPEN_SOURCE_CLIENT}&new_credentials=yes`, { signal });
const data = await json(response);
if (response.status >= 400 || !data?.device_code || !data.user_code) throw new Error(data?.error ? `Real-Debrid: ${data.error}.` : `Real-Debrid answered HTTP ${response.status}.`);
return {
code: String(data.user_code),
qr: data.direct_verification_url || data.verification_url,
page: bare(data.verification_url),
expiresAt: now() + (Number(data.expires_in) || 900) * 1e3,
interval: Math.max(2, Number(data.interval) || 5) * 1e3,
device: data.device_code
};
}
// One look: null while the player has not approved yet, the credentials once they have.
async function check(session, { signal } = {}) {
if (service === "torbox") {
const response = await request(`${TORBOX}/token`, {
method: "POST", signal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ device_code: session.device })
});
const body = await json(response);
if (body?.success) {
const data = body.data;
// The field is not documented: the usual names first, then any value shaped like a TorBox
// key (a UUID), so a renamed field still links the account.
const named = typeof data === "string" ? data : data?.access_token ?? data?.token ?? data?.api_key ?? data?.apikey;
const token = named ?? Object.values(data || {}).find((value) => typeof value === "string" && /^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$/i.test(value));
if (!named) console.log(`[device-login] torbox: approved, fields ${Object.keys(data || {}).join(", ")}`);
if (typeof token !== "string" || !token) throw new Error("TorBox approved the console but sent no key.");
return { torbox: token.trim() };
}
const error = String(body?.error || "");
if (error === "ITEM_NOT_FOUND" || /EXPIRED/i.test(error)) throw new Expired(body?.detail || "The code expired.");
if (error && error !== "DEVICE_CODE_NOT_USED") console.log(`[device-login] torbox: ${error} ${String(body?.detail || "").slice(0, 120)}`);
return null;
}
const response = await request(`${REALDEBRID}/device/credentials?client_id=${OPEN_SOURCE_CLIENT}&code=${encodeURIComponent(session.device)}`, { signal });
if (response.status === 403) return null;
const credentials = await json(response);
if (response.status >= 400 || !credentials?.client_id || !credentials.client_secret) {
if (response.status >= 400 && response.status !== 400) console.log(`[device-login] realdebrid credentials: HTTP ${response.status}`);
return null;
}
return tokens({ client: credentials.client_id, secret: credentials.client_secret, code: session.device, signal });
}
async function tokens({ client, secret, code, signal }) {
const form = `client_id=${encodeURIComponent(client)}&client_secret=${encodeURIComponent(secret)}&code=${encodeURIComponent(code)}&grant_type=${encodeURIComponent(DEVICE_GRANT)}`;
const response = await request(`${REALDEBRID}/token`, {
method: "POST", signal,
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: form
});
const data = await json(response);
if (response.status >= 400 || !data?.access_token) {
console.log(`[device-login] realdebrid token: HTTP ${response.status} ${String(data?.error || "").slice(0, 80)}`);
throw new Error(data?.error ? `Real-Debrid: ${data.error}.` : `Real-Debrid answered HTTP ${response.status}.`);
}
return {
realdebrid: data.access_token,
realdebridRefresh: data.refresh_token || "",
realdebridClient: client,
realdebridSecret: secret,
realdebridExpires: String(now() + (Number(data.expires_in) || 3600) * 1e3)
};
}
/** Waits until the player approves; throws Expired when the code runs out first. */
async function approved(session, { signal } = {}) {
for (;;) {
if (signal?.aborted) throw new Error("Cancelled.");
if (now() >= session.expiresAt) throw new Expired("The code expired.");
await wait(session.interval);
if (signal?.aborted) throw new Error("Cancelled.");
let result = null;
try {
result = await check(session, { signal });
} catch (error) {
if (error instanceof Expired) throw error;
// A network hiccup is not a refusal: the next look may well get through.
console.log(`[device-login] ${service}: ${error.message}`);
}
if (result) return result;
}
}
return { start, approved, check };
}
/** A new Real-Debrid access token from the saved refresh token: what setServices() takes. */
async function refreshRealDebrid({ http, client, secret, refresh, now = Date.now, signal }) {
const form = `client_id=${encodeURIComponent(client)}&client_secret=${encodeURIComponent(secret)}&code=${encodeURIComponent(refresh)}&grant_type=${encodeURIComponent(DEVICE_GRANT)}`;
const response = await http.request(`${REALDEBRID}/token`, {
method: "POST", signal, maxBytes: 64 * 1024,
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: form
});
const data = await json(response);
if (response.status >= 400 || !data?.access_token) {
console.log(`[device-login] realdebrid refresh: HTTP ${response.status} ${String(data?.error || "").slice(0, 80)}`);
throw new Error("Real-Debrid no longer accepts this console. Link it again in the download settings.");
}
console.log("[device-login] realdebrid: token renewed");
return {
realdebrid: data.access_token,
realdebridRefresh: data.refresh_token || refresh,
realdebridExpires: String(now() + (Number(data.expires_in) || 3600) * 1e3)
};
}
// An email shown on a TV that may be streamed: enough of it to recognise, not to copy.
var masked = (name) => String(name || "").replace(/^([^@]{0,2})[^@]*(@.*)$/, "$1***$2");
// Who the key belongs to, for the setup to say so; null when the service does not answer.
async function accountOf({ http, service, key, signal }) {
try {
if (service === "torbox") {
const response = await http.request("https://api.torbox.app/v1/api/user/me?settings=false", { signal, maxBytes: 256 * 1024, headers: { Authorization: `Bearer ${key}` } });
const data = (await json(response))?.data;
if (response.status >= 400 || !data) return null;
const plans = ["Free", "Essential", "Pro", "Standard"];
return { name: masked(data.email || data.username), plan: plans[Number(data.plan)] ?? "", until: String(data.premium_expires_at || "").slice(0, 10) };
}
if (service === "realdebrid") {
const response = await http.request("https://api.real-debrid.com/rest/1.0/user", { signal, maxBytes: 64 * 1024, headers: { Authorization: `Bearer ${key}` } });
const data = await json(response);
if (response.status >= 400 || !data) return null;
return { name: masked(data.username || data.email), plan: data.type === "premium" ? "Premium" : String(data.type || ""), until: String(data.expiration || "").slice(0, 10) };
}
} catch {
}
return null;
}
export { Expired, accountOf, createDeviceLogin, refreshRealDebrid };
+157
View File
@@ -0,0 +1,157 @@
import assert from 'node:assert/strict';
import {register} from 'node:module';
import test from 'node:test';
register(new URL('./download-queue-test-loader.mjs', import.meta.url), import.meta.url);
const {Expired, accountOf, createDeviceLogin, refreshRealDebrid} = await import('./device-login.js');
const {createRealDebrid} = await import('./realdebrid.js');
const {qrcodegen} = await import('../third_party/qrcodegen.js');
// A service that answers from a script: each URL prefix maps to the answers it gives in turn.
function service(script) {
const calls = [];
const http = {
async request(url, options = {}) {
calls.push({url, ...options});
const key = Object.keys(script).find(prefix => url.startsWith(prefix));
if (!key) throw new Error(`unexpected ${url}`);
const answers = script[key];
const [status, body] = answers.length > 1 ? answers.shift() : answers[0];
return {status, ok: status < 300, headers: {}, text: async () => typeof body === 'string' ? body : JSON.stringify(body)};
},
};
return {http, calls};
}
const clock = () => {
let time = 1_000_000;
return {now: () => time, wait: async ms => { time += ms; }};
};
// The live API's own answers, 2026-10-25.
const TORBOX_START = {success: true, error: null, detail: 'Successfully created your device code authorization.', data: {
device_code: 'IGIfdNrA_8bD5yBA3YxEiFnfKSaJTacArJDkE33RB2U', code: '583133', interval: 5, expires_in: 600,
verification_url: 'https://torbox.app/oauth/device?app=Overdrive',
verification_url_complete: 'https://torbox.app/oauth/device?app=Overdrive&code=583133',
friendly_verification_url: 'https://tor.box/link'}};
const TORBOX_PENDING = {success: false, error: 'DEVICE_CODE_NOT_USED', detail: 'This device code has not been used yet. Please wait for the user to scan the code.', data: null};
const RD_START = {device_code: 'HPF6V3HW3RBOOJNHLBZUGBHVAKOODPZBF3SWF2Z7FXNVX6TEAPZA', user_code: 'VNBENEFA', interval: 5, expires_in: 900,
verification_url: 'https://real-debrid.com/device',
direct_verification_url: 'https://real-debrid.com/authorize?client_id=X245A4XAIBGVM&device_id=HPF6V3HW3RBOOJNHLBZUGBHVAKOODPZBF3SWF2Z7FXNVX6TEAPZA'};
test('TorBox shows its six digits, its short page and a QR of the page with the code in it', async () => {
const {http} = service({'https://api.torbox.app/v1/api/user/auth/device/start': [[200, TORBOX_START]]});
const session = await createDeviceLogin({http, service: 'torbox', ...clock()}).start();
assert.equal(session.code, '583133');
assert.equal(session.page, 'tor.box/link');
assert.equal(session.qr, 'https://torbox.app/oauth/device?app=Overdrive&code=583133');
assert.equal(session.interval, 5000);
});
test('TorBox is asked again until the player approves, and its token is the key', async () => {
const {http, calls} = service({
'https://api.torbox.app/v1/api/user/auth/device/start': [[200, TORBOX_START]],
'https://api.torbox.app/v1/api/user/auth/device/token': [[400, TORBOX_PENDING], [400, TORBOX_PENDING], [200, {success: true, data: {access_token: 'tb-key-1234', token_type: 'Bearer'}}]],
});
const login = createDeviceLogin({http, service: 'torbox', ...clock()});
const values = await login.approved(await login.start());
assert.deepEqual(values, {torbox: 'tb-key-1234'});
const polls = calls.filter(call => call.url.endsWith('/token'));
assert.equal(polls.length, 3);
assert.equal(polls[0].method, 'POST');
assert.equal(polls[0].body, JSON.stringify({device_code: TORBOX_START.data.device_code}));
});
test('a TorBox answer that names its key differently still links the account', async () => {
const {http} = service({
'https://api.torbox.app/v1/api/user/auth/device/start': [[200, TORBOX_START]],
'https://api.torbox.app/v1/api/user/auth/device/token': [[200, {success: true, data: {expires_in: 0, key: '1b2c3d4e-5f60-4718-9a0b-1c2d3e4f5a6b'}}]],
});
const login = createDeviceLogin({http, service: 'torbox', ...clock()});
assert.deepEqual(await login.approved(await login.start()), {torbox: '1b2c3d4e-5f60-4718-9a0b-1c2d3e4f5a6b'});
});
test('a code that runs out is reported as expired, so the dialog asks for a new one', async () => {
const {http} = service({
'https://api.torbox.app/v1/api/user/auth/device/start': [[200, TORBOX_START]],
'https://api.torbox.app/v1/api/user/auth/device/token': [[400, TORBOX_PENDING]],
});
const login = createDeviceLogin({http, service: 'torbox', ...clock()});
await assert.rejects(login.approved(await login.start()), Expired);
const gone = service({
'https://api.torbox.app/v1/api/user/auth/device/start': [[200, TORBOX_START]],
'https://api.torbox.app/v1/api/user/auth/device/token': [[400, {success: false, error: 'ITEM_NOT_FOUND', detail: 'expired'}]],
});
const again = createDeviceLogin({http: gone.http, service: 'torbox', ...clock()});
await assert.rejects(again.approved(await again.start()), Expired);
});
test('Real-Debrid: the code, then this user\'s own client, then the tokens that renew themselves', async () => {
const time = clock();
const {http, calls} = service({
'https://api.real-debrid.com/oauth/v2/device/code': [[200, RD_START]],
'https://api.real-debrid.com/oauth/v2/device/credentials': [[403, {error: null, error_code: null}], [200, {client_id: 'USERCLIENT', client_secret: 'secret'}]],
'https://api.real-debrid.com/oauth/v2/token': [[200, {access_token: 'rd-access', expires_in: 3600, token_type: 'Bearer', refresh_token: 'rd-refresh'}]],
});
const login = createDeviceLogin({http, service: 'realdebrid', ...time});
const session = await login.start();
assert.equal(session.code, 'VNBENEFA');
assert.equal(session.page, 'real-debrid.com/device');
assert.equal(session.qr, RD_START.direct_verification_url);
assert.ok(calls[0].url.includes('client_id=X245A4XAIBGVM&new_credentials=yes'));
const values = await login.approved(session);
assert.equal(values.realdebrid, 'rd-access');
assert.equal(values.realdebridRefresh, 'rd-refresh');
assert.equal(values.realdebridClient, 'USERCLIENT');
assert.equal(values.realdebridSecret, 'secret');
assert.ok(Number(values.realdebridExpires) > time.now());
const exchange = calls.find(call => call.url.endsWith('/oauth/v2/token'));
assert.equal(exchange.method, 'POST');
assert.match(exchange.body, /client_id=USERCLIENT&client_secret=secret&code=HPF6V3HW.*&grant_type=http%3A%2F%2Foauth\.net%2Fgrant_type%2Fdevice%2F1\.0/);
});
test('a Real-Debrid token about to lapse is renewed before the call, and a refused one once after it', async () => {
const {http, calls} = service({
'https://api.real-debrid.com/oauth/v2/token': [[200, {access_token: 'rd-new', expires_in: 3600, refresh_token: 'rd-refresh-2'}]],
'https://api.real-debrid.com/rest/1.0/unrestrict/link': [[200, {download: 'https://node.real-debrid.com/d/file.pkg', filename: 'file.pkg', filesize: 10}]],
});
let saved = null;
const renew = async ({signal}) => {
saved = await refreshRealDebrid({http, client: 'USERCLIENT', secret: 'secret', refresh: 'rd-refresh', signal});
return saved;
};
const client = createRealDebrid({http, key: 'rd-old', renew, expiresAt: Date.now() + 30e3});
const file = await client.unrestrict('https://host.example/f/1');
assert.equal(file.url, 'https://node.real-debrid.com/d/file.pkg');
assert.equal(saved.realdebrid, 'rd-new');
assert.equal(saved.realdebridRefresh, 'rd-refresh-2');
const unrestrict = calls.find(call => call.url.endsWith('/unrestrict/link'));
assert.equal(unrestrict.headers.Authorization, 'Bearer rd-new');
const refused = service({
'https://api.real-debrid.com/oauth/v2/token': [[200, {access_token: 'rd-fresh', expires_in: 3600, refresh_token: 'r'}]],
'https://api.real-debrid.com/rest/1.0/unrestrict/link': [[401, {error: 'bad_token', error_code: 8}], [200, {download: 'https://node.real-debrid.com/d/2.pkg', filename: '2.pkg', filesize: 1}]],
});
const again = createRealDebrid({http: refused.http, key: 'rd-old', renew: ({signal}) => refreshRealDebrid({http: refused.http, client: 'c', secret: 's', refresh: 'r', signal})});
assert.equal((await again.unrestrict('https://host.example/f/2')).url, 'https://node.real-debrid.com/d/2.pkg');
assert.equal(refused.calls.filter(call => call.url.endsWith('/unrestrict/link')).length, 2);
});
test('a typed Real-Debrid token is never renewed: a refusal says so', async () => {
const {http} = service({'https://api.real-debrid.com/rest/1.0/unrestrict/link': [[401, {error: 'bad_token', error_code: 8}]]});
await assert.rejects(createRealDebrid({http, key: 'typed'}).unrestrict('https://host.example/f/1'), /rejected the token/);
});
test('the account a key belongs to is named with its email masked', async () => {
const {http} = service({'https://api.torbox.app/v1/api/user/me': [[200, {success: true, data: {email: 'alexandro@example.com', plan: 2, premium_expires_at: '2027-03-12T00:00:00Z'}}]]});
assert.deepEqual(await accountOf({http, service: 'torbox', key: 'k'}), {name: 'al***@example.com', plan: 'Pro', until: '2027-03-12'});
const rd = service({'https://api.real-debrid.com/rest/1.0/user': [[200, {username: 'luxy', type: 'premium', expiration: '2027-01-02T10:00:00.000Z'}]]});
assert.deepEqual(await accountOf({http: rd.http, service: 'realdebrid', key: 'k'}), {name: 'luxy', plan: 'Premium', until: '2027-01-02'});
});
test('both approval pages fit a QR code a TV shows at 380 px', () => {
for (const text of [TORBOX_START.data.verification_url_complete, RD_START.direct_verification_url]) {
const code = qrcodegen.QrCode.encodeText(text, qrcodegen.QrCode.Ecc.MEDIUM);
// Whole pixels per module with the quiet zone: at least 6 px, which a phone reads across a room.
assert.ok(Math.floor(380 / (code.size + 8)) >= 6, `${text}: ${code.size} modules`);
}
});
+90
View File
@@ -0,0 +1,90 @@
import assert from 'node:assert/strict';
import {register} from 'node:module';
import test from 'node:test';
register(new URL('./download-queue-test-loader.mjs', import.meta.url), import.meta.url);
const {createDownloadQueue} = await import('./download-queue.js');
// Issue #111: a .part an earlier session left behind makes the queue "adopt" it. Its checkpoint names
// another run, so the transfer fails at once with nothing written; Retry then refused with the same
// words for good. It now deletes that partial and starts the download over.
const ROOT = '/data';
const DESTINATION = ROOT + '/downloads/files/game.pkg';
function console_() {
const files = new Map();
const dirs = new Set([ROOT, ROOT + '/downloads', ROOT + '/downloads/files']);
const fs = {
exists: path => dirs.has(path) || files.has(path),
stat: path => dirs.has(path) ? {isDirectory: true, device: 'dev1', inode: 'root', modified: 0} :
files.has(path) ? {isFile: true, size: String(files.get(path)).length, modified: 0} : null,
diskUsage: () => ({free: 1024 ** 4}),
mkdir: path => { dirs.add(path); },
writeFile: (path, data) => { files.set(path, data); },
readFile: path => files.get(path) ?? '',
rename: (source, target) => { if (files.has(source)) { files.set(target, files.get(source)); files.delete(source); } },
readDir: () => [],
remove: path => { files.delete(path); },
removeAsync: async path => { files.delete(path); dirs.delete(path); },
};
return {files, dirs, fs};
}
function boot(machine, {stale = false} = {}) {
let attempts = 0;
const downloads = {
enqueue({destination}) {
// The first transfer is the one the adopted partial stops, as the engine words it.
if (stale && attempts++ === 0)
return {done: Promise.reject(new Error('partial file has no matching durable checkpoint')), snapshot: {state: 'connecting'},
subscribe: () => () => {}, cancel: () => {}};
machine.files.delete(destination + '.part');
machine.files.set(destination, 'game bytes');
return {done: Promise.resolve({written: 4096}), snapshot: {state: 'completed'}, subscribe: () => () => {}, cancel: () => {}};
},
};
const formats = {artifact: (release, where) => ({
directory: where + '/downloads/files',
destination: where + '/downloads/files/' + release.filename,
nextAction: 'downloaded',
})};
return createDownloadQueue({
fs: machine.fs, downloads, formats, dataDir: ROOT, platform: 'desktop',
notifications: {show() {}}, power: {keepAwake() {}},
sources: {resolve: async () => ({url: 'https://example.com/game.pkg'})},
});
}
async function settled(queue, id, states) {
for (const until = Date.now() + 5000; Date.now() < until;) {
const job = queue.snapshot().find(item => item.id === id);
if (states.includes(job?.state)) return job;
await new Promise(resolve => setTimeout(resolve, 5));
}
throw new Error('Queue did not settle: ' + JSON.stringify(queue.snapshot().find(item => item.id === id)));
}
const release = {id: 'source-1', filename: 'game.pkg', url: 'https://example.com/game.pkg', format: 'pkg', sizeBytes: 4096};
test('Retry replaces a partial from an earlier session instead of failing on it again', async () => {
const machine = console_();
machine.files.set(DESTINATION + '.part', 'half');
const queue = boot(machine, {stale: true});
const id = queue.enqueue({id: 'PPSA28000', title: 'Demo'}, release, ROOT);
const failed = await settled(queue, id, ['failed', 'file-downloaded']);
assert.equal(failed.state, 'failed');
assert.match(failed.error, /from another source holds this name/);
await new Promise(resolve => setTimeout(resolve, 20));
await queue.restart(id);
const job = await settled(queue, id, ['file-downloaded', 'failed']);
assert.equal(job.state, 'file-downloaded', job.error);
assert.equal(machine.files.has(DESTINATION + '.part'), false);
});
test('a download with no partial of its own needs no restart', async () => {
const machine = console_();
const queue = boot(machine);
const id = queue.enqueue({id: 'PPSA28000', title: 'Demo'}, release, ROOT);
const job = await settled(queue, id, ['file-downloaded', 'failed']);
assert.equal(job.state, 'file-downloaded', job.error);
});
+229
View File
@@ -0,0 +1,229 @@
import assert from 'node:assert/strict';
import {register} from 'node:module';
import test from 'node:test';
register(new URL('./download-queue-test-loader.mjs', import.meta.url), import.meta.url);
const {createDownloadQueue} = await import('./download-queue.js');
// Issue #77: DataNodes and FileKeeper's dlproxy.uk gateway answers range after range with HTTP 503
// while it wants a pause. The download then sits at 0 MB/s, and only Pause and Resume brought it
// back; the app restarts such a transfer by itself once nothing has been received for the stall
// time. A retried range drops `received` back, so only a new high counts as progress — otherwise
// every retry reset the wait and the restart never came (Narci115's app.log).
const ROOT = '/data';
const DESTINATION = ROOT + '/downloads/packages/game.pkg';
function console_() {
const files = new Map();
const dirs = new Set([ROOT, ROOT + '/downloads', ROOT + '/downloads/packages']);
const fs = {
exists: path => dirs.has(path) || files.has(path),
stat: path => dirs.has(path) ? {isDirectory: true, device: 'dev1', inode: 'root', modified: 0} :
files.has(path) ? {isFile: true, size: String(files.get(path)).length, modified: 0} : null,
diskUsage: () => ({free: 1024 ** 4}),
mkdir: path => { dirs.add(path); },
writeFile: (path, data) => { files.set(path, data); },
readFile: path => files.get(path) ?? '',
rename: (source, target) => { if (files.has(source)) { files.set(target, files.get(source)); files.delete(source); } },
readDir: () => [],
remove: path => { files.delete(path); },
removeAsync: async path => { files.delete(path); dirs.delete(path); },
};
return {files, dirs, fs};
}
function transfer(start) {
let settle, refuse;
const done = new Promise((resolve, reject) => { settle = resolve; refuse = reject; });
let timer = null;
const task = {
done,
snapshot: {state: 'downloading'},
subscribe(listener) {
timer = start(listener, () => { clearInterval(timer); settle({written: 1000}); });
return () => { clearInterval(timer); };
},
cancel() { clearInterval(timer); refuse(new Error('download cancelled by the test')); },
};
return task;
}
function boot(machine, enqueue, {stallMs = 120} = {}) {
const formats = {artifact: (release, where) => ({
directory: where + '/downloads/packages',
destination: where + '/downloads/packages/' + release.filename,
nextAction: 'installer-required',
})};
return createDownloadQueue({
fs: machine.fs, downloads: {enqueue}, formats, dataDir: ROOT, platform: 'desktop', stallMs,
notifications: {show() {}}, power: {keepAwake() {}},
sources: {resolve: async () => ({url: 'https://tunnel1.dlproxy.uk/file'})},
});
}
const release = url => ({id: 'source-1', filename: 'game.pkg', url, format: 'pkg', sizeBytes: 1000});
const game = {id: 'CUSA00001', title: 'Demo'};
async function settled(queue, id, states, until = 4000) {
for (const end = Date.now() + until; Date.now() < end;) {
const job = queue.snapshot().find(item => item.id === id);
if (states.includes(job?.state)) return job;
await new Promise(resolve => setTimeout(resolve, 5));
}
throw new Error('Queue did not settle: ' + JSON.stringify(queue.snapshot().find(item => item.id === id)));
}
test('a gateway that only retries ranges is restarted instead of sitting at 0 MB/s', async () => {
const machine = console_();
let transfers = 0;
const enqueue = () => transfer((emit, finish) => {
if (++transfers === 1) {
// A 503 for every range: `received` falls back to 0 and climbs to 100 again, never further.
return setInterval(() => emit({state: 'downloading', received: Date.now() % 2 ? 100 : 0, completedBytes: 0, total: 1000}), 5).unref();
}
let received = 0;
return setInterval(() => {
received += 100;
if (received >= 1000) finish();
else emit({state: 'downloading', received, completedBytes: 0, total: 1000});
}, 5).unref();
});
const queue = boot(machine, enqueue);
const id = queue.enqueue(game, release('https://filekeeper.net/file/abc'), ROOT);
const job = await settled(queue, id, ['installer-required', 'failed']);
assert.equal(job.state, 'installer-required', job.error);
assert.equal(transfers, 2);
});
test('a transfer that keeps receiving is never restarted', async () => {
const machine = console_();
let transfers = 0;
const enqueue = () => transfer((emit, finish) => {
transfers++;
let received = 0;
return setInterval(() => {
received += 100;
if (received >= 1000) finish();
else emit({state: 'downloading', received, completedBytes: 0, total: 1000});
}, 10).unref();
});
const queue = boot(machine, enqueue, {stallMs: 120});
const id = queue.enqueue(game, release('https://filekeeper.net/file/abc'), ROOT);
const job = await settled(queue, id, ['installer-required', 'failed']);
assert.equal(job.state, 'installer-required', job.error);
assert.equal(transfers, 1);
});
test('a source that is not the gateway waits out a quiet patch', async () => {
const machine = console_();
let transfers = 0;
const enqueue = () => {
transfers++;
return transfer(() => setInterval(() => {}, 1000).unref());
};
const queue = boot(machine, enqueue, {stallMs: 60});
const id = queue.enqueue(game, release('https://archive.org/download/x/game.pkg'), ROOT);
await new Promise(resolve => setTimeout(resolve, 300));
assert.equal(transfers, 1);
assert.notEqual(queue.snapshot().find(item => item.id === id).state, 'failed');
queue.pause(id);
});
// Elefantex's app.log: FileKeeper's edge01.fetchio.cc gateway sends a few seconds, then answers 503
// with Retry-After 60, again and again. No gap reaches the stall time, yet the download is idle most
// of the time; that counts as a stall too.
test('a gateway that sends in short bursts between long waits is restarted', async () => {
const machine = console_();
let transfers = 0;
const lines = [];
const original = console.log;
console.log = (...parts) => lines.push(parts.join(' '));
try {
const enqueue = () => transfer((emit, finish) => {
let received = 0;
if (++transfers === 1) {
const start = Date.now();
// Bytes for 20 ms of every 150: each wait is well under the stall time, and together they
// are most of it. Timed by the clock, since timers on some systems tick every 15 ms.
return setInterval(() => {
if ((Date.now() - start) % 150 < 20) received += 10;
emit({state: 'downloading', received, completedBytes: 0, total: 1000});
}, 5).unref();
}
return setInterval(() => {
received += 100;
if (received >= 1000) finish();
else emit({state: 'downloading', received, completedBytes: 0, total: 1000});
}, 5).unref();
});
// DataNodes: FileKeeper's own link is asked for again sooner (the tests below).
const queue = boot(machine, enqueue, {stallMs: 300});
const id = queue.enqueue(game, release('https://datanodes.to/abc123/game.pkg'), ROOT);
const job = await settled(queue, id, ['installer-required', 'failed']);
assert.equal(job.state, 'installer-required', job.error);
} finally {
console.log = original;
}
assert.equal(transfers, 2);
assert.ok(lines.some(line => line.includes('in bursts and waits') && line.includes('restart 1 of 10')), lines.join('\n'));
});
// FileKeeper's form hands out a new link after five seconds, and its gateway's 503 asks for a minute:
// a few seconds of nothing ask for the new link, and app.log says whether it brought the bytes back.
function fileKeeperRun(secondFlows) {
const machine = console_();
let transfers = 0, resolved = 0;
const lines = [];
const enqueue = () => transfer((emit, finish) => {
let received = 0;
const flowing = ++transfers > 1 && secondFlows;
return setInterval(() => {
if (flowing || received < 50) received += 50;
if (received >= 1000) finish();
else emit({state: 'downloading', received, completedBytes: 0, total: 1000});
}, 5).unref();
});
const queue = createDownloadQueue({
fs: machine.fs, downloads: {enqueue}, dataDir: ROOT, platform: 'desktop', stallMs: 300,
formats: {artifact: (item, where) => ({directory: where + '/downloads/packages', destination: where + '/downloads/packages/' + item.filename, nextAction: 'installer-required'})},
notifications: {show() {}}, power: {keepAwake() {}},
sources: {resolve: async () => { resolved++; return {url: 'https://edge01.fetchio.cc/f/token' + resolved}; }},
});
return {queue, lines, counts: () => ({transfers, resolved})};
}
test('a quiet FileKeeper gateway gets a new link at once, and the log says it helped', async () => {
const lines = [];
const original = console.log;
console.log = (...parts) => lines.push(parts.join(' '));
let run;
try {
run = fileKeeperRun(true);
const id = run.queue.enqueue(game, release('https://filekeeper.net/file/abc'), ROOT);
const job = await settled(run.queue, id, ['installer-required', 'failed']);
assert.equal(job.state, 'installer-required', job.error);
} finally {
console.log = original;
}
assert.deepEqual(run.counts(), {transfers: 2, resolved: 2});
assert.ok(lines.some(line => line.includes("a new link instead of waiting its minute out (1 of 30)")), lines.join('\n'));
assert.ok(lines.some(line => line.includes('the new link brought it back')), lines.join('\n'));
});
test('a new link that brings nothing back either is asked for once, then the gateway is waited out', async () => {
const lines = [];
const original = console.log;
console.log = (...parts) => lines.push(parts.join(' '));
let run, id;
try {
run = fileKeeperRun(false);
id = run.queue.enqueue(game, release('https://filekeeper.net/file/abc'), ROOT);
for (const until = Date.now() + 3000; Date.now() < until && !lines.some(line => line.includes('did not bring it back')); )
await new Promise(resolve => setTimeout(resolve, 10));
} finally {
console.log = original;
run?.queue.pause(id);
}
assert.ok(lines.some(line => line.includes('a new link did not bring it back; waiting')), lines.join('\n'));
assert.equal(lines.filter(line => line.includes('a new link instead of waiting')).length, 1);
});
+455
View File
@@ -0,0 +1,455 @@
import assert from 'node:assert/strict';
import {register} from 'node:module';
import test from 'node:test';
register(new URL('./download-queue-test-loader.mjs', import.meta.url), import.meta.url);
const {createDownloadQueue} = await import('./download-queue.js');
const PS4_ID = 'UP0001-CUSA04013_00-TITANFALL2000000';
const PS4_HEAD = '\x7FCNT' + '\x00\x01\xff\xfe'.repeat(15) + PS4_ID + '\x00'.repeat(64);
const PS5_HEAD = '\x7FFIH' + '\x00'.repeat(120);
const RAR_HEAD = 'Rar!\x1a\x07\x01\x00' + '\x00'.repeat(120);
const SIGNED_LINK = 'https://asia-upload.04b3d96d52475741e6b10f97f0a84a16.r2.cloudflarestorage.com/rVl2jfZkC7' +
'?response-content-disposition=attachment%3Bfilename%3D%22GAME%20%5Bv01.01%5D%2094.7GB.pkg%22' +
'&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIA%2Fauto%2Fs3%2Faws4_request' +
'&X-Amz-Date=20261025T000000Z&X-Amz-Expires=86400&X-Amz-SignedHeaders=host&X-Amz-Signature=' + 'a'.repeat(64);
// The console's answer to a link: 'accepted' reports bytes, 'refused' fails, 'silent' says nothing.
function harness({head = PS4_HEAD, status = 206, console: answer = 'accepted', platform = 'ps5', format = 'binary', redirect = false, size = 100e9, url = 'https://cdn.example/file.pkg', proxy = true, lan = '192.168.1.20', saved = null, delivered = () => 0, helper = null, streamMode, endless = false} = {}) {
const files = new Map();
const dirs = new Set(['/data']);
const requests = [], installs = [], enqueued = [], proxied = [], removed = [];
if (saved) {
dirs.add('/data/overdrive');
files.set('/data/overdrive/downloads.json', JSON.stringify(saved));
}
const fs = {
exists: path => dirs.has(path) || files.has(path),
stat: path => dirs.has(path) ? {isDirectory: true, device: 'dev', inode: 'root', modified: 0} :
files.has(path) ? {isFile: true, size: 4096, modified: 0} : null,
diskUsage: () => ({free: 1024 ** 4}),
mkdir: path => { dirs.add(path); },
writeFile: (path, data) => { files.set(path, data); },
readFile: path => files.get(path) ?? '',
rename: (source, target) => { if (files.has(source)) { files.set(target, files.get(source)); files.delete(source); } },
readDir: () => [],
remove: path => { files.delete(path); },
removeAsync: async path => { files.delete(path); dirs.delete(path); },
};
const http = {
async request(url, options) {
requests.push({url, options});
if (redirect && url === 'https://host.example/f/abc')
return {status: 302, ok: false, headers: {location: 'https://cdn.example/file.pkg'}, text: async () => ''};
return {status, ok: status < 300, headers: status === 206 ? {'content-range': `bytes 0-4095/${size}`, 'content-type': 'application/octet-stream', 'content-disposition': 'attachment; filename="game.pkg"'} : {}, text: async () => head};
},
};
const downloads = {
enqueue(options) {
enqueued.push(options);
if (endless) {
// A transfer that runs until it is stopped, as a big one does.
files.set(options.destination + '.part', 'part');
let stop;
const done = new Promise((_, reject) => { stop = reject; });
done.catch(() => {});
return {done, snapshot: {state: 'downloading'}, subscribe: listener => { listener({state: 'downloading', written: 10, total: 100}); return () => {}; }, cancel: () => stop(new Error('cancelled'))};
}
files.set(options.destination, 'game bytes');
return {done: Promise.resolve({written: 4096}), snapshot: {state: 'completed'}, subscribe: () => () => {}, cancel: () => {}};
},
};
const packages = {
// The engine's own link to the package (ABI v21); `proxy: false` is an engine without it, and
// 'fails' one that cannot listen.
...proxy ? {
proxy(request) {
proxied.push(request);
if (proxy === 'fails') throw new Error('packages.proxy: listen: Operation not permitted');
const token = request.token || 'a'.repeat(64);
const port = request.port || 41000;
return {url: lan ? `http://${lan}:${port}/${token}/${request.file}` : '', localUrl: `http://127.0.0.1:${port}/${token}/${request.file}`, token, port};
},
proxyStats: () => proxied.map(request => ({token: request.token || 'a'.repeat(64), total: request.total, delivered: delivered(), requests: 3, idleMs: 0})),
proxyRemove: token => removed.push(token),
} : {},
install(request) {
installs.push(request);
const listeners = new Set();
let finish, fail;
const done = new Promise((resolve, reject) => { finish = resolve; fail = reject; });
done.catch(() => {});
const task = {
snapshot: {state: 'queued', written: 0},
done,
subscribe(listener) { listener(task.snapshot); listeners.add(listener); return () => listeners.delete(listener); },
cancel() { fail(new Error('cancelled')); },
};
setTimeout(() => {
// The frame's message names the link first, as long as a signed CDN's can be.
const verdict = typeof answer === 'function' ? answer(request.url) : answer;
if (verdict === 'refused') return fail(new Error(`Packages.install ${SIGNED_LINK}: The console refused the package. (0x80990085)`));
task.snapshot = {state: 'installing', written: 0};
if (verdict === 'accepted') {
task.snapshot = {state: 'installing', written: 1 << 20, total: 1 << 30};
for (const listener of listeners) listener(task.snapshot);
}
}, 5);
return task;
},
};
const formats = {artifact: (release, where) => ({
directory: where + '/downloads/files',
destination: where + '/downloads/files/' + release.filename,
nextAction: release.format === 'pkg' ? 'installer-required' : 'downloaded',
})};
const queue = createDownloadQueue({
fs, http, downloads, packages, formats,
dataDir: '/data', platform,
archives: {streams: true, supportsAsyncInspection: false, inspect: () => ({kind: null})},
notifications: {show() {}}, power: {keepAwake() {}},
sources: {resolve: async entry => ({url: entry.url})},
helper,
});
const release = {id: 'source-1', filename: format === 'binary' ? 'game.binary' : 'game.' + format,
url: redirect ? 'https://host.example/f/abc' : url, format, provider: 'vikingfile'};
const id = saved ? saved.jobs[0].id : queue.enqueue({id: 'CUSA04013', title: 'Titanfall 2'}, release, '/data', {streamMode});
return {queue, id, requests, installs, enqueued, proxied, removed, files};
}
async function settled(fixture, states = ['installed', 'file-downloaded', 'installer-required', 'failed', 'downloaded']) {
for (const until = Date.now() + 10000; Date.now() < until;) {
const job = fixture.queue.snapshot().find(item => item.id === fixture.id);
if (states.includes(job?.state)) return job;
await new Promise(resolve => setTimeout(resolve, 5));
}
throw new Error('Queue did not settle');
}
test('a PS4 PKG link goes to the console with its content ID, and nothing downloads here', async () => {
const fixture = harness();
const job = await settled(fixture);
assert.equal(job.state, 'installed', job.error);
assert.equal(job.streamed, true);
assert.deepEqual(fixture.installs, [{url: 'https://cdn.example/file.pkg', name: 'Titanfall 2', contentId: PS4_ID}]);
assert.equal(fixture.enqueued.length, 0);
assert.equal(fixture.requests[0].options.headers.Range, 'bytes=0-4095');
});
test('a PS5 PKG goes without a content ID, through the link a redirect names', async () => {
const fixture = harness({head: PS5_HEAD, redirect: true});
const job = await settled(fixture);
assert.equal(job.state, 'installed', job.error);
assert.deepEqual(fixture.installs.map(item => [item.url, item.contentId]), [['https://cdn.example/file.pkg', '']]);
});
test('an archive downloads here as before', async () => {
const fixture = harness({head: RAR_HEAD});
const job = await settled(fixture);
assert.notEqual(job.state, 'installed');
assert.equal(fixture.installs.length, 0);
assert.equal(fixture.enqueued.length, 1);
});
test('a host that ignores ranges downloads here', async () => {
const fixture = harness({status: 200});
await settled(fixture);
assert.equal(fixture.installs.length, 0);
assert.equal(fixture.enqueued.length, 1);
});
test('a link the console refuses downloads here instead, and is not offered again', async () => {
const fixture = harness({console: 'refused', format: 'pkg'});
const job = await settled(fixture);
assert.equal(fixture.installs.length, 4, "the link, the app's link on the LAN and on 127.0.0.1, then the downloaded file");
assert.equal(fixture.installs[3].path, '/data/downloads/files/game.pkg');
assert.equal(fixture.enqueued.length, 1);
assert.equal(job.streamTried, true);
assert.equal(job.streamed, undefined);
assert.deepEqual(fixture.removed, ['a'.repeat(64)], 'a link the console never took is not served');
});
// A pre-signed link answers the console's HEAD 403 (it signs only the GET), and the console refuses it
// with 0x80B22403 (the user's Vikingfile logs, 2026-10-25). The app's own link answers that HEAD, on
// the console's LAN address first, as Pegasus DL+ serves its packages.
test('a refused link is offered again through the app on the LAN, and taken there', async () => {
const fixture = harness({console: url => url.startsWith('http://192.168.') ? 'accepted' : 'refused', format: 'pkg'});
const job = await settled(fixture);
assert.equal(job.state, 'installed', job.error);
assert.equal(job.streamed, true);
assert.equal(job.proxied, true);
assert.equal(job.message, 'downloads.streamedProxy');
assert.deepEqual(fixture.installs.map(item => [item.url, item.contentId]), [
['https://cdn.example/file.pkg', PS4_ID],
['http://192.168.1.20:41000/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/game.pkg', PS4_ID],
]);
assert.deepEqual(fixture.proxied, [{url: 'https://cdn.example/file.pkg', total: 100e9, file: 'game.pkg'}]);
assert.equal(job.proxy.token, 'a'.repeat(64));
assert.equal(job.proxy.port, 41000);
assert.equal(job.proxy.origin, 'https://cdn.example/file.pkg');
assert.equal(fixture.enqueued.length, 0);
assert.equal(fixture.removed.length, 0);
});
test('a console that refuses the LAN address is offered 127.0.0.1', async () => {
const fixture = harness({console: url => url.startsWith('http://127.0.0.1') ? 'accepted' : 'refused', format: 'pkg'});
const job = await settled(fixture);
assert.equal(job.state, 'installed', job.error);
assert.deepEqual(fixture.installs.map(item => item.url), ['https://cdn.example/file.pkg', 'http://192.168.1.20:41000/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/game.pkg', 'http://127.0.0.1:41000/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/game.pkg']);
});
test('a console with no LAN address goes straight to 127.0.0.1', async () => {
const fixture = harness({console: 'refused', format: 'pkg', lan: ''});
await settled(fixture);
assert.deepEqual(fixture.installs.slice(0, 2).map(item => item.url), ['https://cdn.example/file.pkg', 'http://127.0.0.1:41000/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/game.pkg']);
assert.equal(fixture.installs[2].path, '/data/downloads/files/game.pkg');
});
test('a link the console takes as it came is never served by the app', async () => {
const fixture = harness();
const job = await settled(fixture);
assert.equal(job.state, 'installed', job.error);
assert.equal(job.proxied, undefined);
assert.equal(job.message, 'downloads.streamed');
assert.equal(fixture.proxied.length, 0);
});
test("an engine without the app's own link offers the link once, then downloads here", async () => {
const fixture = harness({console: 'refused', format: 'pkg', proxy: false});
await settled(fixture);
assert.equal(fixture.installs.length, 2, 'the link, then the downloaded file');
assert.equal(fixture.installs[1].path, '/data/downloads/files/game.pkg');
});
test('an app that cannot serve the link says so and downloads here', async () => {
const lines = [];
const original = console.log;
console.log = (...parts) => lines.push(parts.join(' '));
let fixture;
try {
fixture = harness({console: 'refused', format: 'pkg', proxy: 'fails'});
await settled(fixture);
} finally {
console.log = original;
}
assert.equal(fixture.installs.length, 2);
assert.ok(lines.some(line => line.includes('could not serve it to the console: packages.proxy: listen')), lines.join('\n'));
});
// The response the host gives is logged while it still may explain the refusal.
test("a refused link with a query has the host's own answer beside it", async () => {
const lines = [];
const original = console.log;
console.log = (...parts) => lines.push(parts.join(' '));
let fixture;
try {
fixture = harness({console: 'refused', format: 'pkg', url: 'https://cdn.example/file.pkg?b=1&a=2'});
await settled(fixture);
} finally {
console.log = original;
}
assert.deepEqual(fixture.installs.slice(0, 3).map(item => item.url), ['https://cdn.example/file.pkg?b=1&a=2', 'http://192.168.1.20:41000/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/game.pkg', 'http://127.0.0.1:41000/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/game.pkg']);
assert.equal(fixture.installs[3].path, '/data/downloads/files/game.pkg');
assert.ok(lines.some(line => line.includes('the host answers 206 to a one-byte range')), lines.join('\n'));
assert.ok(lines.some(line => line.includes('its query names b, a')), lines.join('\n'));
assert.ok(lines.some(line => line.includes('offered it again through Overdrive to the console: http://192.168.1.20:41000/')), lines.join('\n'));
});
// Closed and opened again, the app serves the same URL on the same token and port, so the console's
// queue reconnects to it without a second install; once every byte went out, it is no longer served.
function servedJob(proxy) {
return {version: 1, jobs: [{
id: 'CUSA04013/source-1', gameId: 'CUSA04013', title: 'Titanfall 2', root: '/data', state: 'installed', streamed: true, proxied: true,
release: {id: 'source-1', filename: 'game.pkg', url: 'https://cdn.example/file.pkg', format: 'pkg', provider: 'vikingfile'},
artifact: {directory: '/data/downloads/files', destination: '/data/downloads/files/game.pkg', nextAction: 'installer-required'},
proxy,
}]};
}
test('after a restart the package is served again on its own token and port, with its progress', async () => {
const proxy = {token: 'b'.repeat(64), port: 41234, origin: 'https://cdn.example/file.pkg', total: 4e9, file: 'game.pkg', since: Date.now()};
let sent = 1e9;
const fixture = harness({saved: servedJob(proxy), delivered: () => sent});
assert.deepEqual(fixture.proxied, [{url: 'https://cdn.example/file.pkg', total: 4e9, file: 'game.pkg', token: 'b'.repeat(64), port: 41234}]);
const progress = await new Promise(resolve => {
const off = fixture.queue.subscribe(jobs => {
const job = jobs.find(item => item.id === fixture.id);
if (job?.message === 'downloads.proxyProgress') { off?.(); resolve(job); }
});
});
assert.equal(progress.state, 'installed');
sent = 4e9;
for (const until = Date.now() + 8000; Date.now() < until;) {
const job = fixture.queue.snapshot().find(item => item.id === fixture.id);
if (job.proxy.done) {
assert.equal(job.message, 'downloads.proxyDone');
return;
}
await new Promise(resolve => setTimeout(resolve, 20));
}
assert.fail('the package was never marked as fetched');
});
test('a package served more than a week ago, or already fetched, is not served again', async () => {
const old = harness({saved: servedJob({token: 'c'.repeat(64), port: 41000, origin: 'https://cdn.example/file.pkg', total: 4e9, file: 'game.pkg', since: Date.now() - 8 * 24 * 3600e3})});
assert.equal(old.proxied.length, 0);
const done = harness({saved: servedJob({token: 'd'.repeat(64), port: 41000, origin: 'https://cdn.example/file.pkg', total: 4e9, file: 'game.pkg', since: Date.now(), done: true})});
assert.equal(done.proxied.length, 0);
});
// app.log cuts a line at 382 characters: a signed CDN link alone fills one, so the reason and its code
// have to come without it, or the next report says nothing again.
test('a refusal is logged with its reason and code, not the link in front of them', async () => {
const lines = [];
const original = console.log;
console.log = (...parts) => lines.push(parts.join(' '));
try {
await settled(harness({console: 'refused', format: 'pkg'}));
} finally {
console.log = original;
}
const refusal = lines.find(line => line.includes('the console refused the link after'));
assert.ok(refusal, 'the refusal is logged');
assert.equal(refusal.split(' ms: ')[1], 'code=0x80990085 The console refused the package.');
const whole = lines.find(line => line.includes(SIGNED_LINK));
assert.ok(!whole, `the link never reaches app.log whole: ${whole?.slice(0, 200)}`);
});
// A file that turns out not to be a PKG is not a failure, but the log has to say so: a bare
// "downloading it here instead" left the reason unnamed in every report until now.
// The package's size beside the room the console has: the two lines together say whether a refusal
// could have been about space, which no other line in app.log can answer. Nothing here sets a locale,
// so a size renders as its translation key: this checks the line is shaped right, not the number.
test('the handoff names the package size, and still reads well when the host gives none', async () => {
const capture = async (options) => {
const lines = [];
const original = console.log;
console.log = (...parts) => lines.push(parts.join(' '));
try {
await settled(harness(options));
} finally {
console.log = original;
}
return lines;
};
const sized = await capture({size: 100e9});
const unsized = await capture({size: 0});
assert.ok(sized.some(line => line.includes('a PS4 PKG of ')), sized.join('\n'));
assert.ok(unsized.some(line => line.includes('a PS4 PKG, handed to the console')), unsized.join('\n'));
assert.ok(sized.some(line => line.includes('internal drive free')), sized.join('\n'));
assert.ok(sized.some(line => line.includes('the host serves it as application/octet-stream, attachment; filename="game.pkg"')), sized.join('\n'));
});
test('a file whose first bytes are not a PKG says so before downloading here', async () => {
const lines = [];
const original = console.log;
console.log = (...parts) => lines.push(parts.join(' '));
try {
harness({head: RAR_HEAD, format: 'pkg'});
for (const until = Date.now() + 2000; Date.now() < until && !lines.some(line => line.includes('not streamed'));)
await new Promise(resolve => setTimeout(resolve, 5));
} finally {
console.log = original;
}
assert.ok(lines.some(line => line.includes('not streamed, its first bytes are not a PKG')), lines.join('\n'));
});
test('a source of a declared archive format is never probed', async () => {
const fixture = harness({format: 'rar', head: RAR_HEAD});
await settled(fixture, ['file-downloaded', 'failed', 'extraction-required', 'extracted', 'downloaded']);
assert.equal(fixture.requests.length, 0);
});
test('outside the PS5 nothing is probed', async () => {
const fixture = harness({platform: 'desktop'});
await settled(fixture);
assert.equal(fixture.requests.length, 0);
assert.equal(fixture.installs.length, 0);
});
// The helper payload (pkg-helper.js) serves the package once Overdrive has closed: the console's
// download goes on while the player uses the console for anything else.
function fakeHelper({fails = false, running = false, delivered = () => 0} = {}) {
const added = [], removed = [];
return {
added, removed,
alive: async () => running,
ensure: async () => {},
async add(request) {
if (fails) throw new Error('Packages.startHelper: the payload loader on port 9021 did not answer');
added.push(request);
const token = request.token || 'e'.repeat(64);
return {url: `http://192.168.1.20:42000/${token}/${request.file}`, localUrl: `http://127.0.0.1:42000/${token}/${request.file}`, token, port: 42000, background: true};
},
stats: async () => added.map(request => ({token: request.token || 'e'.repeat(64), total: request.total, delivered: delivered(), requests: 4, idleMs: 0})),
remove: async token => { removed.push(token); },
};
}
const HELPER_LAN = 'http://192.168.1.20:42000/' + 'e'.repeat(64) + '/game.pkg';
test('a refused link goes to the helper, so Overdrive can close while the console downloads it', async () => {
const helper = fakeHelper();
const fixture = harness({console: url => url.includes(':42000/') ? 'accepted' : 'refused', format: 'pkg', helper});
const job = await settled(fixture);
assert.equal(job.state, 'installed', job.error);
assert.equal(job.proxy.background, true);
assert.equal(job.message, 'downloads.streamedBackground');
assert.deepEqual(fixture.installs.map(item => item.url), ['https://cdn.example/file.pkg', HELPER_LAN]);
assert.equal(fixture.proxied.length, 0, 'the app serves nothing itself');
assert.deepEqual(helper.added, [{url: 'https://cdn.example/file.pkg', total: 100e9, file: 'game.pkg'}]);
});
test('a helper that does not start leaves the app serving it, which has to stay open', async () => {
const lines = [];
const original = console.log;
console.log = (...parts) => lines.push(parts.join(' '));
let fixture;
try {
fixture = harness({console: url => url.startsWith('http://192.168.1.20:41000') ? 'accepted' : 'refused', format: 'pkg', helper: fakeHelper({fails: true})});
const job = await settled(fixture);
assert.equal(job.proxy.background, false);
assert.equal(job.message, 'downloads.streamedProxy');
} finally {
console.log = original;
}
assert.ok(lines.some(line => line.includes('the package helper did not start')), lines.join('\n'));
});
test('a download asked for in Overdrive is never handed to the console', async () => {
const helper = fakeHelper();
const fixture = harness({console: 'refused', format: 'pkg', helper, streamMode: 'here'});
const job = await settled(fixture);
assert.equal(job.streamMode, 'here');
assert.equal(fixture.installs.filter(item => item.url).length, 0);
assert.equal(fixture.enqueued.length, 1);
assert.equal(helper.added.length, 0);
});
test('reopened, the app finds the helper still serving and shows its progress', async () => {
let sent = 2e9;
const helper = fakeHelper({running: true, delivered: () => sent});
const proxy = {token: 'f'.repeat(64), port: 42000, origin: 'https://cdn.example/file.pkg', total: 4e9, file: 'game.pkg', since: Date.now(), background: true};
const fixture = harness({saved: servedJob(proxy), helper});
for (const until = Date.now() + 2000; !helper.added.length && Date.now() < until;) await new Promise(resolve => setTimeout(resolve, 10));
assert.deepEqual(helper.added, [{url: 'https://cdn.example/file.pkg', total: 4e9, file: 'game.pkg', token: 'f'.repeat(64)}]);
assert.equal(fixture.proxied.length, 0);
for (const until = Date.now() + 8000; Date.now() < until;) {
const job = fixture.queue.snapshot().find(item => item.id === fixture.id);
if (job.proxy.done) return;
if (job.message === 'downloads.helperProgress') sent = 4e9;
await new Promise(resolve => setTimeout(resolve, 20));
}
assert.fail('the helper progress was never shown through to the end');
});
// Delete on a paused download takes it off the list and its partial off the drive. A second function
// named like the one that does it once replaced it, and Delete did nothing at all (Horizon, 2026-10-25).
test('a paused download that is deleted leaves the queue and the drive', async () => {
const fixture = harness({format: 'pkg', streamMode: 'here', endless: true});
await settled(fixture, ['downloading']);
fixture.queue.pause(fixture.id);
await settled(fixture, ['paused']);
await fixture.queue.cancel(fixture.id);
assert.equal(fixture.queue.snapshot().find(item => item.id === fixture.id), undefined);
assert.ok(![...fixture.files.keys()].some(path => path.endsWith('.part')), [...fixture.files.keys()].join(', '));
});
+495 -27
View File
@@ -21,7 +21,7 @@ import { VIKING_PAGE_PREFIX, VIKING_PREFIX, describeVikingCapture, linkPageMetad
var ACTIVE = ["resolving", "connecting", "downloading", "verifying", "extracting", "installing", "pausing"];
var BUSY = ["queued", ...ACTIVE];
var COMPLETE = ["downloaded", "file-downloaded", "installer-required", "installed", "extraction-required", "extracted"];
var ADOPTED = "A partial download from another source holds this name. Resume it from its original source, or delete it in Files.";
var ADOPTED = "A partial download from another source holds this name. Retry starts over and replaces it, or resume it from its original source.";
var DELETED = "Its partial files were deleted. Restart to download it again, or remove it.";
var REMOVAL_CUT = "The app closed while deleting its files. Delete it again.";
// The passwords the catalog's archives are published with. Some entries name none or the wrong one,
@@ -34,6 +34,14 @@ import { VIKING_PAGE_PREFIX, VIKING_PREFIX, describeVikingCapture, linkPageMetad
// A LinkVault page adds its own check and a mirror page before the host's, so a link capture gets
// the payload's longest window.
var LINK_CAPTURE_SECONDS = 300;
// Datanodes' dlproxy.uk gateway (FileKeeper's files too) can answer every range HTTP 503 with
// Retry-After: 60 for a while. The engine honours it, with its connections down to a few, and the
// download sits at 0 MB/s for minutes; a new transfer of the same link, which Pause and Resume
// start, goes on at full speed at once (issue #77). So a download from it that gets nothing for
// STALL_MS starts over by itself, from what it has written. A third start in a row with nothing
// received asks the source for a new link; past STALL_RESTARTS the engine's own retries decide.
var STALL_MS = 9e4;
var STALL_RESTARTS = 10;
// The folder an extraction is written to, and its game's under homebrew: the source's file name
// without the ".binary" a source of unknown format downloads under. Jobs extracted before used it
// with that suffix, so ownedPaths claims both.
@@ -93,7 +101,9 @@ import { VIKING_PAGE_PREFIX, VIKING_PREFIX, describeVikingCapture, linkPageMetad
delete job2.captureError;
delete job2.captureFile;
delete job2.fallbacks;
if (job2.state === "installing") return { ...job2, state: "installer-required" };
// Closed while the console was being handed its link: resumed, it downloads here.
if (job2.state === "installing" && !job2.streaming) return { ...job2, state: "installer-required" };
delete job2.streaming;
if (job2.state === "extracting") job2.written = job2.total ?? job2.written;
if (job2.state === "removing") {
job2.state = job2.removingFrom ?? "failed";
@@ -126,6 +136,10 @@ import { VIKING_PAGE_PREFIX, VIKING_PREFIX, describeVikingCapture, linkPageMetad
packages,
releasesOf,
browserCapture,
stallMs = STALL_MS,
// The package helper (pkg-helper.js), whose links keep working once the app has closed; without
// one, a package the console takes from the app is served by the app itself.
helper = null,
wait = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms))
}) {
const legacy = `${dataDir}/overdrive/downloads.json`;
@@ -134,6 +148,9 @@ import { VIKING_PAGE_PREFIX, VIKING_PREFIX, describeVikingCapture, linkPageMetad
const listeners4 = /* @__PURE__ */ new Set();
sources ?? (sources = createSourceResolver({ http }));
const captures = /* @__PURE__ */ new Map();
// The packages this session serves to the console's own download: rest mode would suspend the app
// and cut them, so the console stays awake while the app runs with one.
const serving = /* @__PURE__ */ new Set();
let jobs = [];
let active2 = null;
let ticks = 0;
@@ -169,7 +186,7 @@ import { VIKING_PAGE_PREFIX, VIKING_PREFIX, describeVikingCapture, linkPageMetad
}
const snapshot = () => jobs.map((job2) => ({ ...job2 }));
const emit2 = () => {
const busy = captures.size > 0 || jobs.some((job2) => BUSY.includes(job2.state));
const busy = captures.size > 0 || serving.size > 0 || jobs.some((job2) => BUSY.includes(job2.state));
if (busy !== awake2) {
awake2 = busy;
power.keepAwake(busy);
@@ -186,6 +203,139 @@ import { VIKING_PAGE_PREFIX, VIKING_PREFIX, describeVikingCapture, linkPageMetad
save();
emit2();
};
// The packages served to the console's own download (see streamed). Each job keeps its link's token
// and port, so a later session serves the same URL again and the console's queue, which retries
// it, reconnects without a second install. What the console has fetched shows on the row; once it
// has every byte, or after a week, nothing more is served for it.
const SERVE_FOR_MS = 7 * 24 * 3600e3;
// id -> "helper" (the payload, which outlives the app) or "app" (the server in this process, which
// rest mode would cut: the console stays awake for it, see emit2).
const watched = /* @__PURE__ */ new Map();
const sentBefore = /* @__PURE__ */ new Map();
let serveTimer = null, looks = 0;
const watch = (id, where) => {
watched.set(id, where);
if (where === "app") serving.add(id);
};
const unwatch = (id) => {
watched.delete(id);
serving.delete(id);
sentBefore.delete(id);
};
// The links' progress by token, from each server that serves one; a server that did not answer is
// left out, and its jobs wait for the next look instead of being dropped.
const readStats = async () => {
const byToken = /* @__PURE__ */ new Map(), answered = /* @__PURE__ */ new Set();
const wanted = new Set(watched.values());
if (wanted.has("app")) {
try {
for (const item of packages.proxyStats()) byToken.set(item.token, item);
answered.add("app");
} catch {
}
}
if (wanted.has("helper") && helper) {
try {
for (const item of await helper.stats()) byToken.set(item.token, item);
answered.add("helper");
} catch (error2) {
if (looks % 20 === 0) console.log(`[download-queue] the package helper did not answer: ${error2.message}`);
}
}
return { byToken, answered };
};
const look = async () => {
const { byToken, answered } = await readStats();
looks++;
let shown = false, settled = false;
for (const [id, where] of [...watched]) {
if (!answered.has(where)) continue;
const job2 = jobs.find((item) => item.id === id);
const entry = job2?.proxy && byToken.get(job2.proxy.token);
if (!entry) {
unwatch(id);
settled = true;
continue;
}
const fetched = Math.min(entry.delivered, entry.total);
// Every 30 s, how fast the console takes it: the one number that compares this with a
// download in Overdrive.
if (looks % 10 === 0) {
const before = sentBefore.get(id) ?? { delivered: 0, gets: 0, asked: 0 };
sentBefore.set(id, { delivered: entry.delivered, gets: entry.gets || 0, asked: entry.asked || 0 });
// The size of the console's ranges says whether reading ahead of it would help.
const ranges = (entry.gets || 0) - before.gets;
const size = ranges > 0 ? `, ${ranges} ranges of ${Math.round(((entry.asked || 0) - before.asked) / ranges / 1024)} KiB on average` : "";
if (entry.delivered > before.delivered) console.log(`[download-queue] ${id}: the console fetched ${formatBytes(entry.delivered - before.delivered)} in 30 s (${((entry.delivered - before.delivered) / 30 / 1048576).toFixed(1)} MB/s) from the ${where === "helper" ? "helper" : "app"}, ${Math.floor(fetched / entry.total * 100)}%${size}`);
}
if (fetched >= entry.total) {
job2.proxy.done = true;
unwatch(id);
job2.message = t("downloads.proxyDone");
console.log(`[download-queue] ${id}: the console has fetched every byte (${entry.requests} requests); it installs the rest by itself`);
settled = true;
continue;
}
const values = { done: formatBytes(fetched), total: formatBytes(entry.total), percent: String(Math.floor(fetched / entry.total * 100)) };
const message = t(where === "helper" ? "downloads.helperProgress" : "downloads.proxyProgress", values);
if (message !== job2.message) {
job2.message = message;
shown = true;
}
}
if (!watched.size) {
clearInterval(serveTimer);
serveTimer = null;
}
// Progress is only shown; what changes the job for good is saved.
if (settled) change();
else if (shown) emit2();
};
const watchServing = () => {
if (serveTimer || !watched.size) return;
let busy = false;
serveTimer = setInterval(() => {
if (busy) return;
busy = true;
look().catch((error2) => console.log(`[download-queue] serving watch: ${error2.message}`)).finally(() => {
busy = false;
});
}, 3e3);
serveTimer.unref?.();
};
// After a restart: every link still being fetched is served again, on its own token and port, by
// the helper that may still be running, or by a new one, or by the app for a link it served itself.
const resumeServing = async () => {
if (platform !== "ps5") return;
for (const job2 of jobs) {
const proxy = job2.proxy;
if (!proxy || proxy.done) continue;
if (Date.now() - (proxy.since || 0) > SERVE_FOR_MS) {
proxy.done = true;
continue;
}
try {
if (proxy.background && helper) {
const running = await helper.alive();
const again = await helper.add({ url: proxy.origin, total: proxy.total, file: proxy.file, token: proxy.token });
if (again.port !== proxy.port) console.log(`[download-queue] ${job2.id}: served again on port ${again.port}, not ${proxy.port}: the console's download cannot reconnect, so Retry it in the PS5's Downloads or download it again`);
else console.log(`[download-queue] ${job2.id}: ${running ? "the helper is still serving it to the console" : "the helper serves it to the console again after a restart"} (${formatBytes(proxy.total)})`);
watch(job2.id, "helper");
job2.message = t("downloads.streamedBackground");
} else if (!proxy.background && typeof packages?.proxy === "function") {
const again = packages.proxy({ url: proxy.origin, total: proxy.total, file: proxy.file, token: proxy.token, port: proxy.port });
if (again.port !== proxy.port) console.log(`[download-queue] ${job2.id}: served again on port ${again.port}, not ${proxy.port}: the console's download cannot reconnect, so Retry it in the PS5's Downloads or download it again`);
else console.log(`[download-queue] ${job2.id}: served to the console again after a restart (${formatBytes(proxy.total)})`);
watch(job2.id, "app");
job2.message = t("downloads.streamedProxy");
}
} catch (error2) {
console.log(`[download-queue] ${job2.id}: not served again: ${error2.message}`);
}
}
emit2();
watchServing();
};
if (legacy !== file && fs.exists(legacy)) {
try {
if (stored === legacy) save();
@@ -201,6 +351,7 @@ import { VIKING_PAGE_PREFIX, VIKING_PREFIX, describeVikingCapture, linkPageMetad
console.log(`[download-queue] queue not saved to ${file}: ${error2.message}`);
}
}
void resumeServing();
// A finished download that stayed a ".binary" before its header check knew PS5 packages and its
// name was read (issues #24, #42) is routed now, once at launch, as a new one would be: a game
// image to the homebrew folder for ShadowMount+, a PKG to the packages folder to install. One
@@ -254,15 +405,11 @@ import { VIKING_PAGE_PREFIX, VIKING_PREFIX, describeVikingCapture, linkPageMetad
destination: `${root2}/downloads/extracted/${release.filename}`,
nextAction: "extracted"
} : formats.artifact(release, root2);
// Datanodes' dlproxy.uk gateway (FileKeeper's files too) can answer every range HTTP 503 with
// Retry-After: 60 for a while. The engine honours it, with its connections down to a few, and the
// download sits at 0 MB/s for minutes; a new transfer of the same link, which Pause and Resume
// start, goes on at full speed at once (issue #77). So a download from it that gets nothing for
// STALL_MS starts over by itself, from what it has written. A third start in a row with nothing
// received asks the source for a new link; past STALL_RESTARTS the engine's own retries decide.
var STALL_MS = 9e4;
var STALL_RESTARTS = 10;
function restartsAfterStall(job2) {
// The hosts whose gateway answers every range HTTP 503 for a while: DataNodes and FileKeeper
// both. Named by kind too, since the resolved URL of a captured link need not be the gateway.
const source2 = job2.parts ? job2.currentSource : job2.release;
if (source2 && ["filekeeper", "datanodes"].includes(sourceKind(source2))) return true;
try {
return /(?:^|\.)dlproxy\.uk$/.test(webURL(job2.options?.url).host);
} catch {
@@ -270,27 +417,77 @@ import { VIKING_PAGE_PREFIX, VIKING_PREFIX, describeVikingCapture, linkPageMetad
}
}
function stallWatch(job2, task) {
let moved = Date.now(), seen = -1;
const began = Date.now();
let moved = began, best = -1, gaps = [];
// A gateway that sends a few seconds and then asks a minute's wait, over and over, never stays
// quiet for the stall time: FileKeeper's edge01.fetchio.cc sent 6-8 s at 30 MB/s, then HTTP 503
// with Retry-After 60, and averaged 2.7 MB/s for 106 minutes (issue #77, Elefantex's log). A
// transfer idle for three quarters of the last 300 s (with the stall time at 90 s) is restarted too.
const idleWindow = stallMs * 10 / 3, idleGap = stallMs * 2 / 9;
// FileKeeper's gateway still answered 503 with Retry-After 60 after 96 s at 11 MB/s on sixteen
// connections, and the download sat out 52 s of it (2026-10-25). Its form gives a new link after a
// five-second countdown, so 12 s of nothing asks for one instead of waiting the minute; when the
// first new link brings nothing back either, this download waits the gateway out as before.
const quickMs = stallMs * 2 / 15;
const filekeeper = sourceKind(job2.release) === "filekeeper" && !job2.parts;
const restart = (relink, line) => {
console.log(`[download-queue] ${job2.id}: ${line}${relink ? ", with a new link from the source" : ""}`);
if (relink) delete job2.options;
active2.restarting = true;
task.cancel();
};
return (progress) => {
const bytes = (progress.received || 0) + (progress.completedBytes ?? progress.written ?? 0);
if (bytes !== seen) {
if (seen >= 0 && job2.stalls) {
// Only a new high counts: a range the engine retries drops `received` back to what the file
// already holds, and with a plain "changed" test every one of those reset the wait, so a
// download 503-ing range after range never reached it (issue #77, Narci115's 4.7 MB log).
if (bytes > best) {
if (best >= 0 && job2.stalls) {
console.log(`[download-queue] ${job2.id}: receiving again after ${job2.stalls} restart(s)`);
delete job2.stalls;
}
seen = bytes;
moved = Date.now();
if (best >= 0 && job2.quickRelinkAt) {
console.log(`[download-queue] ${job2.id}: the new link brought it back ${Math.round((Date.now() - job2.quickRelinkAt) / 1e3)} s after it was asked for`);
delete job2.quickRelinkAt;
}
const now = Date.now();
if (best >= 0 && now - moved >= idleGap) gaps.push({ at: now, gap: now - moved });
gaps = gaps.filter((item) => now - item.at < idleWindow);
best = bytes;
moved = now;
const idle = gaps.reduce((sum, item) => sum + item.gap, 0);
if (active2?.restarting || now - began < idleWindow || idle < idleWindow * 0.75 || !restartsAfterStall(job2)) return;
if ((job2.idleRestarts || 0) >= STALL_RESTARTS) return;
job2.idleRestarts = (job2.idleRestarts || 0) + 1;
// Every second one asks FileKeeper's form for a new link: whether a fresh link or a fresh
// transfer is what the gateway wants is not known yet, and app.log shows which one helped.
const relink = job2.idleRestarts % 2 === 0 && sourceKind(job2.release) === "filekeeper";
restart(relink, `idle ${Math.round(idle / 1e3)} s of the last ${Math.round(idleWindow / 1e3)} s in bursts and waits (${gaps.length} waits); restart ${job2.idleRestarts} of ${STALL_RESTARTS}`);
return;
}
if (active2?.restarting || Date.now() - moved < STALL_MS || !["connecting", "downloading"].includes(progress.state) || !restartsAfterStall(job2)) return;
const quiet = Date.now() - moved;
if (filekeeper && !active2?.restarting && progress.state === "downloading" && quiet >= quickMs && job2.quickRelink !== false) {
// A new link that went quiet as well: the gateway is not refusing the link, so another one
// will not help either, and this download waits it out with the rules below.
if (job2.quickRelinkAt) {
if (Date.now() - job2.quickRelinkAt >= quickMs * 4) {
console.log(`[download-queue] ${job2.id}: a new link did not bring it back; waiting FileKeeper's gateway out instead`);
job2.quickRelink = false;
delete job2.quickRelinkAt;
}
} else if ((job2.quickRelinks || 0) < 30) {
job2.quickRelinks = (job2.quickRelinks || 0) + 1;
job2.quickRelinkAt = Date.now();
restart(true, `nothing received for ${Math.round(quiet / 1e3)} s on FileKeeper's gateway; a new link instead of waiting its minute out (${job2.quickRelinks} of 30)`);
return;
}
}
if (active2?.restarting || quiet < stallMs || !["connecting", "downloading"].includes(progress.state) || !restartsAfterStall(job2)) return;
if ((job2.stalls || 0) >= STALL_RESTARTS) return;
job2.stalls = (job2.stalls || 0) + 1;
// Only FileKeeper's link comes from a form the app posts again; DataNodes' needs the browser.
const relink = job2.stalls % 3 === 0 && sourceKind(job2.release) === "filekeeper";
console.log(`[download-queue] ${job2.id}: nothing received for ${Math.round((Date.now() - moved) / 1e3)} s (connections ${progress.connections || 0}, retries ${progress.retries || 0}); restart ${job2.stalls} of ${STALL_RESTARTS}${relink ? ", with a new link from the source" : ""}`);
if (relink) delete job2.options;
active2.restarting = true;
task.cancel();
restart(relink, `nothing received for ${Math.round((Date.now() - moved) / 1e3)} s (connections ${progress.connections || 0}, retries ${progress.retries || 0}); restart ${job2.stalls} of ${STALL_RESTARTS}`);
};
}
function reresolve(job2) {
@@ -344,7 +541,7 @@ import { VIKING_PAGE_PREFIX, VIKING_PREFIX, describeVikingCapture, linkPageMetad
await task.done;
installed.push(path);
} catch (error2) {
console.log(`[download-queue] install refused ${path}:`, error2.message);
console.log(`[download-queue] install refused ${path}: ${consoleFailure(error2.message)}`);
if (!backport(path) || paths.every(backport)) throw error2;
} finally {
off();
@@ -369,6 +566,260 @@ import { VIKING_PAGE_PREFIX, VIKING_PREFIX, describeVikingCapture, linkPageMetad
change();
}
const canInstall = () => Boolean(packages) && platform === "ps5";
// PKG streaming (issue #50): a plain PKG goes by its link to the console's own download queue, which
// installs it in the background, with Overdrive closed too, and with no copy on the drive first.
// Archives and game images stay here, as does anything the console refuses: that downloads as before.
// The format is read from the file's first bytes, since most sources name none ("binary").
var STREAMABLE = ["pkg", "fpkg", "binary"];
var PKG_PROBE_BYTES = 4096;
var PS4_CONTENT_ID = /[A-Z]{2}\d{4}-[A-Z]{4}\d{5}_\d{2}-[A-Z0-9]{16}/;
// How long the console has to start fetching the link before the app leaves it to the console.
var STREAM_WATCH_MS = 3e4;
// How big the package is, from the range the host answered with: next to the internal drive's free
// space, it is the one line that says whether the console had room to take the link at all.
var packageSize = (headers) => Number(String(headers?.["content-range"] || "").split("/")[1]) || 0;
async function pkgLink(job2, signal) {
let url = job2.options.url;
for (let hop = 0; hop < 5; hop++) {
const response = await http.request(url, { headers: { Range: `bytes=0-${PKG_PROBE_BYTES - 1}` }, maxBytes: 64 * 1024, signal });
const location = response.headers.location;
if (response.status >= 300 && response.status < 400 && /^https?:\/\//i.test(location || "")) {
url = location;
continue;
}
// A host that ignores the range sends the whole file: the console's downloader needs ranges too.
if (response.status !== 206) {
console.log(`[download-queue] ${job2.id}: not streamed, the host answered ${response.status} instead of a range`);
return null;
}
const head = await response.text();
const ps4 = head.startsWith("\x7FCNT"), ps5 = head.startsWith("\x7FFIH");
const contentId = ps4 ? head.slice(0, 256).match(PS4_CONTENT_ID)?.[0] : "";
// Said out loud: a silent decline leaves "downloading it here instead" with no reason next to it.
if (!ps4 && !ps5) {
console.log(`[download-queue] ${job2.id}: not streamed, its first bytes are not a PKG`);
return null;
}
if (contentId === void 0) {
console.log(`[download-queue] ${job2.id}: not streamed, its PS4 header names no content ID`);
return null;
}
return { url, contentId, kind: ps4 ? "PS4" : "PS5", total: packageSize(response.headers), headers: response.headers };
}
return null;
}
// Why the console said no: its code is the only thing that names the cause, and its error arrives as
// "Packages.install <url>: <reason> (0x<code>)". app.log cuts a line at 382 characters, so a signed
// CDN link filled the line and both the reason and the code went missing exactly when they were
// wanted (a Wolverine fPKG, 2026-10-25). The link is logged already, masked, on the line before.
var consoleFailure = (message) => {
const text = String(message || "");
const code = text.match(/\(0x([0-9a-zA-Z]+)\)/)?.[1];
// The frame says it as "Packages.install: <url>: <reason> (0x<code>)": the last piece before the
// code is the reason, and reading it from there keeps a signed CDN link out of app.log's 382
// characters, which are not enough for both.
const parts = text.replace(/\(0x[0-9a-zA-Z]+\)\s*$/, "").split(": ");
const reason = parts[parts.length - 1].replace(/https?:\/\/\S+/g, "").trim();
return `code=0x${code || "none"}${reason ? ` ${reason}` : ""}`;
};
// What the console's own install has to work with: it installs to the internal drive, not to this
// job's, and a full one is refused before anything is fetched.
var internalFree = () => {
try {
return formatBytes(fs.diskUsage("/data").free);
} catch {
return "unknown";
}
};
// The ways the console is offered the package, in order. First the link as it came, which the
// console fetches by itself, also once the app has closed. Then the same file served by the app:
// the console asks a HEAD before it fetches, and a pre-signed link (Vikingfile's S3 storage) signs
// only the GET it was made for, so the HEAD got 403 and the console refused it with 0x80B22403
// whatever shape the link had (".pkg" on the end and a sorted query were tried and refused alike,
// 2026-10-25). The app's link names the console's LAN address, which its queue treats as any other
// origin (Pegasus DL+ serves its packages the same way), and 127.0.0.1 after it. The first shape the
// console takes is the one it installs.
var linkShapes = (link) => {
const shapes = [{ name: "as it came", url: link.url }];
if ((helper || typeof packages?.proxy === "function") && link.total)
shapes.push({ name: "through Overdrive", proxied: "url" }, { name: "through Overdrive on 127.0.0.1", proxied: "localUrl" });
return shapes;
};
// Overdrive's own link to the package, or null when it cannot serve one. The helper first: its link
// keeps working once the app closes, which is the point of handing the package to the console. The
// app's own server is the fallback (no loader on 9021, a helper that did not start), and then
// Overdrive has to stay open until the console has it.
var proxyLink = async (job2, link) => {
const file = job2.release.filename || job2.title;
if (helper) {
try {
return await helper.add({ url: link.url, total: link.total, file });
} catch (error2) {
console.log(`[download-queue] ${job2.id}: the package helper did not start (${error2.message}); serving it from the app, which has to stay open`);
}
}
if (typeof packages?.proxy !== "function") return null;
try {
return { ...packages.proxy({ url: link.url, total: link.total, file }), background: false };
} catch (error2) {
console.log(`[download-queue] ${job2.id}: the app could not serve it to the console: ${error2.message}`);
return null;
}
};
// Stops serving a link. Not "forget": that one deletes a job's files and drops it from the queue, and
// a second function of that name replaced it, so Delete removed nothing (2026-10-25).
var unserve = (served) => served.background ? helper?.remove(served.token) : packages?.proxyRemove?.(served.token);
// What the host answers: the console's refusal names no cause, and this is the response it would have
// had in front of it. A one-byte range, because a host that serves 85 GB will not answer a plain
// request at all ("response exceeds maxBytes", 2026-10-25).
var describeHost = async (job2, url) => {
const header = (response, key) => String(response?.headers?.[key] ?? "-").slice(0, 70);
try {
const response = await http.request(url, { headers: { Range: "bytes=0-0" }, maxBytes: 8 * 1024 });
console.log(`[download-queue] ${job2.id}: the host answers ${response.status} to a one-byte range, ${header(response, "content-type")}, ${header(response, "content-range") || header(response, "content-length")} bytes, ${header(response, "content-disposition")}`);
// A HEAD is what a downloader may ask before it fetches, and a pre-signed URL signs the method
// it was made for: a 403 here would be a refusal no shape of the link can cure (2026-10-25).
try {
const head = await http.request(url, { method: "HEAD", maxBytes: 8 * 1024 });
console.log(`[download-queue] ${job2.id}: the host answers ${head.status} to a HEAD, ${header(head, "content-length") || header(head, "content-range")} bytes, ${header(head, "etag")}`);
} catch (error2) {
console.log(`[download-queue] ${job2.id}: the HEAD failed: ${error2.message}`);
}
// Names only: a signed CDN's values alone are longer than the line app.log keeps.
const mark = url.indexOf("?");
if (mark > 0) console.log(`[download-queue] ${job2.id}: its query names ${url.slice(mark + 1).split("&").map(part => part.split("=")[0]).join(", ")}`);
} catch (error2) {
console.log(`[download-queue] ${job2.id}: a one-byte range of the host failed: ${error2.message}`);
}
};
// One ask of the console: it either takes the link or says no.
var handOver = async (job2, url, link) => {
let task;
// How long the console took to say no: an immediate no never tried to fetch, a late one did.
const asked = Date.now();
const accepted = await new Promise((resolve) => {
let settled = false, off = null, timer = null;
const settle = (value) => {
if (settled) return;
settled = true;
clearTimeout(timer);
off?.();
resolve(value);
};
try {
task = packages.install({ url, name: job2.title, contentId: link.contentId });
} catch (error2) {
console.log(`[download-queue] ${job2.id}: the console was not asked: ${error2.message}`);
settle(false);
return;
}
active2.task = task;
// Accepted once the console reports bytes, or has said nothing against it within the watch.
off = task.subscribe((progress) => {
if (progress.written > 0) settle(true);
});
if (settled) off();
task.done.then(() => settle(true), (error2) => {
const text = String(error2?.message || "");
const why = consoleFailure(text);
// The app stops the task itself once the console has taken the link: that is not a failure,
// and neither is a task closed without a code of its own ("the installer stopped answering",
// which is the app letting go of it, logged after every accepted link, 2026-10-25).
if (!/cancel/i.test(text) && !(settled && why.startsWith("code=0xnone"))) console.log(settled
? `[download-queue] ${job2.id}: the install failed after the console took the link: ${why}`
: `[download-queue] ${job2.id}: the console refused the link after ${Date.now() - asked} ms: ${why}`);
settle(false);
});
timer = setTimeout(() => settle(task.snapshot.state === "installing"), STREAM_WATCH_MS);
});
return { task, accepted };
};
async function streamed(job2, signal) {
const options = job2.options;
const format = String(job2.release.format || "binary").toLowerCase();
if (!canInstall() || job2.streamTried || job2.written || job2.output || !STREAMABLE.includes(format) || !options?.url || options.pieces || Object.keys(options.headers || {}).length) return false;
// A part file means this job already began downloading here: the console would start from zero.
if (fs.exists(`${job2.downloadDestination}.part`)) {
console.log(`[download-queue] ${job2.id}: not streamed, it already has a part file here`);
return false;
}
// The player asked for it in Overdrive, with pause, resume and the install at the end.
if (job2.streamMode === "here") {
console.log(`[download-queue] ${job2.id}: downloading it in Overdrive, as asked`);
return false;
}
let link;
try {
link = await pkgLink(job2, signal);
} catch (error2) {
if (!signal.aborted) console.log(`[download-queue] ${job2.id}: not streamed, its first bytes were not read: ${error2.message}`);
return false;
}
if (!link) return false;
job2.streamTried = true;
job2.streaming = true;
job2.state = "installing";
job2.written = 0;
job2.message = "";
change();
console.log(`[download-queue] ${job2.id}: a ${link.kind} PKG${link.total ? ` of ${formatBytes(link.total)}` : ""}, handed to the console's downloads${link.contentId ? ` as ${link.contentId}` : ""}: ${linkForLog(link.url)}`);
// How the host served the bytes the console was offered: the same line for a link it takes and one
// it refuses is the only way to see what the two hosts do differently (2026-10-25).
console.log(`[download-queue] ${job2.id}: the host serves it as ${String(link.headers?.["content-type"] || "-").slice(0, 60)}, ${String(link.headers?.["content-disposition"] || "-").slice(0, 90)}`);
// The install goes to the internal drive: a full one is a refusal that looks like any other.
console.log(`[download-queue] ${job2.id}: internal drive free ${internalFree()}`);
const shapes = linkShapes(link);
let task = null, accepted = false, taken = null, served;
for (let attempt = 0; attempt < shapes.length && !accepted && job2.state !== "pausing"; attempt++) {
const shape = shapes[attempt];
// Served once, offered on each address: a console without a LAN address skips that one.
if (shape.proxied && served === void 0) served = await proxyLink(job2, link);
if (shape.proxied && !served) break;
const url = shape.proxied ? served[shape.proxied] : shape.url;
if (!url) continue;
// The app's own link names no secret: it goes to app.log whole.
if (attempt) console.log(`[download-queue] ${job2.id}: offered it again ${shape.name} to the console: ${shape.proxied ? url : linkForLog(url)}`);
const answer = await handOver(job2, url, link);
task = answer.task;
accepted = answer.accepted;
if (accepted) taken = shape;
else if (attempt === 0) await describeHost(job2, url);
}
delete job2.streaming;
// The console goes on by itself with what it took: the app stops listening, and waits until the
// installer has let go, since it takes one package at a time (the next one, or this one's
// download here, would be turned away).
if (task) {
task.cancel();
await task.done.catch(() => {});
}
// A link the console never took is not served.
if (served && !taken?.proxied) void unserve(served);
if (job2.state === "pausing") return true;
if (!accepted) {
job2.state = "resolving";
job2.written = 0;
change();
console.log(`[download-queue] ${job2.id}: downloading it here instead`);
return false;
}
console.log(`[download-queue] ${job2.id}: the console took it ${taken.name}`);
job2.state = "installed";
job2.streamed = true;
job2.written = job2.total || 0;
job2.bytesPerSecond = 0;
// Served by the app, the console's download lasts as long as the app does, or until a later
// session serves it again from what is kept here.
if (taken.proxied) {
job2.proxied = true;
job2.proxy = { token: served.token, port: served.port, origin: link.url, total: link.total, file: job2.release.filename || job2.title, since: Date.now(), background: Boolean(served.background) };
watch(job2.id, served.background ? "helper" : "app");
watchServing();
}
job2.message = t(!taken.proxied ? "downloads.streamed" : served.background ? "downloads.streamedBackground" : "downloads.streamedProxy");
notifications.show(t("downloads.notice.streamed"), job2.title);
return true;
}
async function execute(job2) {
let task;
let unsubscribe;
@@ -400,7 +851,9 @@ import { VIKING_PAGE_PREFIX, VIKING_PREFIX, describeVikingCapture, linkPageMetad
job2.options = formats.manifest(await response.json());
} else {
if (!job2.release.url && !job2.release.browserUrl) throw new Error("This release has no download URL.");
const resolved = job2.directURL || sourceKind(job2.release) !== "direct" ? await sources.resolve(job2.release, { signal, directURL: job2.directURL }) : { url: webURL(job2.release.url).url };
// A pasted link may come from a service of the user's own (a qBittorrent's WebUI), whose
// file is behind its password: the release carries the headers for it (link-download.js).
const resolved = job2.directURL || sourceKind(job2.release) !== "direct" ? await sources.resolve(job2.release, { signal, directURL: job2.directURL }) : { url: webURL(job2.release.url).url, headers: job2.release.headers };
job2.options = {
...resolved,
rejectHtml: true,
@@ -414,6 +867,7 @@ import { VIKING_PAGE_PREFIX, VIKING_PREFIX, describeVikingCapture, linkPageMetad
change();
}
if (job2.state === "pausing") return;
if (await streamed(job2, signal) || job2.state === "pausing") return;
const root2 = storageIdentity(job2);
if (!job2.rootIdentity) {
job2.rootIdentity = root2;
@@ -549,7 +1003,8 @@ import { VIKING_PAGE_PREFIX, VIKING_PREFIX, describeVikingCapture, linkPageMetad
// are none Overdrive knows, the host sent something else, usually a web page. Leaving it as a
// downloaded ".binary" (issue #8) told the user nothing. Only a small file reads as one, though:
// gigabytes the headers don't name are still the game, in a format not checked (issue #24).
if (!kind && job2.release.format === "binary" && (fs.stat(job2.downloadDestination)?.size || 0) < WEB_PAGE_BYTES) throw new Error("Not a game file: the host sent something else.");
// A pasted link is the user's own file, not a catalog entry: whatever it is, it is kept.
if (!kind && !job2.release.link && job2.release.format === "binary" && (fs.stat(job2.downloadDestination)?.size || 0) < WEB_PAGE_BYTES) throw new Error("Not a game file: the host sent something else.");
const named = kind ? null : namedKind(job2);
if (!kind && !named) return;
if (named) console.log(`[download-queue] headers name no format; the file's name says ${named}`);
@@ -1617,7 +2072,9 @@ import { VIKING_PAGE_PREFIX, VIKING_PREFIX, describeVikingCapture, linkPageMetad
listener(snapshot());
return () => listeners4.delete(listener);
},
enqueue(game, release, root2) {
// `streamMode`: "console" hands a PKG to the PS5's own Downloads, "here" keeps it in Overdrive;
// none tries the console first, as before the question existed.
enqueue(game, release, root2, { streamMode } = {}) {
const id = `${game.id}/${release.id}`;
const existing = jobs.find((job2) => job2.id === id);
if (existing) return existing.id;
@@ -1638,6 +2095,7 @@ import { VIKING_PAGE_PREFIX, VIKING_PREFIX, describeVikingCapture, linkPageMetad
jobs.push({
id,
gameId: game.id,
...streamMode === "console" || streamMode === "here" ? { streamMode } : {},
// A source of another region or edition names its file's own title id: the capture looks
// for it, and the files carry it, so it is no "another game?" warning.
titleId: release.titleId || game.titleId || game.id,
@@ -1697,8 +2155,13 @@ import { VIKING_PAGE_PREFIX, VIKING_PREFIX, describeVikingCapture, linkPageMetad
async restart(id) {
const job2 = find(id);
if (!["paused", "failed", "awaiting-browser"].includes(job2.state) || active2?.job === job2) return;
if (job2.adopted) throw new Error(ADOPTED);
const paths = !job2.parts?.[job2.partIndex]?.completed && (job2.options || job2.rootIdentity || job2.written || fs.exists(job2.artifact.destination + ".part") || fs.exists(job2.artifact.destination + ".resume")) ? partialFiles(job2) : [];
// A partial this app did not write ("adopted", left by an earlier session) is what the last
// attempt stopped on: its checkpoint names another run and can never be resumed. Retry is the
// player asking to start over, so that partial goes with the rest; refusing here left the task
// failed for good, with no way to resume it or begin it again (issue #111).
const adopted = job2.adopted;
delete job2.adopted;
const paths = !job2.parts?.[job2.partIndex]?.completed && (adopted || job2.options || job2.rootIdentity || job2.written || fs.exists(job2.artifact.destination + ".part") || fs.exists(job2.artifact.destination + ".resume")) ? partialFiles(job2) : [];
stopCapture(job2);
if (paths.length) await removing(job2, () => removeAll(paths));
delete job2.directURL;
@@ -1722,6 +2185,11 @@ import { VIKING_PAGE_PREFIX, VIKING_PREFIX, describeVikingCapture, linkPageMetad
const job2 = find(id);
if (!["paused", "failed", "awaiting-browser", ...COMPLETE].includes(job2.state) || active2?.job === job2) return;
stopCapture(job2);
// A removed row stops serving its package: the console's download of it stops too.
if (job2.proxy && !job2.proxy.done) {
unwatch(job2.id);
void unserve(job2.proxy);
}
const previous = jobs;
jobs = jobs.filter((item) => item !== job2);
try {
+41
View File
@@ -0,0 +1,41 @@
import { useEffect, useState } from 'react';
import { loadList, saveList } from './saved.js';
// The drive downloads are saved to, kept between sessions (issue #103): with three drives plugged
// in the player had to pick the right one on every game. It lives in settings.json beside the
// speed limit. Null is no choice: DownloadButton's own pick, the first drive with room for the
// release. Settings and the game page's "Save to" row both write it, so the drive picked last is
// the one proposed next time.
var SETTINGS = "settings.json";
var ROOT = /^\/(?:data|mnt\/(?:usb|ext)\d+)$/;
var saved = loadList(SETTINGS, "settings").downloadRoot;
var current = typeof saved === "string" && ROOT.test(saved) ? saved : null;
var downloadRoot = () => current;
var listeners = /* @__PURE__ */ new Set();
function setDownloadRoot(root) {
const next = typeof root === "string" && ROOT.test(root) ? root : null;
if (next === current) return;
current = next;
const { downloadRoot: _old, ...settings } = loadList(SETTINGS, "settings");
saveList(SETTINGS, "settings", next ? { ...settings, downloadRoot: next } : settings);
for (const listener of listeners) listener(next);
}
function useDownloadRoot() {
const [root, setRoot] = (0, useState)(current);
(0, useEffect)(() => {
listeners.add(setRoot);
setRoot(current);
return () => {
listeners.delete(setRoot);
};
}, []);
return root;
}
// The drive to preselect: the remembered one when it is mounted and has room for this release,
// else the first drive that has (a disconnected or full drive must not preselect itself).
function storageIndex(roots, remembered, fits) {
const index = remembered ? roots.indexOf(remembered) : -1;
return index >= 0 && fits(roots[index]) ? index : Math.max(0, roots.findIndex(fits));
}
export { downloadRoot, setDownloadRoot, storageIndex, useDownloadRoot };
+36
View File
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import {register} from 'node:module';
import test from 'node:test';
register(new URL('./download-queue-test-loader.mjs', import.meta.url), import.meta.url);
const {storageIndex} = await import('./download-root.js');
// Issue #103: the drive picked last time is proposed again, but only when it is still there and has
// room for the release; otherwise the app falls back to its own pick (the first drive that fits).
const ROOTS = ['/data', '/mnt/usb0', '/mnt/ext1'];
const fitsAll = () => true;
const fitsOnly = (...roots) => root => roots.includes(root);
test('the remembered drive is preselected', () => {
assert.equal(storageIndex(ROOTS, '/mnt/ext1', fitsAll), 2);
assert.equal(storageIndex(ROOTS, '/mnt/usb0', fitsAll), 1);
});
test('nothing remembered keeps the app`s own pick', () => {
assert.equal(storageIndex(ROOTS, null, fitsAll), 0);
assert.equal(storageIndex(ROOTS, null, fitsOnly('/mnt/usb0')), 1);
});
test('a remembered drive that is gone falls back', () => {
assert.equal(storageIndex(ROOTS, '/mnt/usb9', fitsAll), 0);
assert.equal(storageIndex(ROOTS, '/mnt/usb9', fitsOnly('/mnt/ext1')), 2);
});
test('a remembered drive without room falls back', () => {
assert.equal(storageIndex(ROOTS, '/mnt/ext1', fitsOnly('/data', '/mnt/usb0')), 0);
assert.equal(storageIndex(ROOTS, '/mnt/ext1', fitsOnly('/mnt/usb0')), 1);
});
test('no drive fits, so the first one is proposed', () => {
assert.equal(storageIndex(ROOTS, '/mnt/ext1', () => false), 0);
});
+108
View File
@@ -0,0 +1,108 @@
// The services a pasted link or magnet can be handed to (issues #20 and #89): TorBox's, Real-Debrid's
// and Premiumize's debrid APIs, and a qBittorrent WebUI on the home network. Each needs the user's
// own credentials, entered once on the console and kept in settings.json beside the speed limit.
// Nothing here has a default: an empty field means the service is not set up, and the Downloads page
// offers the setup instead. settings.json sits in the app's data folder, outside the app image, so a
// key or a phone login stays through closing, restarting and updating Overdrive.
import { useEffect, useState } from 'react';
import { loadList, saveList } from './saved.js';
var SETTINGS = "settings.json";
var FIELDS = {
torbox: "torboxKey", realdebrid: "realdebridToken", premiumize: "premiumizeKey",
address: "qbittorrentAddress", user: "qbittorrentUser", password: "qbittorrentPassword",
// A Real-Debrid phone login (device-login.js): its token lasts hours, these renew it.
realdebridRefresh: "realdebridRefresh", realdebridClient: "realdebridClient", realdebridSecret: "realdebridSecret",
realdebridExpires: "realdebridExpires"
};
// What a typed Real-Debrid token replaces: a private API token never expires and needs none of it.
var REALDEBRID_LOGIN = ["realdebridRefresh", "realdebridClient", "realdebridSecret", "realdebridExpires"];
// "192.168.1.10:8080", "http://192.168.1.10:8080/" and "https://host/qbittorrent" are all an
// address; what is not one (no host, a space) is refused before it is saved.
function serviceAddress(value) {
let text = String(value ?? "").trim();
if (!text) return "";
if (!/^https?:\/\//i.test(text)) text = `http://${text}`;
text = text.replace(/\/+$/, "");
if (!/^https?:\/\/[a-z0-9.-]+(:\d{1,5})?(\/[a-z0-9._~%!$&'()*+,;=:@/-]*)?$/i.test(text))
throw new Error("Enter the WebUI address as host:port, for example 192.168.1.10:8080.");
return text;
}
function read2() {
const settings = loadList(SETTINGS, "settings");
const text = (key) => typeof settings[key] === "string" ? settings[key] : "";
return {
torbox: text(FIELDS.torbox).trim(), realdebrid: text(FIELDS.realdebrid).trim(), premiumize: text(FIELDS.premiumize).trim(),
address: text(FIELDS.address).trim(), user: text(FIELDS.user).trim(), password: text(FIELDS.password),
...Object.fromEntries(REALDEBRID_LOGIN.map((field) => [field, text(FIELDS[field]).trim()]))
};
}
var current = read2();
var listeners = new Set();
function services() {
return current;
}
/** Saves several fields at once, as one write ({ field: value }); null or "" clears one. */
function setServices(values) {
const settings = { ...loadList(SETTINGS, "settings") };
const next = { ...current };
for (const [field, value] of Object.entries(values)) {
if (!(field in FIELDS)) throw new Error("Unknown download service field.");
let text = field === "address" ? serviceAddress(value) : String(value ?? "").trim();
if (field === "password") text = String(value ?? "");
if (text) settings[FIELDS[field]] = text;
else delete settings[FIELDS[field]];
next[field] = text;
}
if (Object.keys(next).every((field) => next[field] === current[field])) return;
saveList(SETTINGS, "settings", settings);
current = next;
for (const listener of listeners) listener(current);
}
/** Saves one service field ("torbox", "address", "user", "password"); null or "" clears it. */
function setService(field, value) {
if (!(field in FIELDS)) throw new Error("Unknown download service field.");
// A token typed by hand is the account's own, which never expires: a phone login's renewal goes.
if (field === "realdebrid") return setServices({ realdebrid: value, ...Object.fromEntries(REALDEBRID_LOGIN.map((name) => [name, ""])) });
setServices({ [field]: value });
}
/** A qBittorrent is set up with an address; a user and password only when its WebUI asks. */
function qbittorrent() {
if (!current.address) return null;
return { address: current.address, username: current.user, password: current.password };
}
function torbox() {
return current.torbox || null;
}
function realdebrid() {
return current.realdebrid || null;
}
function premiumize() {
return current.premiumize || null;
}
function useServices() {
const [state, setState] = useState(current);
useEffect(() => {
const listener = (next) => setState(next);
listeners.add(listener);
setState(current);
return () => {
listeners.delete(listener);
};
}, []);
return state;
}
export { premiumize, qbittorrent, realdebrid, serviceAddress, services, setService, setServices, torbox, useServices };
+26
View File
@@ -0,0 +1,26 @@
import assert from 'node:assert/strict';
import {register} from 'node:module';
import test from 'node:test';
register(new URL('./download-queue-test-loader.mjs', import.meta.url), import.meta.url);
const {serviceAddress} = await import('./download-services.js');
// The setup the Downloads page offers (issues #20, #89): each user's own key and client address.
// An address is saved as a URL the clients can call, and refused before it is if it is not one.
test('an address is saved with a scheme and no trailing slash', () => {
assert.equal(serviceAddress('192.168.1.10:8080'), 'http://192.168.1.10:8080');
assert.equal(serviceAddress('http://192.168.1.10:8080/'), 'http://192.168.1.10:8080');
assert.equal(serviceAddress('https://nas.local/qbittorrent/'), 'https://nas.local/qbittorrent');
assert.equal(serviceAddress(' http://10.0.0.2:8080 '), 'http://10.0.0.2:8080');
});
test('an empty address clears the setup instead of failing', () => {
assert.equal(serviceAddress(''), '');
assert.equal(serviceAddress(null), '');
});
test('what is not an address is refused', () => {
assert.throws(() => serviceAddress('two words'), /host:port/);
assert.throws(() => serviceAddress('ftp://host:21'), /host:port/);
assert.throws(() => serviceAddress('http://'), /host:port/);
});
+3 -1
View File
@@ -125,7 +125,9 @@
const url = release.browserUrl || release.url;
const kind = sourceKind(release);
if (kind === "unavailable") throw new Error("Source unavailable: no download URL.");
if (kind === "direct") return { url: webURL(url).url, rejectHtml: true };
// A pasted link may be behind the password of the service it came from (a qBittorrent WebUI):
// the release then carries the headers to send with it.
if (kind === "direct") return { url: webURL(url).url, rejectHtml: true, headers: release.headers };
if (kind === "manifest") throw new Error("Split manifests must be resolved by the queue.");
// Buzzheavier's pages sit behind a Cloudflare check only a browser passes (403 to the app,
// 2026-10-17): its file link is captured from the console's browser, as Datanodes' is.
+24
View File
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import test from 'node:test';
const {configure} = await import('../i18n/core.js');
const {driveName, driveShortName} = await import('./drive-names.js');
// Issue #103: Settings shows one segment per drive, and with two USB drives plus the extended storage
// five of them share 830 px. The long names lose the number that tells the drives apart there, so
// Settings asks this short form; the game page keeps the long one.
configure(() => ({}), JSON.parse(readFileSync(new URL('../i18n/locales/en.json', import.meta.url), 'utf8')));
test('each drive has a short name that keeps its number', () => {
assert.equal(driveName('/mnt/ext1'), 'Extended storage 2');
assert.equal(driveShortName('/mnt/ext1'), 'Extended 2');
assert.equal(driveName('/mnt/usb0'), 'USB drive 1');
assert.equal(driveShortName('/mnt/usb0'), 'USB 1');
assert.equal(driveName('/data'), 'Internal storage');
assert.equal(driveShortName('/data'), 'Internal');
});
test('a mount that is not a drive keeps its plain name', () => {
assert.equal(driveShortName('/mnt/something'), 'This device');
});
+5 -8
View File
@@ -1,13 +1,14 @@
// ../pub-game-store/native/state/downloads.js
// reconstructed from the esbuild bundle in eboot.bin
import { t } from '../i18n/core.js';
import { getGame } from './catalog.js';
import { driveName, driveShortName } from './drive-names.js';
import { createDownloadQueue } from './download-queue.js';
import { createSourceResolver } from './download-sources.js';
import { createMockDownloadQueue } from './mock-download-queue.js';
import { consoleRoots } from './roots.js';
import { createShadowMountProbe } from './shadowmount.js';
import { createPkgHelper } from './pkg-helper.js';
import { Archives, BrowserCapture, DownloadFormats, Downloads, FileSystem, Http, Linking, Notifications, Packages, Platform, Power } from '@ps5-react/core';
var instance;
@@ -27,6 +28,8 @@ import { Archives, BrowserCapture, DownloadFormats, Downloads, FileSystem, Http,
linking: Linking,
sources: createSourceResolver({ http: Http }),
packages: Packages,
// On the PS5 the console's Downloads take a package from the helper payload, which outlives the app.
helper: Platform.OS === "ps5" && typeof Packages.startHelper === "function" ? createPkgHelper({ packages: Packages, http: Http, fs: FileSystem, dir: "/data/ps5-react/PPSA99058/queue", log: "/data/ps5-react/PPSA99058/pkg-helper.log" }) : null,
browserCapture: BrowserCapture,
releasesOf: (id) => getGame(id)?.releases || []
}));
@@ -38,15 +41,9 @@ import { Archives, BrowserCapture, DownloadFormats, Downloads, FileSystem, Http,
return null;
}
}
function driveName(root2) {
if (root2 === "/data") return t("drives.internal");
const mount = /^\/mnt\/(usb|ext)(\d+)$/.exec(root2);
if (!mount) return t("drives.thisDevice");
return t(mount[1] === "usb" ? "drives.usb" : "drives.extended", { number: String(Number(mount[2]) + 1) });
}
function storageRoots() {
if (Platform.OS !== "ps5") return [FileSystem.dataDir];
return consoleRoots(() => FileSystem.mounts());
}
export { downloadQueue, driveName, freeBytes, shadowMountUp, storageRoots };
export { downloadQueue, driveName, driveShortName, freeBytes, shadowMountUp, storageRoots };
+23
View File
@@ -0,0 +1,23 @@
// A drive's name, for the places that only label it (Settings, the game page, Files).
// Kept apart from downloads.js so it can be tested without the PS5 host: importing the queue
// chain pulls in @ps5-react/core, which Node only has a stub for.
import { t } from '../i18n/core.js';
function driveName(root) {
if (root === "/data") return t("drives.internal");
const mount = /^\/mnt\/(usb|ext)(\d+)$/.exec(root);
if (!mount) return t("drives.thisDevice");
return t(mount[1] === "usb" ? "drives.usb" : "drives.extended", { number: String(Number(mount[2]) + 1) });
}
// The same drive, short enough for the Settings segments: five of them share 830 px, and the long
// names ("Archiviazione estesa 1" is 221 px, a segment 154) would lose the number that tells them apart.
function driveShortName(root) {
if (root === "/data") return t("drives.short.internal");
const mount = /^\/mnt\/(usb|ext)(\d+)$/.exec(root);
if (!mount) return t("drives.thisDevice");
return t(mount[1] === "usb" ? "drives.short.usb" : "drives.short.extended", { number: String(Number(mount[2]) + 1) });
}
export { driveName, driveShortName };
+14
View File
@@ -0,0 +1,14 @@
export const FAVORITE_KINDS = [
{ id: "all", label: "homebrew.kind.all" },
{ id: "app", label: "homebrew.kind.app" },
{ id: "game", label: "homebrew.kind.game" },
{ id: "emulators", label: "genre.emulators" },
{ id: "tool", label: "homebrew.kind.tool" }
];
export function matchesFavoriteKind(game, kind = "all") {
if (kind === "all") return true;
if (kind === "game") return !game.homebrew || game.homebrew.kind === "game";
if (!game.homebrew) return false;
if (kind === "emulators") return game.homebrew.category === "emulators";
return game.homebrew.kind === kind;
}
+154
View File
@@ -0,0 +1,154 @@
// A download that starts from a link the user pasted (issue #107), not from the catalog: a direct
// file, a host page the app already resolves, or a file another service prepared (TorBox, a
// qBittorrent on the home network). The link becomes a game and a release the queue accepts as any
// other, so it queues, pauses, resumes, installs and deletes like a catalog download.
//
// A pasted file names no game: the release carries `link`, the queue keeps it out of the game
// checks that would reject a file it does not know (a text file, a video), and the row shows the
// file's name as its title.
import { webURL } from './download-sources.js';
// The formats download-formats.js routes by name, in its own order (an image, then a package,
// then an archive), so the format this module derives is the one that accepts the file's name.
var IMAGES2 = ["ffpkg", "exfat", "ffpfs", "ffpfsc"];
var ARCHIVES2 = ["zip", "rar", "7z", "tar", "tar.gz", "tgz", "tar.bz2", "tar.xz", "tar.zst"];
var NAMED2 = [...IMAGES2, "pkg", "fpkg", ...ARCHIVES2];
var MAX_NAME = 200;
var FALLBACK = "download";
function decode2(value) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
// A file name taken from a link: no separators, no control characters, and never so long that the
// destination path does not fit. The extension stays, since it is what the format is read from.
function fileName(value) {
let name = String(value ?? "").replace(/[\\/\x00-\x1f]/g, "_").trim();
if (!name || name === "." || name === "..") return "";
if (name.length > MAX_NAME) {
const extension = /(\.[a-z0-9]{1,8})$/i.exec(name)?.[1] ?? "";
name = name.slice(0, MAX_NAME - extension.length) + extension;
}
return name;
}
// The format the file name declares: "pkg" for an .fpkg (download-formats.js treats them alike),
// otherwise the extension, and "binary" for a name it does not know.
function formatOf(name) {
const lower = String(name ?? "").toLowerCase();
const found = NAMED2.find((extension) => lower.endsWith(`.${extension}`));
if (!found) return "binary";
return found === "fpkg" ? "pkg" : found;
}
function query2(value) {
const text = String(value);
const start = text.indexOf("?");
if (start < 0) return {};
const params = {};
for (const pair of text.slice(start + 1).split("&")) {
const at = pair.indexOf("=");
if (at <= 0) continue;
const key = decode2(pair.slice(0, at));
if (!(key in params)) params[key] = decode2(pair.slice(at + 1));
}
return params;
}
// The name a URL gives its file: its last path segment when that names a format, else a
// `file=`/`filename=`/`name=` in the query (a gateway's own link), else the segment as it is.
function fileNameOf(value) {
const text = String(value);
const path = text.split(/[?#]/)[0];
const direct = fileName(decode2(path.slice(path.lastIndexOf("/") + 1)));
if (direct && formatOf(direct) !== "binary") return direct;
const params = query2(text);
for (const key of ["file", "filename", "name"]) {
const candidate = fileName(params[key]);
if (candidate && formatOf(candidate) !== "binary") return candidate;
}
return direct;
}
// A BitTorrent infohash as 40 hex digits: a v2 magnet or a base32 v1 one is converted, since
// qBittorrent lists torrents by their hex hash.
var BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
function base32Hex(text) {
if (!/^[a-z2-7]{32}$/i.test(text)) return "";
let bits = "";
for (const character of text.toUpperCase()) {
const value = BASE32.indexOf(character);
if (value < 0) return "";
bits += value.toString(2).padStart(5, "0");
}
let hex = "";
for (let i = 0; i + 4 <= bits.length; i += 4) hex += parseInt(bits.slice(i, i + 4), 2).toString(16);
return hex;
}
// A magnet link's own parts: the link as pasted, its infohash when it names one, and its name.
function magnetInfo(value) {
const text = String(value ?? "").trim();
if (!/^magnet:\?/i.test(text)) return null;
const params = query2(text);
const urn = /^urn:btih:([a-z0-9]{32,40})$/i.exec(params.xt || "")?.[1] ?? "";
const hash = urn.length === 32 ? base32Hex(urn) : urn.toLowerCase();
return { magnet: text, hash, name: fileName(params.dn) };
}
// What the user pasted, checked: a magnet link, or an http(s) URL the queue can fetch.
function linkInput(value) {
const text = String(value ?? "").trim();
if (!text) throw new Error("Enter a link or a magnet.");
const magnet = magnetInfo(text);
if (magnet) return { kind: "magnet", value: text, hash: magnet.hash, name: magnet.name };
webURL(text);
return { kind: "link", value: text, name: fileNameOf(text) };
}
// A short stable id from the link, so pasting the same link twice finds the same job instead of
// downloading the file a second time under another name.
function linkId(value) {
let hash = 2166136261;
const text = String(value);
for (let i = 0; i < text.length; i++) {
hash ^= text.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0).toString(16).padStart(8, "0");
}
var LINK_GAME = "link";
function linkGame(title) {
return { id: LINK_GAME, title: title || FALLBACK, art: {}, releases: [] };
}
// The release the queue downloads: its name, its link, the format its name declares, and the
// headers a service needs for it (a qBittorrent's own download link is behind its password).
function linkRelease({ url, filename, headers, sizeBytes, title } = {}) {
const address = webURL(url).url;
const name = fileName(filename) || fileNameOf(address) || FALLBACK;
return {
id: `link-${linkId(address)}`,
filename: name,
url: address,
format: formatOf(name),
sizeBytes: Number.isSafeInteger(sizeBytes) && sizeBytes > 0 ? sizeBytes : 0,
title: title || name,
link: true,
...headers && Object.keys(headers).length ? { headers } : {}
};
}
/** Queues a pasted link (or a file a service prepared) on the drive the user chose. */
function addLink(queue, { url, filename, headers, sizeBytes, title, root } = {}) {
const release = linkRelease({ url, filename, headers, sizeBytes, title });
return queue.enqueue(linkGame(title || release.title), release, root);
}
export { addLink, base32Hex, fileName, fileNameOf, formatOf, linkGame, linkId, linkInput, linkRelease, magnetInfo };
+100
View File
@@ -0,0 +1,100 @@
import assert from 'node:assert/strict';
import {register} from 'node:module';
import test from 'node:test';
register(new URL('./download-queue-test-loader.mjs', import.meta.url), import.meta.url);
const {addLink, base32Hex, fileName, fileNameOf, formatOf, linkId, linkInput, linkRelease, magnetInfo, linkGame} = await import('./link-download.js');
const {DownloadFormats} = await import('../../ps5-react/runtime/js/download-formats.js');
// Issue #107: a pasted link becomes a release the queue accepts. The format this module derives has
// to be the one download-formats.js routes by, or the queue refuses the file before downloading it.
const NAMES = ['game.pkg', 'game.PKG', 'game.fpkg', 'game.zip', 'game.rar', 'game.7z', 'game.tar',
'game.tar.gz', 'game.exfat', 'game.ffpkg', 'game.ffpfsc', 'movie.mp4', 'notes.txt', 'README', 'a.b.c.bin'];
test('every name the app can meet routes through download-formats', () => {
for (const name of NAMES) {
const release = linkRelease({url: 'https://example.com/f/' + name, filename: name});
const artifact = DownloadFormats.artifact(release, '/data');
assert.equal(artifact.destination, `${artifact.directory}/${name}`, name);
assert.ok(artifact.destination.startsWith('/data/'), name);
}
});
test('a package goes to downloads/packages, an archive to archives, anything else to files', () => {
const where = name => DownloadFormats.artifact(linkRelease({url: 'https://example.com/x/' + name, filename: name}), '/data').directory;
assert.equal(where('game.pkg'), '/data/downloads/packages');
assert.equal(where('game.fpkg'), '/data/downloads/packages');
assert.equal(where('game.zip'), '/data/downloads/archives');
assert.equal(where('movie.mp4'), '/data/downloads/files');
assert.equal(where('game.exfat'), '/data/homebrew');
assert.equal(formatOf('movie.mp4'), 'binary');
});
test('a file name from a link loses what a file system will not take', () => {
assert.equal(fileName('a/b'), 'a_b');
assert.equal(fileName('a\\b'), 'a_b');
assert.equal(fileName('game\u0000.pkg'), 'game_.pkg');
assert.equal(fileName('..'), '');
assert.equal(fileName('.pkg'), '.pkg'.length > 1 ? '.pkg' : '.pkg');
const long = 'x'.repeat(400) + '.pkg';
assert.equal(fileName(long).length, 200);
assert.ok(fileName(long).endsWith('.pkg'));
});
test('the name a URL gives its file, query included', () => {
assert.equal(fileNameOf('https://host/files/Game.PKG'), 'Game.PKG');
assert.equal(fileNameOf('https://host/files/game%20one.zip?token=1'), 'game one.zip');
assert.equal(fileNameOf('https://host/dld/536f032d?token=abc'), '536f032d');
assert.equal(fileNameOf('https://host/download?file=game.rar'), 'game.rar');
assert.equal(fileNameOf('https://host/'), '');
});
test('a magnet names its hash, hex or base32, and its file', () => {
const hex = magnetInfo('magnet:?xt=urn:btih:7ACF8FB590B2060DD9C3146EF770169D593433B0&dn=Debian.ISO');
assert.equal(hex.hash, '7acf8fb590b2060dd9c3146ef770169d593433b0');
assert.equal(hex.name, 'Debian.ISO');
const base = magnetInfo('magnet:?xt=urn:btih:PLHY7NMQWIDA3WODCRXPO4AWTVMTIM5Q');
assert.equal(base.hash, '7acf8fb590b2060dd9c3146ef770169d593433b0');
assert.equal(magnetInfo('https://host/file.pkg'), null);
assert.equal(base32Hex('PLHY7NMQWIDA3WODCRXPO4AWTVMTIM5Q'), '7acf8fb590b2060dd9c3146ef770169d593433b0');
});
test('what the user pasted is checked before anything is queued', () => {
assert.equal(linkInput(' https://host/a/b/game.pkg ').kind, 'link');
assert.equal(linkInput('magnet:?xt=urn:btih:7acf8fb590b2060dd9c3146ef770169d593433b0').kind, 'magnet');
assert.throws(() => linkInput(''), /link or a magnet/);
assert.throws(() => linkInput('ftp://host/file.pkg'), /Invalid HTTP URL/);
assert.throws(() => linkInput('host/file.pkg'), /Invalid HTTP URL/);
});
test('the same link is the same job, another link is another', () => {
assert.equal(linkId('https://host/a.pkg'), linkId('https://host/a.pkg'));
assert.notEqual(linkId('https://host/a.pkg'), linkId('https://host/b.pkg'));
});
test('a release carries the service headers and nothing it does not need', () => {
const plain = linkRelease({url: 'https://host/game.pkg', filename: 'game.pkg'});
assert.equal(plain.format, 'pkg');
assert.equal(plain.link, true);
assert.equal('headers' in plain, false);
const guarded = linkRelease({url: 'http://192.168.1.10:8080/api/v2/torrents/downloadFile?hash=a&file=b.pkg', filename: 'game.pkg', headers: {Authorization: 'Basic x'}, sizeBytes: 12});
assert.deepEqual(guarded.headers, {Authorization: 'Basic x'});
assert.equal(guarded.sizeBytes, 12);
});
test('queueing a link calls the queue it is given, on the drive it is given', () => {
const seen = [];
const queue = {
enqueue(game, release, root) {
seen.push({game, release, root});
return `${game.id}/${release.id}`;
}
};
const id = addLink(queue, {url: 'https://host/x/game.pkg', root: '/mnt/usb0'});
assert.equal(id, 'link/link-' + linkId('https://host/x/game.pkg'));
assert.equal(seen[0].root, '/mnt/usb0');
assert.equal(seen[0].game.title, 'game.pkg');
assert.equal(seen[0].release.filename, 'game.pkg');
assert.equal(seen[0].release.link, true);
assert.deepEqual(linkGame('x').releases, []);
});
+104
View File
@@ -0,0 +1,104 @@
// The package helper: the server that hands a PKG to the PS5's own Downloads (pkg_proxy.cpp), run as
// a payload of its own so the console keeps fetching after Overdrive closes. The app starts it once
// (Packages.startHelper) and talks to it over HTTP on 127.0.0.1 with a secret only this console knows;
// a later session finds the same helper again on the port and secret kept in pkg-helper.json, or
// starts a new one there when the console restarted in between.
function randomSecret() {
let text = "";
while (text.length < 40) text += Math.floor(Math.random() * 2 ** 32).toString(36);
return text.slice(0, 40) + Date.now().toString(36);
}
// "token=…\nurl=…" into { token, url, … }.
function fields(text) {
const out = {};
for (const line of String(text).split("\n")) {
const mark = line.indexOf("=");
if (mark > 0) out[line.slice(0, mark)] = line.slice(mark + 1).trim();
}
return out;
}
function createPkgHelper({ packages, http, fs, dir, log = "" }) {
const file = `${dir}/pkg-helper.json`;
let config = null;
let starting = null;
try {
const saved = JSON.parse(String(fs.readFile(file)));
if (Number.isInteger(saved?.port) && typeof saved.secret === "string" && saved.secret.length >= 16) config = saved;
} catch {
}
const keep = () => {
try {
fs.mkdir(dir, { recursive: true });
fs.writeFile(file, JSON.stringify(config), { sync: true });
} catch (error) {
console.log(`[pkg-helper] not saved: ${error.message}`);
}
};
const base = () => `http://127.0.0.1:${config.port}/control/${config.secret}`;
const call = async (command, body) => {
const response = await http.request(`${base()}/${command}`, {
maxBytes: 256 * 1024,
...body == null ? {} : { method: "POST", headers: { "Content-Type": "text/plain" }, body }
});
const text = await response.text();
if (response.status !== 200) throw new Error(`the helper answered HTTP ${response.status} to ${command}: ${text.trim().slice(0, 120)}`);
return text;
};
const alive = async () => {
if (!config) return false;
try {
return (await call("ping")).startsWith("ok");
} catch {
return false;
}
};
/** Makes sure the helper runs: the one already there, or a new one on the same port. */
async function ensure() {
if (await alive()) return;
starting ?? (starting = (async () => {
const wanted = config ?? { port: 0, secret: randomSecret() };
const { port } = await packages.startHelper({ port: wanted.port, secret: wanted.secret, log });
if (wanted.port && port !== wanted.port) console.log(`[pkg-helper] port ${wanted.port} was taken; the helper listens on ${port}`);
config = { port, secret: wanted.secret };
keep();
console.log(`[pkg-helper] started on port ${port}`);
})().finally(() => {
starting = null;
}));
await starting;
}
/** Serves a link: { url, localUrl, token, port }; a token of before asks for that same link. */
async function add({ url, total, file, token = "" }) {
await ensure();
const served = fields(await call("add", `${token || "-"}\n${total}\n${url}\n${file || "package"}\n`));
if (!served.token) throw new Error("the helper named no link");
return { url: served.url || "", localUrl: served.local || "", token: served.token, port: Number(served.port) || config.port, background: true };
}
/** What the console fetched of each link: [{ token, total, delivered, requests, idleMs }]. */
async function stats() {
if (!config) return [];
const text = await call("stats");
return text.split("\n").filter(Boolean).map((line) => {
const [token, total, delivered, requests, idleMs, gets = "0", asked = "0"] = line.split(" ");
return { token, total: Number(total), delivered: Number(delivered), requests: Number(requests), idleMs: Number(idleMs), gets: Number(gets), asked: Number(asked) };
});
}
async function remove(token) {
if (!config) return;
try {
await call("remove", `${token}\n`);
} catch {
}
}
return { ensure, add, stats, remove, alive };
}
export { createPkgHelper, fields };
+69
View File
@@ -0,0 +1,69 @@
import assert from 'node:assert/strict';
import {register} from 'node:module';
import test from 'node:test';
register(new URL('./download-queue-test-loader.mjs', import.meta.url), import.meta.url);
const {createPkgHelper} = await import('./pkg-helper.js');
// A helper payload behind 127.0.0.1: `up` says whether it answers; it keeps the links it is given.
function world({up = false, savedConfig = null} = {}) {
const files = new Map();
if (savedConfig) files.set('/q/pkg-helper.json', JSON.stringify(savedConfig));
const fs = {readFile: path => files.get(path) ?? '', writeFile: (path, data) => files.set(path, data), mkdir() {}};
const state = {up, starts: [], links: new Map(), calls: []};
const packages = {
async startHelper(request) {
state.starts.push(request);
state.up = true;
return {port: request.port || 42000};
},
};
const http = {
async request(url, options = {}) {
state.calls.push({url, ...options});
if (!state.up) throw new Error('Http.request: Could not connect to server');
const command = url.split('/').pop();
const answer = text => ({status: 200, text: async () => text});
if (command === 'ping') return answer('ok 42000\n');
if (command === 'stats') return answer([...state.links].map(([token, link]) => `${token} ${link.total} 1000 3 50 2 2048`).join('\n') + '\n');
if (command === 'add') {
const [token, total, link, file] = options.body.split('\n');
const named = token === '-' ? 'a'.repeat(64) : token;
state.links.set(named, {total: Number(total), url: link});
return answer(`token=${named}\nurl=http://192.168.1.20:42000/${named}/${file}\nlocal=http://127.0.0.1:42000/${named}/${file}\nport=42000\n`);
}
return answer('ok\n');
},
};
return {fs, files, state, helper: createPkgHelper({packages, http, fs, dir: '/q', log: '/data/pkg-helper.log'})};
}
test('the first link starts the helper and keeps its port and secret for the next session', async () => {
const {helper, state, files} = world();
const served = await helper.add({url: 'https://cdn.example/a.pkg', total: 5e9, file: 'a.pkg'});
assert.equal(state.starts.length, 1);
assert.equal(state.starts[0].port, 0);
assert.ok(state.starts[0].secret.length >= 16);
assert.equal(state.starts[0].log, '/data/pkg-helper.log');
assert.deepEqual(served, {url: `http://192.168.1.20:42000/${'a'.repeat(64)}/a.pkg`, localUrl: `http://127.0.0.1:42000/${'a'.repeat(64)}/a.pkg`, token: 'a'.repeat(64), port: 42000, background: true});
assert.deepEqual(JSON.parse(files.get('/q/pkg-helper.json')), {port: 42000, secret: state.starts[0].secret});
const add = state.calls.find(call => call.url.endsWith('/add'));
assert.equal(add.method, 'POST');
assert.ok(add.url.startsWith(`http://127.0.0.1:42000/control/${state.starts[0].secret}/`));
});
test('a helper still running from before is used as it is, and one that is gone starts on the same port', async () => {
const running = world({up: true, savedConfig: {port: 42000, secret: 's'.repeat(20)}});
await running.helper.add({url: 'https://cdn.example/b.pkg', total: 1, file: 'b.pkg', token: 'b'.repeat(64)});
assert.equal(running.state.starts.length, 0);
assert.ok(running.state.links.has('b'.repeat(64)));
const gone = world({up: false, savedConfig: {port: 42000, secret: 's'.repeat(20)}});
await gone.helper.ensure();
assert.deepEqual(gone.state.starts, [{port: 42000, secret: 's'.repeat(20), log: '/data/pkg-helper.log'}]);
});
test('the progress of each link is read back from the helper', async () => {
const {helper} = world({up: true, savedConfig: {port: 42000, secret: 's'.repeat(20)}});
await helper.add({url: 'https://cdn.example/c.pkg', total: 9000, file: 'c.pkg', token: 'c'.repeat(64)});
assert.deepEqual(await helper.stats(), [{token: 'c'.repeat(64), total: 9000, delivered: 1000, requests: 3, idleMs: 50, gets: 2, asked: 2048}]);
});
+38
View File
@@ -0,0 +1,38 @@
import { useEffect, useState } from 'react';
import { loadList, saveList } from './saved.js';
// Where a PKG downloads: in the background through the PS5's own Downloads, where it keeps going
// once Overdrive is closed and the console is free for anything else, or in Overdrive as before,
// with its pause, resume and install at the end. "ask" puts the question before each download; a
// choice remembered there, or made in Settings, answers it from then on. Kept in settings.json.
var MODES = ["ask", "overdrive", "console"];
var SETTINGS = "settings.json";
var saved = loadList(SETTINGS, "settings").pkgMode;
var mode = MODES.includes(saved) ? saved : "ask";
var listeners = /* @__PURE__ */ new Set();
function pkgMode() {
return mode;
}
function setPkgMode(next) {
if (!MODES.includes(next) || next === mode) return;
mode = next;
const { pkgMode: _old, ...settings } = loadList(SETTINGS, "settings");
saveList(SETTINGS, "settings", next === "ask" ? settings : { ...settings, pkgMode: next });
for (const listener of listeners) listener(next);
}
function usePkgMode() {
const [value, setValue] = (0, useState)(mode);
(0, useEffect)(() => {
listeners.add(setValue);
setValue(mode);
return () => {
listeners.delete(setValue);
};
}, []);
return value;
}
export { MODES, pkgMode, setPkgMode, usePkgMode };
+156
View File
@@ -0,0 +1,156 @@
// Premiumize (issue #20): the third debrid service, after TorBox and Real-Debrid. premiumize.me
// hands a magnet or a host link to its own servers and answers with direct links; a cached one is
// ready at once, anything else is a transfer that is polled until it finishes.
//
// The API key is the user's own, saved on the console (download-services.js); nothing is sent
// anywhere else. Every answer carries { status, message }; both are logged, so a user's app.log
// says which call answered what.
//
// Written from https://www.premiumize.me/api (2026-10-24), not against a live account: the flows
// follow the documented fields, and any difference shows up in the log lines as the call's own
// status and message.
import { linkInput } from './link-download.js';
var API = "https://www.premiumize.me/api";
// A cached file is ready at once; one Premiumize has to fetch waits this long at most, then the
// user can add it again later (Premiumize keeps fetching it in the meantime).
var WAIT_MS = 30 * 60e3;
var FIRST_MS = 2e3;
var THEN_MS = 5e3;
function message(json, status) {
const text = String(json?.message ?? "").trim().replace(/\.$/, "");
if (status === 401 || status === 403 || /authentication failed|invalid api|api key/i.test(text))
return "Premiumize rejected the API key. Enter it again in the download settings.";
if (/rate ?limit|too many requests/i.test(text))
return "Premiumize is rate limiting the key. Try again in a minute.";
return text ? `Premiumize: ${text}.` : `Premiumize answered HTTP ${status}.`;
}
function baseName(value) {
const text = String(value ?? "");
const cut = text.slice(Math.max(text.lastIndexOf("/"), text.lastIndexOf("\\")) + 1);
return cut || text;
}
// The file to download: the largest one, so a release's own .nfo or sample is passed over. A
// container answer may list bare links instead of { path, size, link }.
function pickContent(content) {
if (!Array.isArray(content)) return null;
let best = null;
for (const entry of content) {
const link = typeof entry === "string" ? entry : entry?.link;
if (typeof link !== "string" || !/^https?:\/\//.test(link)) continue;
const sizeBytes = Number(typeof entry === "string" ? 0 : entry.size) || 0;
const path = typeof entry === "string" ? link : entry.path;
if (!best || sizeBytes > best.sizeBytes) best = { url: link, filename: baseName(path), sizeBytes };
}
return best;
}
function createPremiumize({ http, key, wait = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms)), now = Date.now, timeoutMs = WAIT_MS }) {
if (!key) throw new Error("Premiumize is not set up.");
async function call(path, { method = "GET", form, signal } = {}) {
const response = await http.request(API + path, {
method,
signal,
maxBytes: 1024 * 1024,
headers: {
Authorization: `Bearer ${key}`,
...(form ? { "Content-Type": "application/x-www-form-urlencoded" } : {})
},
...(form ? { body: form } : {})
});
const text = await response.text();
let json = null;
try {
json = text ? JSON.parse(text) : null;
} catch {
}
if (response.status >= 400) {
// The one line that makes a user's log enough: which call, what it answered, and the reason.
console.log(`[premiumize] ${path}: HTTP ${response.status} ${text.slice(0, 300)}`);
throw new Error(message(json, response.status));
}
if (!json || typeof json !== "object") throw new Error(`Premiumize answered HTTP ${response.status}.`);
return json;
}
// The direct links for a source Premiumize already holds. Anything it does not hold yet answers
// status "error" (or no content), which is why the caller has a transfer to fall back on.
async function direct(src, { signal } = {}) {
const data = await call("/transfer/directdl", { method: "POST", form: `src=${encodeURIComponent(src)}`, signal });
const pick = String(data.status) === "success" ? pickContent(data.content) : null;
if (pick) {
console.log(`[premiumize] directdl: ${pick.filename} (${pick.sizeBytes} bytes)`);
return { item: pick };
}
const reason = String(data.message ?? (String(data.status) === "success" ? "no file in the answer" : data.status ?? "no reason"));
console.log(`[premiumize] directdl: not ready (${reason})`);
return { reason };
}
// Adds the source as a transfer for Premiumize to fetch; a container answer already holds links.
async function create(src, { signal } = {}) {
const data = await call("/transfer/create", { method: "POST", form: `src=${encodeURIComponent(src)}`, signal });
if (String(data.status) !== "success") throw new Error(message(data, 200));
if (data.type === "container") {
const pick = pickContent(data.content);
if (pick) return { item: pick };
}
const id = data.id != null ? String(data.id) : "";
if (!id) throw new Error("Premiumize took the link but named no transfer.");
console.log(`[premiumize] transfer ${id} added`);
return { id };
}
// Waits until the transfer is fetched, reporting progress as a fraction and logging each state
// change once: a transfer stuck in "queued" in a user's log says exactly that.
async function fetched(id, { onProgress, signal } = {}) {
const end = now() + timeoutMs;
let pause = FIRST_MS;
let last = "";
for (;;) {
const data = await call("/transfer/list", { signal });
const list = Array.isArray(data.transfers) ? data.transfers : [];
const item = list.find((entry) => entry && String(entry.id) === id);
if (!item) {
console.log(`[premiumize] transfer ${id} is no longer listed; asking for its links`);
return null;
}
const state = String(item.status ?? "");
if (state !== last) {
console.log(`[premiumize] transfer ${id}: ${state || "no status"}${item.message ? ` (${item.message})` : ""}`);
last = state;
}
if (onProgress) onProgress({ progress: Math.max(0, Math.min(1, Number(item.progress) || 0)), state });
if (state === "finished") return item;
if (state === "error") throw new Error(`Premiumize could not fetch it${item.message ? ` (${item.message})` : ""}.`);
if (signal?.aborted) throw new Error("Cancelled.");
if (now() >= end)
throw new Error("Premiumize is still fetching it. Add it again later: Premiumize keeps working on it.");
await wait(pause);
pause = THEN_MS;
}
}
/** Hands the input to Premiumize and waits for the file; resolves to what addLink() needs. */
async function prepare(input, { onProgress, signal } = {}) {
const parsed = linkInput(input);
const src = parsed.value;
const quick = await direct(src, { signal });
if (quick.item) return quick.item;
const created = await create(src, { signal });
if (created.item) return created.item;
await fetched(created.id, { onProgress, signal });
const ready = await direct(src, { signal });
if (ready.item) return ready.item;
throw new Error(`Premiumize gave no download link (${quick.reason}).`);
}
return { create, direct, fetched, prepare };
}
export { baseName, createPremiumize, pickContent };
+126
View File
@@ -0,0 +1,126 @@
import assert from 'node:assert/strict';
import {register} from 'node:module';
import test from 'node:test';
register(new URL('./download-queue-test-loader.mjs', import.meta.url), import.meta.url);
const {createPremiumize, pickContent} = await import('./premiumize.js');
// Issue #20: Premiumize answers a magnet or a host link with direct links when it already holds the
// file, and otherwise takes it as a transfer to fetch. These answers follow the documented API
// (https://www.premiumize.me/api, 2026-10-24); no account was available to replay a live one.
const MAGNET = 'magnet:?xt=urn:btih:7acf8fb590b2060dd9c3146ef770169d593433b0&dn=Debian';
const NFO = {path: 'Debian/readme.nfo', size: 1024, link: 'https://storage.premiumize.me/nfo'};
const ISO = {path: 'Debian/debian.iso', size: 792723456, link: 'https://storage-11.premiumize.me/debian.iso'};
function server(routes) {
const calls = [];
return {
calls,
async request(url, options = {}) {
calls.push({url, options});
const route = routes.find(entry => entry.match.test(url));
if (!route) throw new Error('unexpected request: ' + url);
const answer = route.answer(url, options);
const status = answer.status ?? 200;
const body = typeof answer.body === 'string' ? answer.body : JSON.stringify(answer.body);
return {status, ok: status >= 200 && status < 300, url, headers: {}, text: async () => body, json: async () => JSON.parse(body)};
},
};
}
const noWait = async () => {};
const keyed = (http, extra = {}) => createPremiumize({http, key: 'test-key', wait: noWait, ...extra});
test('a cached magnet is linked at once, with its largest file', async () => {
const http = server([
{match: /\/transfer\/directdl$/, answer: () => ({body: {status: 'success', content: [NFO, ISO]}})},
]);
const prepared = await keyed(http).prepare(MAGNET);
assert.equal(prepared.url, ISO.link);
assert.equal(prepared.filename, 'debian.iso');
assert.equal(prepared.sizeBytes, 792723456);
assert.equal(http.calls.length, 1);
assert.equal(new URLSearchParams(http.calls[0].options.body).get('src'), MAGNET);
assert.equal(http.calls[0].options.headers.Authorization, 'Bearer test-key');
});
test('an uncached magnet becomes a transfer, is polled, then linked', async () => {
let direct = 0;
let polls = 0;
const http = server([
{match: /\/transfer\/directdl$/, answer: () => ({body: ++direct === 1
? {status: 'error', message: 'This file is not cached yet.'}
: {status: 'success', content: [ISO]}})},
{match: /\/transfer\/create$/, answer: () => ({body: {status: 'success', id: 'T1', name: 'Debian'}})},
{match: /\/transfer\/list$/, answer: () => {
const index = Math.min(polls++, 1);
return {body: {status: 'success', transfers: [[
{id: 'T1', name: 'Debian', status: 'running', progress: 0.5, file_id: null, message: null},
], [
{id: 'T1', name: 'Debian', status: 'finished', progress: 1, file_id: 'F1', message: null},
]][index]}};
}},
]);
const progress = [];
const prepared = await keyed(http).prepare(MAGNET, {onProgress: report => progress.push(report.progress)});
assert.equal(prepared.url, ISO.link);
assert.equal(prepared.filename, 'debian.iso');
// The transfer was made once, polled until finished, and the direct link asked for again.
assert.equal(http.calls.filter(call => /transfer\/create$/.test(call.url)).length, 1);
assert.equal(http.calls.filter(call => /transfer\/directdl$/.test(call.url)).length, 2);
assert.deepEqual(progress, [0.5, 1]);
});
test('a host link Premiumize holds is linked without a transfer', async () => {
const http = server([
{match: /\/transfer\/directdl$/, answer: () => ({body: {status: 'success', content: [{path: 'game.zip', size: 2048, link: 'https://storage.premiumize.me/game.zip'}]}})},
]);
const prepared = await keyed(http).prepare('https://host/files/game.zip');
assert.equal(prepared.url, 'https://storage.premiumize.me/game.zip');
assert.equal(prepared.filename, 'game.zip');
});
test('a refused key is named as such', async () => {
const http = server([
{match: /\/transfer\/directdl$/, answer: () => ({status: 401, body: {status: 'error', message: 'Authentication failed.'}})},
]);
await assert.rejects(keyed(http).prepare('https://host/files/game.pkg'), /rejected the API key/);
});
test('a host Premiumize does not support says what it answered', async () => {
const http = server([
{match: /\/transfer\/directdl$/, answer: () => ({body: {status: 'error', message: 'This file is not supported.'}})},
{match: /\/transfer\/create$/, answer: () => ({body: {status: 'error', message: 'This file is not supported.'}})},
]);
await assert.rejects(keyed(http).prepare('https://host/files/game.pkg'), /Premiumize: This file is not supported\./);
});
test('a transfer Premiumize could not fetch fails with its message', async () => {
const http = server([
{match: /\/transfer\/directdl$/, answer: () => ({body: {status: 'error', message: 'not cached'}})},
{match: /\/transfer\/create$/, answer: () => ({body: {status: 'success', id: 'T9', name: 'Broken'}})},
{match: /\/transfer\/list$/, answer: () => ({body: {status: 'success', transfers: [{id: 'T9', status: 'error', progress: 0, message: 'No peers available'}]}})},
]);
await assert.rejects(keyed(http).prepare(MAGNET), /could not fetch it \(No peers available\)/);
});
test('waiting too long says to come back, and keeps what Premiumize has', async () => {
let clock = 0;
const http = server([
{match: /\/transfer\/directdl$/, answer: () => ({body: {status: 'error', message: 'not cached'}})},
{match: /\/transfer\/create$/, answer: () => ({body: {status: 'success', id: 'SLOW', name: 'Debian'}})},
{match: /\/transfer\/list$/, answer: () => ({body: {status: 'success', transfers: [{id: 'SLOW', status: 'running', progress: 0.2}]}})},
]);
const service = createPremiumize({http, key: 'k', timeoutMs: 10, now: () => (clock += 6), wait: noWait});
await assert.rejects(service.prepare(MAGNET), /still fetching it\. Add it again later/);
});
test('the largest file wins, bare links count, and only a real one counts', () => {
assert.equal(pickContent([NFO, ISO])?.url, ISO.link);
assert.equal(pickContent([{path: 'x', size: 5, link: 'https://a/x'}, {path: 'y', size: 9, link: 'https://a/y'}])?.url, 'https://a/y');
assert.equal(pickContent(['https://a/bare.bin'])?.url, 'https://a/bare.bin');
assert.equal(pickContent([]), null);
assert.equal(pickContent(null), null);
assert.equal(pickContent([{path: 'x', size: 1, link: 'not-a-link'}]), null);
});
+157
View File
@@ -0,0 +1,157 @@
// qBittorrent on the home network (issue #89): the user's own client fetches the torrent, and the
// WebUI hands the finished file over HTTP, so the console downloads it like any other link.
//
// GET /api/v2/app/webapiVersion the WebUI's version
// POST /api/v2/torrents/add urls=<magnet> adds the torrent
// GET /api/v2/torrents/info?hashes= progress of the torrent
// GET /api/v2/torrents/files?hash= the torrent's files
// GET /api/v2/torrents/downloadFile?hash=&file= the file itself (WebAPI 2.16.0 and up)
//
// Everything is behind the WebUI's password: HTTP Basic, which the WebUI accepts from 2.15.0, the
// same versions that have downloadFile.
import { magnetInfo } from './link-download.js';
var NEEDED = [2, 16, 0];
var WAIT_MS = 30 * 60e3;
var FIRST_MS = 2e3;
var THEN_MS = 5e3;
var BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
// Base64 of a "user:password", in UTF-8; QuickJS has no btoa to rely on.
function base64(text) {
const bytes = [];
const value = String(text);
for (let i = 0; i < value.length; i++) {
let code = value.charCodeAt(i);
if (code >= 0xd800 && code <= 0xdbff && i + 1 < value.length) {
code = 0x10000 + ((code - 0xd800) << 10) + (value.charCodeAt(++i) - 0xdc00);
}
if (code < 0x80) bytes.push(code);
else if (code < 0x800) bytes.push(0xc0 | code >> 6, 0x80 | code & 63);
else if (code < 0x10000) bytes.push(0xe0 | code >> 12, 0x80 | code >> 6 & 63, 0x80 | code & 63);
else bytes.push(0xf0 | code >> 18, 0x80 | code >> 12 & 63, 0x80 | code >> 6 & 63, 0x80 | code & 63);
}
let out = "";
for (let i = 0; i < bytes.length; i += 3) {
const chunk = (bytes[i] << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0);
out += BASE64[chunk >> 18 & 63] + BASE64[chunk >> 12 & 63];
out += i + 1 < bytes.length ? BASE64[chunk >> 6 & 63] : "=";
out += i + 2 < bytes.length ? BASE64[chunk & 63] : "=";
}
return out;
}
function version(parts) {
return parts.map((part, index) => Number(part) * Math.pow(1000, 2 - index)).reduce((sum, part) => sum + part, 0);
}
// The file to download: the largest one that is complete, so a release's own .nfo, a sample or a
// second episode is passed over.
function pickFile(files) {
if (!Array.isArray(files)) return null;
let best = null;
for (const file of files) {
if (!file || typeof file.name !== "string" || !file.name) continue;
if (Number(file.progress ?? 1) < 1) continue;
const size = Number(file.size) || 0;
if (!best || size > (Number(best.size) || 0)) best = file;
}
return best;
}
function baseName(value) {
const text = String(value ?? "");
return text.slice(text.lastIndexOf("/") + 1) || text;
}
function createQBittorrent({ http, address, username = "", password = "", wait = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms)), now = Date.now, timeoutMs = WAIT_MS }) {
if (!address) throw new Error("qBittorrent is not set up.");
const base = String(address).replace(/\/+$/, "");
const headers = username || password
? { Authorization: `Basic ${base64(`${username}:${password}`)}`, Referer: base }
: { Referer: base };
async function request(path, { method = "GET", form, signal, binary = false } = {}) {
const response = await http.request(base + path, {
method,
signal,
maxBytes: binary ? 1024 : 4 * 1024 * 1024,
headers: { ...headers, ...(form ? { "Content-Type": "application/x-www-form-urlencoded" } : {}) },
...(form ? { body: form } : {})
});
if (response.status === 401 || response.status === 403)
throw new Error("qBittorrent refused the login. Check its user and password in the download settings.");
if (response.status === 404 && path.startsWith("/api/v2/torrents/downloadFile"))
throw new Error("This qBittorrent cannot hand files over. Update it to a version with WebAPI 2.16.0 or newer.");
if (!response.ok && response.status !== 200) throw new Error(`qBittorrent answered HTTP ${response.status}.`);
return response;
}
async function webapi() {
const text = await (await request("/api/v2/app/webapiVersion")).text();
const parts = String(text).trim().split(".").map((part) => Number(/^\d+/.exec(part)?.[0] ?? 0));
if (!parts.length || version(parts) < version(NEEDED))
throw new Error(`qBittorrent's WebUI is version ${String(text).trim() || "unknown"}; handing files over needs ${NEEDED.join(".")} or newer. Update qBittorrent.`);
return String(text).trim();
}
// Adds a magnet (or a link to a .torrent) and returns the hash to watch. A magnet names its own
// hash; the client's newest torrent is the one just added when the link was a .torrent file.
async function add(value, { signal } = {}) {
const text = String(value ?? "").trim();
const magnet = magnetInfo(text);
if (!magnet && !/^https?:\/\//i.test(text)) throw new Error("Paste a magnet link, or a link to a .torrent file.");
const answer = String(await (await request("/api/v2/torrents/add", { method: "POST", form: `urls=${encodeURIComponent(text)}`, signal })).text()).trim();
if (/fail/i.test(answer)) throw new Error("qBittorrent refused the torrent.");
if (magnet && magnet.hash.length === 40) return magnet.hash;
await wait(1e3);
const list = await (await request("/api/v2/torrents/info?sort=added_on&reverse=true&limit=1", { signal })).json();
const newest = Array.isArray(list) ? list[0] : null;
if (!newest?.hash) throw new Error("qBittorrent added it but does not list it.");
return String(newest.hash).toLowerCase();
}
async function status(hash, { signal } = {}) {
const list = await (await request(`/api/v2/torrents/info?hashes=${hash}`, { signal })).json();
const item = Array.isArray(list) ? list.find((entry) => String(entry.hash).toLowerCase() === hash) ?? list[0] : null;
if (!item) throw new Error("qBittorrent does not list the torrent any more.");
return item;
}
// Waits for the torrent to finish, reporting progress as a fraction of its size.
async function downloaded(hash, { onProgress, signal } = {}) {
const end = now() + timeoutMs;
let pause = FIRST_MS;
for (;;) {
const item = await status(hash, { signal });
const state = String(item.state ?? "");
if (/^(error|missingFiles|unknown)$/i.test(state)) throw new Error(`qBittorrent stopped on it (${state}).`);
const progress = Number(item.progress) || 0;
if (progress >= 1) return item;
if (onProgress) onProgress({ progress, state });
if (signal?.aborted) throw new Error("Cancelled.");
if (now() >= end) throw new Error("qBittorrent is still downloading it. Add it again later: the client keeps working on it.");
await wait(pause);
pause = THEN_MS;
}
}
/** Adds the torrent, waits for it, and resolves to the file's own WebUI link. */
async function prepare(input, { onProgress, signal } = {}) {
const version2 = await webapi();
console.log(`[qbittorrent] WebAPI ${version2}; adding the torrent`);
const hash = await add(input, { signal });
await downloaded(hash, { onProgress, signal });
const files = await (await request(`/api/v2/torrents/files?hash=${hash}`, { signal })).json();
const file = pickFile(files);
if (!file) throw new Error("qBittorrent has no finished file to download.");
const url = `${base}/api/v2/torrents/downloadFile?hash=${hash}&file=${encodeURIComponent(file.name)}`;
console.log(`[qbittorrent] ${hash}: ${baseName(file.name)} ready`);
return { url, filename: baseName(file.name), sizeBytes: Number(file.size) || 0, headers };
}
return { add, prepare, status, webapi };
}
export { base64, createQBittorrent, pickFile };
+104
View File
@@ -0,0 +1,104 @@
import assert from 'node:assert/strict';
import {register} from 'node:module';
import test from 'node:test';
register(new URL('./download-queue-test-loader.mjs', import.meta.url), import.meta.url);
const {base64, createQBittorrent, pickFile} = await import('./qbittorrent.js');
// Issue #89: the user's own qBittorrent fetches the torrent and its WebUI hands the finished file
// over HTTP (torrents/downloadFile, WebAPI 2.16.0). Nothing here talks to a real client: the calls
// are the documented ones, replayed, so the console test is the one that proves the real client.
const MAGNET = 'magnet:?xt=urn:btih:7acf8fb590b2060dd9c3146ef770169d593433b0&dn=Debian';
const HASH = '7acf8fb590b2060dd9c3146ef770169d593433b0';
function server(routes) {
const calls = [];
return {
calls,
async request(url, options = {}) {
calls.push({url, options});
const route = routes.find(entry => entry.match.test(url));
if (!route) throw new Error('unexpected request: ' + url);
const answer = route.answer(url, options);
const status = answer.status ?? 200;
const body = typeof answer.body === 'string' ? answer.body : JSON.stringify(answer.body);
return {status, ok: status >= 200 && status < 300, url, headers: {}, text: async () => body, json: async () => JSON.parse(body)};
},
};
}
const noWait = async () => {};
const client = (http, extra = {}) => createQBittorrent({http, address: 'http://192.168.1.10:8080', username: 'admin', password: 'p@ss', wait: noWait, ...extra});
test('a magnet is added, waited for, and its file is linked through the WebUI', async () => {
let polls = 0;
const http = server([
{match: /\/api\/v2\/app\/webapiVersion$/, answer: () => ({body: '2.16.0'})},
{match: /\/api\/v2\/torrents\/add$/, answer: () => ({body: 'Ok.'})},
{match: /\/api\/v2\/torrents\/info\?hashes=/, answer: () => ({body: [{
hash: HASH, name: 'Debian', progress: ++polls === 1 ? 0.4 : 1, state: polls === 1 ? 'downloading' : 'uploading', size: 900
}]})},
{match: /\/api\/v2\/torrents\/files\?hash=/, answer: () => ({body: [
{index: 0, name: 'Debian/readme.nfo', size: 1024, progress: 1},
{index: 1, name: 'Debian/debian.iso', size: 792723456, progress: 1},
{index: 2, name: 'Debian/second.iso', size: 10, progress: 0.5},
]})},
]);
const progress = [];
const prepared = await client(http).prepare(MAGNET, {onProgress: report => progress.push(report.progress)});
assert.deepEqual(progress, [0.4]);
assert.equal(prepared.filename, 'debian.iso');
assert.equal(prepared.sizeBytes, 792723456);
assert.equal(prepared.url, 'http://192.168.1.10:8080/api/v2/torrents/downloadFile?hash=' + HASH + '&file=Debian%2Fdebian.iso');
// The queue sends these with the file itself: the WebUI is behind a password.
assert.equal(prepared.headers.Authorization, 'Basic ' + base64('admin:p@ss'));
assert.equal(new URLSearchParams(http.calls[1].options.body).get('urls'), MAGNET);
assert.equal(http.calls[0].options.headers.Authorization, 'Basic ' + base64('admin:p@ss'));
});
test('a WebUI too old to hand files over says exactly that', async () => {
const http = server([{match: /webapiVersion$/, answer: () => ({body: '2.11.0'})}]);
await assert.rejects(client(http).prepare(MAGNET), /needs 2\.16\.0 or newer/);
});
test('a wrong login is named as a login', async () => {
const http = server([{match: /webapiVersion$/, answer: () => ({status: 403, body: 'Forbidden'})}]);
await assert.rejects(client(http).prepare(MAGNET), /refused the login/);
});
test('a .torrent link is followed by the client\'s newest torrent', async () => {
const http = server([
{match: /webapiVersion$/, answer: () => ({body: '2.16.0'})},
{match: /\/api\/v2\/torrents\/add$/, answer: () => ({body: 'Ok.'})},
{match: /\/api\/v2\/torrents\/info\?sort=added_on/, answer: () => ({body: [{hash: 'abcdef', progress: 1}]})},
{match: /\/api\/v2\/torrents\/info\?hashes=abcdef/, answer: () => ({body: [{hash: 'abcdef', progress: 1, state: 'uploading'}]})},
{match: /\/api\/v2\/torrents\/files\?hash=abcdef/, answer: () => ({body: [{index: 0, name: 'game.pkg', size: 12, progress: 1}]})},
]);
const prepared = await client(http).prepare('https://tracker.example/game.torrent');
assert.equal(prepared.url, 'http://192.168.1.10:8080/api/v2/torrents/downloadFile?hash=abcdef&file=game.pkg');
assert.equal(prepared.filename, 'game.pkg');
});
test('a torrent the client stopped on fails with its state', async () => {
const http = server([
{match: /webapiVersion$/, answer: () => ({body: '2.16.0'})},
{match: /torrents\/add$/, answer: () => ({body: 'Ok.'})},
{match: /torrents\/info\?hashes=/, answer: () => ({body: [{hash: HASH, progress: 0.2, state: 'error'}]})},
]);
await assert.rejects(client(http).prepare(MAGNET), /stopped on it \(error\)/);
});
test('Basic auth encodes what the WebUI expects', () => {
assert.equal(base64('admin:p@ss'), 'YWRtaW46cEBzcw==');
assert.equal(base64('user:pass'), 'dXNlcjpwYXNz');
// A password in UTF-8: the bytes, not the UTF-16 code units.
assert.equal(base64('user:päss'), 'dXNlcjpww6Rzcw==');
assert.equal(base64(''), '');
});
test('only a finished file is handed over, the largest of them', () => {
assert.equal(pickFile([{name: 'a', size: 5, progress: 1}, {name: 'b', size: 9, progress: 0.2}])?.name, 'a');
assert.equal(pickFile([{name: 'a', size: 5, progress: 1}, {name: 'b', size: 9, progress: 1}])?.name, 'b');
assert.equal(pickFile([]), null);
});
+202
View File
@@ -0,0 +1,202 @@
// Real-Debrid (issue #20): the second debrid service beside TorBox. A magnet becomes a torrent
// Real-Debrid fetches on its servers, a host link is unrestricted straight away; either way the
// answer is a direct link the normal queue downloads. Real-Debrid keeps what it fetched, so the same
// torrent added again is instant.
//
// The token is the user's own, saved on the console (download-services.js); nothing is sent
// anywhere else. The REST API answers HTTP errors with { error, error_code }, so both are logged:
// a user's app.log names the call, the code and Real-Debrid's own message.
//
// A token from the phone login (device-login.js) lasts hours: `renew` gets a new one before it runs
// out, and once more if Real-Debrid refuses the old one; a token typed by hand comes with no `renew`.
//
// Written from https://api.real-debrid.com/ (2026-10-24), not against a live account: the flows
// below follow the documented fields, and any difference shows up in the log lines as the call's
// own status and error code.
import { linkInput } from './link-download.js';
var API = "https://api.real-debrid.com/rest/1.0";
// A cached torrent is ready in seconds; one Real-Debrid has to fetch waits this long at most,
// then the user can add it again later (Real-Debrid keeps fetching it in the meantime).
var WAIT_MS = 30 * 60e3;
var FIRST_MS = 2e3;
var THEN_MS = 5e3;
var FAILED = /^(magnet_error|error|virus|dead)$/;
var DONE = "downloaded";
function message(json, status) {
const error = String(json?.error ?? "").trim();
const code = Number(json?.error_code) || 0;
if (status === 401 || code === 8 || /bad_token/i.test(error))
return "Real-Debrid rejected the token. Enter it again in the download settings.";
if (code === 9 || code === 14 || /permission_denied|account_locked/i.test(error))
return "Real-Debrid refused the account: it is locked or not premium.";
if (code === 24 || /unavailable_file|file_unavailable/i.test(error))
return "Real-Debrid has that file unavailable.";
if (code === 25 || /service_unavailable/i.test(error))
return "Real-Debrid is unavailable right now. Try again later.";
if (code === 34 || /too_many_requests/i.test(error) || status === 429)
return "Real-Debrid is rate limiting the token. Try again in a minute.";
return error ? `Real-Debrid: ${error}${code ? ` (code ${code})` : ""}.` : `Real-Debrid answered HTTP ${status}.`;
}
function baseName(value) {
const text = String(value ?? "");
const cut = text.slice(text.lastIndexOf("/") + 1);
return cut || text;
}
// The file to download: the largest one, so a release's own .nfo or sample is passed over.
function pickFile(files) {
if (!Array.isArray(files)) return null;
let best = null;
for (const file of files) {
if (!file || !Number.isSafeInteger(file.id)) continue;
const size = Number(file.bytes) || 0;
if (!best || size > (Number(best.bytes) || 0)) best = file;
}
return best;
}
function createRealDebrid({ http, key, renew = null, expiresAt = 0, wait = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms)), now = Date.now, timeoutMs = WAIT_MS }) {
if (!key) throw new Error("Real-Debrid is not set up.");
let renewed = null;
// One renewal at a time, shared by the calls that wait for it.
const fresh = async (signal) => {
renewed ?? (renewed = renew({ signal }).then((token) => {
key = token.realdebrid;
expiresAt = Number(token.realdebridExpires) || 0;
return token;
}).finally(() => {
renewed = null;
}));
return renewed;
};
async function call(path, { method = "GET", form, signal, again = true } = {}) {
// A minute early, so no call goes out with a token about to lapse.
if (renew && expiresAt && now() > expiresAt - 60e3) await fresh(signal);
const response = await http.request(API + path, {
method,
signal,
maxBytes: 1024 * 1024,
headers: {
Authorization: `Bearer ${key}`,
...(form ? { "Content-Type": "application/x-www-form-urlencoded" } : {})
},
...(form ? { body: form } : {})
});
const text = await response.text();
let json = null;
try {
json = text ? JSON.parse(text) : null;
} catch {
}
if (response.status === 204) return null;
if (response.status === 401 && renew && again) {
console.log(`[realdebrid] ${path}: HTTP 401, renewing the token`);
await fresh(signal);
return call(path, { method, form, signal, again: false });
}
if (response.status >= 400) {
// The one line that makes a user's log enough: which call, what it answered, and the reason.
console.log(`[realdebrid] ${path}: HTTP ${response.status} ${text.slice(0, 300)}`);
throw new Error(message(json, response.status));
}
return json;
}
async function status(id, { signal } = {}) {
return call(`/torrents/info/${encodeURIComponent(id)}`, { signal });
}
// Adds the magnet; Real-Debrid answers the torrent's id, which the rest is asked for by.
async function addMagnet(magnet, { signal } = {}) {
const data = await call("/torrents/addMagnet", { method: "POST", form: `magnet=${encodeURIComponent(magnet)}`, signal });
const id = data && data.id != null ? String(data.id) : "";
if (!id) throw new Error("Real-Debrid took the magnet but named no torrent.");
console.log(`[realdebrid] magnet added: torrent ${id}`);
return id;
}
// Tells Real-Debrid which files the torrent's download should keep: only the one that is wanted,
// so its link list holds one entry instead of the release's samples and subtitles.
async function selectFiles(id, files, { signal } = {}) {
await call(`/torrents/selectFiles/${encodeURIComponent(id)}`, { method: "POST", form: `files=${encodeURIComponent(files)}`, signal });
console.log(`[realdebrid] torrent ${id}: selected files ${files}`);
}
// Waits until Real-Debrid has the torrent, selecting its largest file on the way, and reports
// each state change once: a log that shows a torrent stuck in magnet_conversion says why.
async function fetched(id, { onProgress, signal } = {}) {
const end = now() + timeoutMs;
let pause = FIRST_MS;
let last = "";
let selected = false;
const select = async (files) => {
const file = pickFile(files);
if (!file) throw new Error("Real-Debrid lists no file to download.");
await selectFiles(id, String(file.id), { signal });
selected = true;
};
for (;;) {
const item = await status(id, { signal });
if (!item) throw new Error("Real-Debrid does not list the torrent any more.");
const state = String(item.status ?? "");
const progress = Math.max(0, Math.min(1, Number(item.progress) / 100 || 0));
if (state !== last) {
console.log(`[realdebrid] torrent ${id}: ${state || "no status"} (${Math.round(progress * 100)}%)`);
last = state;
}
if (onProgress) onProgress({ progress, state });
if (FAILED.test(state)) throw new Error(`Real-Debrid could not fetch it (${state}).`);
if (state === "waiting_files_selection") {
if (selected) throw new Error("Real-Debrid did not start the download after its files were selected.");
await select(item.files);
}
if (state === DONE) {
if (Array.isArray(item.links) && item.links.length) return item;
// Finished, but nothing was selected yet: pick the file and wait for its link.
if (selected) throw new Error("Real-Debrid finished the torrent but gave no download link.");
await select(item.files);
}
if (signal?.aborted) throw new Error("Cancelled.");
if (now() >= end)
throw new Error("Real-Debrid is still fetching it. Add it again later: Real-Debrid keeps working on it.");
await wait(pause);
pause = THEN_MS;
}
}
// The direct link for one hoster link, valid for a few hours: Real-Debrid turns its own
// https://real-debrid.com/d/… into one on a download node.
async function unrestrict(link, { signal } = {}) {
const data = await call("/unrestrict/link", { method: "POST", form: `link=${encodeURIComponent(link)}`, signal });
const url = String(data?.download ?? "");
if (!/^https?:\/\//.test(url)) throw new Error("Real-Debrid gave no download link for the file.");
const filename = baseName(data.filename);
console.log(`[realdebrid] unrestrict ${data.host ?? "-"}: ${filename} (${Number(data.filesize) || 0} bytes)`);
return { url, filename, sizeBytes: Number(data.filesize) || 0 };
}
/** Hands the input to Real-Debrid and waits for the file; resolves to what addLink() needs. */
async function prepare(input, { onProgress, signal } = {}) {
const parsed = linkInput(input);
if (parsed.kind !== "magnet") return unrestrict(parsed.value, { signal });
const id = await addMagnet(parsed.value, { signal });
const item = await fetched(id, { onProgress, signal });
const link = item.links[0];
const file = pickFile(item.files);
const prepared = await unrestrict(link, { signal });
// A torrent's own name is the file's when the link leaves it out.
if (file) prepared.filename = prepared.filename || baseName(file.path);
console.log(`[realdebrid] torrent ${id}: ${prepared.filename} ready`);
return prepared;
}
return { addMagnet, status, prepare, unrestrict };
}
export { baseName, createRealDebrid, pickFile };
+127
View File
@@ -0,0 +1,127 @@
import assert from 'node:assert/strict';
import {register} from 'node:module';
import test from 'node:test';
register(new URL('./download-queue-test-loader.mjs', import.meta.url), import.meta.url);
const {createRealDebrid, pickFile} = await import('./realdebrid.js');
// Issue #20: Real-Debrid fetches a magnet on its servers and gives a direct link to the largest
// file, which the queue then downloads; a host link is unrestricted straight away. These answers
// follow the documented API (https://api.real-debrid.com/, 2026-10-24); no account was available to
// replay a live one, so the shapes are the documentation's.
const MAGNET = 'magnet:?xt=urn:btih:7acf8fb590b2060dd9c3146ef770169d593433b0&dn=Debian';
const NFO = {id: 1, path: 'Debian/readme.nfo', bytes: 1024, selected: 0};
const ISO = {id: 2, path: 'Debian/debian.iso', bytes: 792723456, selected: 0};
const SELECTED = {...ISO, selected: 1};
function server(routes) {
const calls = [];
return {
calls,
async request(url, options = {}) {
calls.push({url, options});
const route = routes.find(entry => entry.match.test(url));
if (!route) throw new Error('unexpected request: ' + url);
const answer = route.answer(url, options);
const status = answer.status ?? 200;
const body = typeof answer.body === 'string' ? answer.body : JSON.stringify(answer.body);
return {status, ok: status >= 200 && status < 300, url, headers: {}, text: async () => body, json: async () => JSON.parse(body)};
},
};
}
const noWait = async () => {};
const keyed = (http, extra = {}) => createRealDebrid({http, key: 'test-token', wait: noWait, ...extra});
test('a magnet is added, its largest file selected, and its direct link returned', async () => {
let polls = 0;
const http = server([
{match: /\/torrents\/addMagnet$/, answer: () => ({status: 201, body: {id: 'ABC123', uri: 'https://api.real-debrid.com/rest/1.0/torrents/info/ABC123'}})},
{match: /\/torrents\/info\/ABC123$/, answer: () => ({body: [
{id: 'ABC123', status: 'magnet_conversion', progress: 0, files: [NFO, ISO], links: []},
{id: 'ABC123', status: 'waiting_files_selection', progress: 0, files: [NFO, ISO], links: []},
{id: 'ABC123', status: 'downloading', progress: 42, files: [NFO, SELECTED], links: []},
{id: 'ABC123', status: 'downloaded', progress: 100, files: [NFO, SELECTED], links: ['https://real-debrid.com/d/XYZ']},
][polls++]})},
{match: /\/torrents\/selectFiles\/ABC123$/, answer: () => ({status: 204, body: ''})},
{match: /\/unrestrict\/link$/, answer: () => ({body: {id: 'L1', filename: 'debian.iso', filesize: 792723456, link: 'https://real-debrid.com/d/XYZ', host: 'real-debrid', download: 'https://cache-42.real-debrid.com/d/XYZ/debian.iso'}})},
]);
const progress = [];
const prepared = await keyed(http).prepare(MAGNET, {onProgress: report => progress.push(report.progress)});
assert.deepEqual(progress, [0, 0, 0.42, 1]);
assert.equal(prepared.url, 'https://cache-42.real-debrid.com/d/XYZ/debian.iso');
assert.equal(prepared.filename, 'debian.iso');
assert.equal(prepared.sizeBytes, 792723456);
// The magnet goes as a form field, and only the largest file is selected.
assert.equal(new URLSearchParams(http.calls[0].options.body).get('magnet'), MAGNET);
const select = http.calls.find(call => /selectFiles/.test(call.url));
assert.equal(new URLSearchParams(select.options.body).get('files'), '2');
assert.equal(http.calls[0].options.headers.Authorization, 'Bearer test-token');
});
test('a host link is unrestricted without a torrent', async () => {
const http = server([
{match: /\/unrestrict\/link$/, answer: () => ({body: {id: 'L2', filename: 'game.zip', filesize: 2048, host: 'other', download: 'https://node.real-debrid.com/d/game.zip'}})},
]);
const prepared = await keyed(http).prepare('https://host/files/game.zip');
assert.equal(prepared.url, 'https://node.real-debrid.com/d/game.zip');
assert.equal(prepared.filename, 'game.zip');
assert.equal(new URLSearchParams(http.calls[0].options.body).get('link'), 'https://host/files/game.zip');
});
test('a refused token is named as such', async () => {
const http = server([
{match: /\/torrents\/addMagnet$/, answer: () => ({status: 401, body: {error: 'bad_token', error_code: 8}})},
]);
await assert.rejects(keyed(http).prepare(MAGNET), /rejected the token/);
});
test('a locked or free account says so instead of a bare HTTP code', async () => {
const http = server([
{match: /\/unrestrict\/link$/, answer: () => ({status: 403, body: {error: 'permission_denied', error_code: 9}})},
]);
await assert.rejects(keyed(http).prepare('https://host/files/game.pkg'), /locked or not premium/);
});
test('a torrent Real-Debrid could not fetch fails with its state', async () => {
const http = server([
{match: /\/torrents\/addMagnet$/, answer: () => ({status: 201, body: {id: 'DEAD1'}})},
{match: /\/torrents\/info\/DEAD1$/, answer: () => ({body: {id: 'DEAD1', status: 'dead', progress: 0, files: [], links: []}})},
]);
await assert.rejects(keyed(http).prepare(MAGNET), /could not fetch it \(dead\)/);
});
test('a torrent that finished without files is selected once, then linked', async () => {
let polls = 0;
const http = server([
{match: /\/torrents\/addMagnet$/, answer: () => ({status: 201, body: {id: 'CACHED'}})},
{match: /\/torrents\/info\/CACHED$/, answer: () => ({body: [
{id: 'CACHED', status: 'downloaded', progress: 100, files: [NFO, ISO], links: []},
{id: 'CACHED', status: 'downloaded', progress: 100, files: [NFO, SELECTED], links: ['https://real-debrid.com/d/XYZ']},
][polls++]})},
{match: /\/torrents\/selectFiles\/CACHED$/, answer: () => ({status: 204, body: ''})},
{match: /\/unrestrict\/link$/, answer: () => ({body: {filename: 'debian.iso', filesize: 792723456, download: 'https://node.real-debrid.com/d/debian.iso'}})},
]);
const prepared = await keyed(http).prepare(MAGNET);
assert.equal(prepared.filename, 'debian.iso');
assert.equal(http.calls.filter(call => /selectFiles/.test(call.url)).length, 1);
});
test('waiting too long says to come back, and keeps what Real-Debrid has', async () => {
let clock = 0;
const http = server([
{match: /\/torrents\/addMagnet$/, answer: () => ({status: 201, body: {id: 'SLOW'}})},
{match: /\/torrents\/info\/SLOW$/, answer: () => ({body: {id: 'SLOW', status: 'downloading', progress: 50, files: [ISO], links: []}})},
]);
const service = createRealDebrid({http, key: 'k', timeoutMs: 10, now: () => (clock += 6), wait: noWait});
await assert.rejects(service.prepare(MAGNET), /still fetching it\. Add it again later/);
});
test('the largest file wins, and only a real one counts', () => {
assert.equal(pickFile([NFO, ISO])?.id, 2);
assert.equal(pickFile([{id: 1, path: 'x', bytes: 5}, {id: 2, path: 'y', bytes: 9}])?.id, 2);
assert.equal(pickFile([]), null);
assert.equal(pickFile(null), null);
assert.equal(pickFile([{path: 'no-id', bytes: 10}]), null);
});
+5 -2
View File
@@ -2,7 +2,7 @@
// reconstructed from the esbuild bundle in eboot.bin
import { pausedSource } from './paused-hosts.js';
import { releaseSets } from './release-sets.js';
import { isDlcPack, isPatchOnly, releaseSets } from './release-sets.js';
var KEPT2 = 6;
function createReleaseCache(read) {
@@ -19,7 +19,10 @@ import { releaseSets } from './release-sets.js';
// A paused browser host's sources are left out here, before any screen sees them. A source that
// names its own file's title ID (another region's edition, tools/import-superpsx.py) keeps it:
// the queue downloads by it, and the browser capture looks for it in the file's name.
releases = releaseSets(parsed.filter((release) => !pausedSource(release)).map((release) => ({ titleId, ...release })));
// A DLC pack (the scene's dlc_emu kits) or a backport's files alone go too: nothing on the
// console installs them, so offering them only leads to a download that then says there was
// nothing to install. A DLC in a PS5 package stays, as does anything holding the game itself.
releases = releaseSets(parsed.filter((release) => !pausedSource(release) && !isDlcPack(release) && !isPatchOnly(release)).map((release) => ({ titleId, ...release })));
if (games2.size === KEPT2) games2.delete(games2.keys().next().value);
}
games2.delete(file);
+8 -1
View File
@@ -20,6 +20,13 @@ import { archivePassword } from './archive-password.js';
const first = release.parts?.[0]?.sources[0] ?? release;
return PATCH_FILES.test(`${first.url || ""} ${first.browserUrl || ""} ${release.label || ""}`);
};
// A source that is only DLC, never the game itself: the scene packs these as a dlc_emu kit (an ini and
// a fakelib of replacement modules) or a DLC package, neither of which a console installs on its own.
// A label naming the game as well ("Game (DLCs)", "Game + DLC") is the game, so it is not one.
var isDlc = ({ label }) => /\bDLCs?\b/i.test(label || "") && !/\b(?:game|full)\b|\+\s*(?:all\s+)?DLCs?\b|\bwith\s+(?:all\s+)?DLCs?\b|\bincl/i.test(label);
// The DLC-only sources a console cannot install: the scene's dlc_emu kits (an ini and a fakelib of
// replacement modules) and DLC images. A DLC in a PS5 package installs like any other, so it stays.
var isDlcPack = (release) => isDlc(release) && release.format !== "pkg" && release.format !== "fpkg";
var VOLUME_NAME =/\bpart(\d+)\.(?:rar|7z|zip)\b/i;
function missingVolumes(parts, declaredBytes) {
if (parts.some((part, index) => part.number !== index + 1)) return "gap";
@@ -89,4 +96,4 @@ import { archivePassword } from './archive-password.js';
};
var sameRelease = (saved, release) => saved.id === release.id || Boolean(link(saved)) && link(saved) === link(release) && saved.parts?.length === release.parts?.length;
export { isPatchOnly, releaseSets, sameRelease };
export { isDlc, isDlcPack, isPatchOnly, releaseSets, sameRelease };
+167
View File
@@ -0,0 +1,167 @@
// TorBox (issue #20): a magnet or a link another host refuses is handed to TorBox's API, which
// fetches it on its own servers and then gives a direct link to the file, which the normal queue
// downloads. TorBox keeps what it fetched, so pasting the same magnet again is instant.
//
// The API key is the user's own, saved on the console (download-services.js); nothing is sent
// anywhere else. Every call answers { success, error, detail, data }; only data is returned.
import { linkInput } from './link-download.js';
var API = "https://api.torbox.app";
var TORRENTS = "/v1/api/torrents";
var WEB = "/v1/api/webdl";
// A cached file is ready in a second or two; one TorBox has to fetch waits this long at most,
// then the user can add it again later (TorBox keeps fetching it in the meantime).
var WAIT_MS = 30 * 60e3;
var FIRST_MS = 2e3;
var THEN_MS = 5e3;
var FAILED = /^(error|failed|missingfiles|uploaderror|downloaderror)$/i;
function message(json, status) {
const detail = json && typeof json.detail === "string" ? json.detail.trim() : "";
const error = json && typeof json.error === "string" ? json.error : "";
if (/BAD_TOKEN|AUTH_ERROR|BAD_API_KEY|INVALID_TOKEN/i.test(error + detail))
return "TorBox rejected the API key. Enter it again in the download settings.";
if (/TOO_MANY|RATE_LIMIT/i.test(error + detail)) return "TorBox is rate limiting the key. Try again in a minute.";
return detail || (error ? `TorBox: ${error}.` : `TorBox answered HTTP ${status}.`);
}
// The torrent or web download an answer names, whatever the endpoint calls it.
function createdId(kind, data) {
const item = Array.isArray(data) ? data[0] : data;
if (!item || typeof item !== "object") return null;
const id = kind === "torrent" ? item.torrent_id ?? item.id : item.web_id ?? item.webdownload_id ?? item.id;
return Number.isSafeInteger(id) && id > 0 ? id : null;
}
function listItem(kind, data) {
const item = Array.isArray(data) ? data.find((entry) => entry && (entry.id ?? entry.torrent_id ?? entry.web_id)) ?? data[0] : data;
return item && typeof item === "object" ? item : null;
}
// Whether the item is fetched and ready: TorBox's own flag, its state, or full progress.
function ready(item) {
if (!item) return false;
if (item.download_finished === true || item.download_present === true) return true;
if (typeof item.download_state === "string" && /^(cached|completed|uploading)$/i.test(item.download_state)) return true;
return Number(item.progress) >= 1 && Number(item.size) > 0;
}
function failed(item) {
if (!item) return "";
const state = String(item.download_state ?? "");
if (!FAILED.test(state)) return "";
return `TorBox could not fetch it (${state.toLowerCase()}).`;
}
// The file to download: the largest one, so a release's own .nfo or sample is passed over.
function pickFile(files) {
if (!Array.isArray(files)) return null;
let best = null;
for (const file of files) {
if (!file || !Number.isSafeInteger(file.id)) continue;
const size = Number(file.size) || 0;
if (!best || size > (Number(best.size) || 0)) best = file;
}
return best;
}
function baseName(value) {
const text = String(value ?? "");
const cut = text.slice(text.lastIndexOf("/") + 1);
return cut || text;
}
function createTorbox({ http, key, wait = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms)), now = Date.now, timeoutMs = WAIT_MS }) {
if (!key) throw new Error("TorBox is not set up.");
async function call(path, { method = "GET", form, signal } = {}) {
const response = await http.request(API + path, {
method,
signal,
maxBytes: 1024 * 1024,
headers: {
Authorization: `Bearer ${key}`,
...(form ? { "Content-Type": "application/x-www-form-urlencoded" } : {})
},
...(form ? { body: form } : {})
});
const text = await response.text();
let json = null;
try {
json = JSON.parse(text);
} catch {
}
// Whatever the body says, these two are the key: the API has none of its own to refuse.
if (response.status === 401 || response.status === 403)
throw new Error("TorBox rejected the API key. Enter it again in the download settings.");
if (!json || typeof json !== "object") throw new Error(`TorBox answered HTTP ${response.status}.`);
if (!json.success) throw new Error(message(json, response.status));
return json.data;
}
// Adds the link or magnet: a magnet is a torrent, anything else a web download. Both answer
// with the item's id, which the status and the download link are asked for by.
async function create(input, { signal } = {}) {
const parsed = linkInput(input);
if (parsed.kind === "magnet") {
const data = await call(`${TORRENTS}/createtorrent`, { method: "POST", form: `magnet=${encodeURIComponent(parsed.value)}`, signal });
const id = createdId("torrent", data);
if (!id) throw new Error("TorBox took the magnet but named no torrent.");
return { kind: "torrent", id };
}
const data = await call(`${WEB}/createwebdownload`, { method: "POST", form: `link=${encodeURIComponent(parsed.value)}`, signal });
const id = createdId("web", data);
if (!id) throw new Error("TorBox took the link but named no download.");
return { kind: "web", id };
}
async function status({ kind, id }, { signal } = {}) {
const data = await call(`${kind === "torrent" ? TORRENTS : WEB}/mylist?id=${id}`, { signal });
return listItem(kind, data);
}
// Waits until TorBox has the file, reporting progress as a fraction of the item's size.
async function fetched(created, { onProgress, signal } = {}) {
const end = now() + timeoutMs;
let pause = FIRST_MS;
for (;;) {
const item = await status(created, { signal });
if (!item) throw new Error("TorBox does not list the download any more.");
const failure = failed(item);
if (failure) throw new Error(failure);
if (ready(item) && pickFile(item.files)) return item;
if (onProgress) onProgress({ progress: Number(item.progress) || 0, state: String(item.download_state ?? "") });
if (signal?.aborted) throw new Error("Cancelled.");
if (now() >= end)
throw new Error("TorBox is still fetching it. Add it again later: TorBox keeps working on it.");
await wait(pause);
pause = THEN_MS;
}
}
// The direct link to one file, valid for a few hours and carrying the key.
async function link({ kind, id }, file, { signal } = {}) {
const query3 = kind === "torrent" ? `torrent_id=${id}` : `web_id=${id}`;
const data = await call(`${kind === "torrent" ? TORRENTS : WEB}/requestdl?token=${encodeURIComponent(key)}&${query3}&file_id=${file.id}`, { signal });
if (typeof data !== "string" || !/^https?:\/\//.test(data)) throw new Error("TorBox gave no download link for the file.");
return data;
}
/** Hands the input to TorBox and waits for the file; resolves to what addLink() needs. */
async function prepare(input, { onProgress, signal } = {}) {
const created = await create(input, { signal });
console.log(`[torbox] added ${created.kind} ${created.id}; waiting for the file`);
const item = await fetched(created, { onProgress, signal });
const file = pickFile(item.files);
const url = await link(created, file, { signal });
const filename = baseName(file.short_name || file.name);
console.log(`[torbox] ${created.kind} ${created.id}: ${filename} ready`);
return { url, filename, sizeBytes: Number(file.size) || 0 };
}
return { create, status, prepare };
}
export { baseName, createTorbox, createdId, pickFile, ready };
+108
View File
@@ -0,0 +1,108 @@
import assert from 'node:assert/strict';
import {register} from 'node:module';
import test from 'node:test';
register(new URL('./download-queue-test-loader.mjs', import.meta.url), import.meta.url);
const {createTorbox, pickFile, ready} = await import('./torbox.js');
// Issue #20: TorBox fetches a magnet or a link and gives a direct link to the file, which the queue
// then downloads. These are the API's real answers (2026-10-24) replayed; the calls themselves were
// checked against api.torbox.app with a live key, and the key is never part of the app.
const MAGNET = 'magnet:?xt=urn:btih:7acf8fb590b2060dd9c3146ef770169d593433b0&dn=Debian';
const ISO = {id: 2, name: 'Debian/debian.iso', short_name: 'debian.iso', size: 792723456};
const NFO = {id: 1, name: 'Debian/readme.nfo', short_name: 'readme.nfo', size: 1024};
function server(routes) {
const calls = [];
return {
calls,
async request(url, options = {}) {
calls.push({url, options});
const route = routes.find(entry => entry.match.test(url));
if (!route) throw new Error('unexpected request: ' + url);
const answer = route.answer(url, options);
const status = answer.status ?? 200;
const body = typeof answer.body === 'string' ? answer.body : JSON.stringify(answer.body);
return {status, ok: status >= 200 && status < 300, url, headers: {}, text: async () => body, json: async () => JSON.parse(body)};
},
};
}
const noWait = async () => {};
const keyed = (http, extra = {}) => createTorbox({http, key: 'test-key', wait: noWait, ...extra});
const ok = data => ({success: true, error: null, detail: 'ok', data});
test('a magnet is added, waited for, and its largest file is linked', async () => {
let polls = 0;
const http = server([
{match: /\/v1\/api\/torrents\/createtorrent$/, answer: () => ({body: ok({torrent_id: 42, hash: '7acf8fb5', auth_id: 'x'})})},
{match: /\/v1\/api\/torrents\/mylist\?id=42$/, answer: () => ({body: ok(++polls === 1
? {id: 42, download_state: 'downloading', progress: 0.25, size: 792724000, files: [ISO, NFO]}
: {id: 42, download_state: 'cached', download_finished: true, progress: 1, size: 792724000, files: [NFO, ISO]})})},
{match: /\/v1\/api\/torrents\/requestdl\?/, answer: () => ({body: ok('https://nexus-318.ceur.tb-cdn.st/dld/abc?token=test-key')})},
]);
const progress = [];
const prepared = await keyed(http).prepare(MAGNET, {onProgress: report => progress.push(report.progress)});
assert.deepEqual(progress, [0.25]);
assert.equal(prepared.url, 'https://nexus-318.ceur.tb-cdn.st/dld/abc?token=test-key');
assert.equal(prepared.filename, 'debian.iso');
assert.equal(prepared.sizeBytes, 792723456);
// The magnet goes as a form field, and the download link names the largest file's id.
assert.equal(new URLSearchParams(http.calls[0].options.body).get('magnet'), MAGNET);
assert.match(http.calls.at(-1).url, /torrent_id=42&file_id=2$/);
assert.equal(http.calls[0].options.headers.Authorization, 'Bearer test-key');
});
test('a link is a web download, and its id answers the same way', async () => {
const http = server([
{match: /\/v1\/api\/webdl\/createwebdownload$/, answer: () => ({body: ok({web_id: 7, hash: 'abc'})})},
{match: /\/v1\/api\/webdl\/mylist\?id=7$/, answer: () => ({body: ok({id: 7, download_state: 'completed', progress: 1, size: 2048, files: [{id: 3, name: 'game.zip', size: 2048}]})})},
{match: /\/v1\/api\/webdl\/requestdl\?/, answer: () => ({body: ok('https://cdn/web/game.zip')})},
]);
const prepared = await keyed(http).prepare('https://host/files/game.zip');
assert.equal(prepared.url, 'https://cdn/web/game.zip');
assert.equal(prepared.filename, 'game.zip');
assert.equal(new URLSearchParams(http.calls[0].options.body).get('link'), 'https://host/files/game.zip');
assert.match(http.calls.at(-1).url, /web_id=7&file_id=3$/);
});
test('a site TorBox does not support says so', async () => {
const http = server([{match: /createwebdownload$/, answer: () => ({body: {success: false, error: 'UNSUPPORTED_SITE', detail: 'The site you are trying to download from is not supported.', data: null}})},]);
await assert.rejects(keyed(http).prepare('https://example.com/file.pkg'), /site you are trying to download from is not supported/);
});
test('a refused key is named as such, not as a host error', async () => {
const http = server([{match: /createtorrent$/, answer: () => ({status: 401, body: {detail: 'Unauthorized'}})},]);
await assert.rejects(keyed(http).prepare(MAGNET), /rejected the API key/);
});
test('a torrent TorBox could not fetch fails with its state', async () => {
const http = server([
{match: /createtorrent$/, answer: () => ({body: ok({torrent_id: 9})})},
{match: /mylist\?id=9$/, answer: () => ({body: ok({id: 9, download_state: 'error', progress: 0, files: []})})},
]);
await assert.rejects(keyed(http).prepare(MAGNET), /could not fetch it \(error\)/);
});
test('waiting too long says to come back, and keeps what TorBox has', async () => {
let clock = 0;
const http = server([
{match: /createtorrent$/, answer: () => ({body: ok({torrent_id: 9})})},
{match: /mylist\?id=9$/, answer: () => ({body: ok({id: 9, download_state: 'downloading', progress: 0.5, size: 100, files: []})})},
]);
const torbox = createTorbox({http, key: 'k', timeoutMs: 10, now: () => (clock += 6), wait: noWait});
await assert.rejects(torbox.prepare(MAGNET), /still fetching it\. Add it again later/);
});
test('the largest file wins, and only a real one counts as ready', () => {
assert.equal(pickFile([NFO, ISO])?.id, 2);
assert.equal(pickFile([{id: 1, name: 'x', size: 5}, {id: 2, name: 'y', size: 9}])?.id, 2);
assert.equal(pickFile([]), null);
assert.equal(pickFile(null), null);
assert.equal(ready({download_finished: true}), true);
assert.equal(ready({download_state: 'cached'}), true);
assert.equal(ready({progress: 1, size: 10}), true);
assert.equal(ready({progress: 0.9, size: 10}), false);
assert.equal(ready(null), false);
});
+25
View File
@@ -0,0 +1,25 @@
QR Code generator library (TypeScript)
Copyright (c) Project Nayuki. (MIT License)
https://www.nayuki.io/page/qr-code-generator-library
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
- The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
- The Software is provided "as is", without warranty of any kind, express or
implied, including but not limited to the warranties of merchantability,
fitness for a particular purpose and noninfringement. In no event shall the
authors or copyright holders be liable for any claim, damages or other
liability, whether in an action of contract, tort or otherwise, arising from,
out of or in connection with the Software or the use or other dealings in the
Software.
Used by Overdrive for the QR codes of the phone login (app/third_party/qrcodegen.js),
compiled unmodified from commit 720f62bddb7226106071d4728c292cb1df519ceb.
+844
View File
@@ -0,0 +1,844 @@
// nayuki QR Code generator v1.8.0 (MIT), typescript-javascript/qrcodegen.ts at commit
// 720f62bddb7226106071d4728c292cb1df519ceb (sha256 c4749095a91bf9696e3a303998b9905e467094f53041e64393e65e6d887737fd),
// compiled unmodified with tsc 5.6.3 --target es2020; only the export at the end is added.
// https://github.com/nayuki/QR-Code-generator — its copyright and permission notice follow.
/*
* QR Code generator library (TypeScript)
*
* Copyright (c) Project Nayuki. (MIT License)
* https://www.nayuki.io/page/qr-code-generator-library
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
* - The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
* - The Software is provided "as is", without warranty of any kind, express or
* implied, including but not limited to the warranties of merchantability,
* fitness for a particular purpose and noninfringement. In no event shall the
* authors or copyright holders be liable for any claim, damages or other
* liability, whether in an action of contract, tort or otherwise, arising from,
* out of or in connection with the Software or the use or other dealings in the
* Software.
*/
"use strict";
var qrcodegen;
(function (qrcodegen) {
/*---- QR Code symbol class ----*/
/*
* A QR Code symbol, which is a type of two-dimension barcode.
* Invented by Denso Wave and described in the ISO/IEC 18004 standard.
* Instances of this class represent an immutable square grid of dark and light cells.
* The class provides static factory functions to create a QR Code from text or binary data.
* The class covers the QR Code Model 2 specification, supporting all versions (sizes)
* from 1 to 40, all 4 error correction levels, and 4 character encoding modes.
*
* Ways to create a QR Code object:
* - High level: Take the payload data and call QrCode.encodeText() or QrCode.encodeBinary().
* - Mid level: Custom-make the list of segments and call QrCode.encodeSegments().
* - Low level: Custom-make the array of data codeword bytes (including
* segment headers and final padding, excluding error correction codewords),
* supply the appropriate version number, and call the QrCode() constructor.
* (Note that all ways require supplying the desired error correction level.)
*/
class QrCode {
/*-- Static factory functions (high level) --*/
// Returns a QR Code representing the given Unicode text string at the given error correction level.
// As a conservative upper bound, this function is guaranteed to succeed for strings that have 738 or fewer
// Unicode code points (not UTF-16 code units) if the low error correction level is used. The smallest possible
// QR Code version is automatically chosen for the output. The ECC level of the result may be higher than the
// ecl argument if it can be done without increasing the version.
static encodeText(text, ecl) {
const segs = qrcodegen.QrSegment.makeSegments(text);
return QrCode.encodeSegments(segs, ecl);
}
// Returns a QR Code representing the given binary data at the given error correction level.
// This function always encodes using the binary segment mode, not any text mode. The maximum number of
// bytes allowed is 2953. The smallest possible QR Code version is automatically chosen for the output.
// The ECC level of the result may be higher than the ecl argument if it can be done without increasing the version.
static encodeBinary(data, ecl) {
const seg = qrcodegen.QrSegment.makeBytes(data);
return QrCode.encodeSegments([seg], ecl);
}
/*-- Static factory functions (mid level) --*/
// Returns a QR Code representing the given segments with the given encoding parameters.
// The smallest possible QR Code version within the given range is automatically
// chosen for the output. Iff boostEcl is true, then the ECC level of the result
// may be higher than the ecl argument if it can be done without increasing the
// version. The mask number is either between 0 to 7 (inclusive) to force that
// mask, or -1 to automatically choose an appropriate mask (which may be slow).
// This function allows the user to create a custom sequence of segments that switches
// between modes (such as alphanumeric and byte) to encode text in less space.
// This is a mid-level API; the high-level API is encodeText() and encodeBinary().
static encodeSegments(segs, ecl, minVersion = 1, maxVersion = 40, mask = -1, boostEcl = true) {
if (!(QrCode.MIN_VERSION <= minVersion && minVersion <= maxVersion && maxVersion <= QrCode.MAX_VERSION)
|| mask < -1 || mask > 7)
throw new RangeError("Invalid value");
// Find the minimal version number to use
let version;
let dataUsedBits;
for (version = minVersion;; version++) {
const dataCapacityBits = QrCode.getNumDataCodewords(version, ecl) * 8; // Number of data bits available
const usedBits = QrSegment.getTotalBits(segs, version);
if (usedBits <= dataCapacityBits) {
dataUsedBits = usedBits;
break; // This version number is found to be suitable
}
if (version >= maxVersion) // All versions in the range could not fit the given data
throw new RangeError("Data too long");
}
// Increase the error correction level while the data still fits in the current version number
for (const newEcl of [QrCode.Ecc.MEDIUM, QrCode.Ecc.QUARTILE, QrCode.Ecc.HIGH]) { // From low to high
if (boostEcl && dataUsedBits <= QrCode.getNumDataCodewords(version, newEcl) * 8)
ecl = newEcl;
}
// Concatenate all segments to create the data bit string
let bb = [];
for (const seg of segs) {
appendBits(seg.mode.modeBits, 4, bb);
appendBits(seg.numChars, seg.mode.numCharCountBits(version), bb);
for (const b of seg.getData())
bb.push(b);
}
assert(bb.length == dataUsedBits);
// Add terminator and pad up to a byte if applicable
const dataCapacityBits = QrCode.getNumDataCodewords(version, ecl) * 8;
assert(bb.length <= dataCapacityBits);
appendBits(0, Math.min(4, dataCapacityBits - bb.length), bb);
appendBits(0, (8 - bb.length % 8) % 8, bb);
assert(bb.length % 8 == 0);
// Pad with alternating bytes until data capacity is reached
for (let padByte = 0xEC; bb.length < dataCapacityBits; padByte ^= 0xEC ^ 0x11)
appendBits(padByte, 8, bb);
// Pack bits into bytes in big endian
let dataCodewords = [];
while (dataCodewords.length * 8 < bb.length)
dataCodewords.push(0);
bb.forEach((b, i) => dataCodewords[i >>> 3] |= b << (7 - (i & 7)));
// Create the QR Code object
return new QrCode(version, ecl, dataCodewords, mask);
}
/*-- Constructor (low level) and fields --*/
// Creates a new QR Code with the given version number,
// error correction level, data codeword bytes, and mask number.
// This is a low-level API that most users should not use directly.
// A mid-level API is the encodeSegments() function.
constructor(
// The version number of this QR Code, which is between 1 and 40 (inclusive).
// This determines the size of this barcode.
version,
// The error correction level used in this QR Code.
errorCorrectionLevel, dataCodewords, msk) {
this.version = version;
this.errorCorrectionLevel = errorCorrectionLevel;
// The modules of this QR Code (false = light, true = dark).
// Immutable after constructor finishes. Accessed through getModule().
this.modules = [];
// Indicates function modules that are not subjected to masking. Discarded when constructor finishes.
this.isFunction = [];
// Check scalar arguments
if (version < QrCode.MIN_VERSION || version > QrCode.MAX_VERSION)
throw new RangeError("Version value out of range");
if (msk < -1 || msk > 7)
throw new RangeError("Mask value out of range");
this.size = version * 4 + 17;
// Initialize both grids to be size*size arrays of Boolean false
let row = [];
for (let i = 0; i < this.size; i++)
row.push(false);
for (let i = 0; i < this.size; i++) {
this.modules.push(row.slice()); // Initially all light
this.isFunction.push(row.slice());
}
// Compute ECC, draw modules
this.drawFunctionPatterns();
const allCodewords = this.addEccAndInterleave(dataCodewords);
this.drawCodewords(allCodewords);
// Do masking
if (msk == -1) { // Automatically choose best mask
let minPenalty = 1000000000;
for (let i = 0; i < 8; i++) {
this.applyMask(i);
this.drawFormatBits(i);
const penalty = this.getPenaltyScore();
if (penalty < minPenalty) {
msk = i;
minPenalty = penalty;
}
this.applyMask(i); // Undoes the mask due to XOR
}
}
assert(0 <= msk && msk <= 7);
this.mask = msk;
this.applyMask(msk); // Apply the final choice of mask
this.drawFormatBits(msk); // Overwrite old format bits
this.isFunction = [];
}
/*-- Accessor methods --*/
// Returns the color of the module (pixel) at the given coordinates, which is false
// for light or true for dark. The top left corner has the coordinates (x=0, y=0).
// If the given coordinates are out of bounds, then false (light) is returned.
getModule(x, y) {
return 0 <= x && x < this.size && 0 <= y && y < this.size && this.modules[y][x];
}
/*-- Private helper methods for constructor: Drawing function modules --*/
// Reads this object's version field, and draws and marks all function modules.
drawFunctionPatterns() {
// Draw horizontal and vertical timing patterns
for (let i = 0; i < this.size; i++) {
this.setFunctionModule(6, i, i % 2 == 0);
this.setFunctionModule(i, 6, i % 2 == 0);
}
// Draw 3 finder patterns (all corners except bottom right; overwrites some timing modules)
this.drawFinderPattern(3, 3);
this.drawFinderPattern(this.size - 4, 3);
this.drawFinderPattern(3, this.size - 4);
// Draw numerous alignment patterns
const alignPatPos = this.getAlignmentPatternPositions();
const numAlign = alignPatPos.length;
for (let i = 0; i < numAlign; i++) {
for (let j = 0; j < numAlign; j++) {
// Don't draw on the three finder corners
if (!(i == 0 && j == 0 || i == 0 && j == numAlign - 1 || i == numAlign - 1 && j == 0))
this.drawAlignmentPattern(alignPatPos[i], alignPatPos[j]);
}
}
// Draw configuration data
this.drawFormatBits(0); // Dummy mask value; overwritten later in the constructor
this.drawVersion();
}
// Draws two copies of the format bits (with its own error correction code)
// based on the given mask and this object's error correction level field.
drawFormatBits(mask) {
// Calculate error correction code and pack bits
const data = this.errorCorrectionLevel.formatBits << 3 | mask; // errCorrLvl is uint2, mask is uint3
let rem = data;
for (let i = 0; i < 10; i++)
rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
const bits = (data << 10 | rem) ^ 0x5412; // uint15
assert(bits >>> 15 == 0);
// Draw first copy
for (let i = 0; i <= 5; i++)
this.setFunctionModule(8, i, getBit(bits, i));
this.setFunctionModule(8, 7, getBit(bits, 6));
this.setFunctionModule(8, 8, getBit(bits, 7));
this.setFunctionModule(7, 8, getBit(bits, 8));
for (let i = 9; i < 15; i++)
this.setFunctionModule(14 - i, 8, getBit(bits, i));
// Draw second copy
for (let i = 0; i < 8; i++)
this.setFunctionModule(this.size - 1 - i, 8, getBit(bits, i));
for (let i = 8; i < 15; i++)
this.setFunctionModule(8, this.size - 15 + i, getBit(bits, i));
this.setFunctionModule(8, this.size - 8, true); // Always dark
}
// Draws two copies of the version bits (with its own error correction code),
// based on this object's version field, iff 7 <= version <= 40.
drawVersion() {
if (this.version < 7)
return;
// Calculate error correction code and pack bits
let rem = this.version; // version is uint6, in the range [7, 40]
for (let i = 0; i < 12; i++)
rem = (rem << 1) ^ ((rem >>> 11) * 0x1F25);
const bits = this.version << 12 | rem; // uint18
assert(bits >>> 18 == 0);
// Draw two copies
for (let i = 0; i < 18; i++) {
const color = getBit(bits, i);
const a = this.size - 11 + i % 3;
const b = Math.floor(i / 3);
this.setFunctionModule(a, b, color);
this.setFunctionModule(b, a, color);
}
}
// Draws a 9*9 finder pattern including the border separator,
// with the center module at (x, y). Modules can be out of bounds.
drawFinderPattern(x, y) {
for (let dy = -4; dy <= 4; dy++) {
for (let dx = -4; dx <= 4; dx++) {
const dist = Math.max(Math.abs(dx), Math.abs(dy)); // Chebyshev/infinity norm
const xx = x + dx;
const yy = y + dy;
if (0 <= xx && xx < this.size && 0 <= yy && yy < this.size)
this.setFunctionModule(xx, yy, dist != 2 && dist != 4);
}
}
}
// Draws a 5*5 alignment pattern, with the center module
// at (x, y). All modules must be in bounds.
drawAlignmentPattern(x, y) {
for (let dy = -2; dy <= 2; dy++) {
for (let dx = -2; dx <= 2; dx++)
this.setFunctionModule(x + dx, y + dy, Math.max(Math.abs(dx), Math.abs(dy)) != 1);
}
}
// Sets the color of a module and marks it as a function module.
// Only used by the constructor. Coordinates must be in bounds.
setFunctionModule(x, y, isDark) {
this.modules[y][x] = isDark;
this.isFunction[y][x] = true;
}
/*-- Private helper methods for constructor: Codewords and masking --*/
// Returns a new byte string representing the given data with the appropriate error correction
// codewords appended to it, based on this object's version and error correction level.
addEccAndInterleave(data) {
const ver = this.version;
const ecl = this.errorCorrectionLevel;
if (data.length != QrCode.getNumDataCodewords(ver, ecl))
throw new RangeError("Invalid argument");
// Calculate parameter numbers
const numBlocks = QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver];
const blockEccLen = QrCode.ECC_CODEWORDS_PER_BLOCK[ecl.ordinal][ver];
const rawCodewords = Math.floor(QrCode.getNumRawDataModules(ver) / 8);
const numShortBlocks = numBlocks - rawCodewords % numBlocks;
const shortBlockLen = Math.floor(rawCodewords / numBlocks);
// Split data into blocks and append ECC to each block
let blocks = [];
const rsDiv = QrCode.reedSolomonComputeDivisor(blockEccLen);
for (let i = 0, k = 0; i < numBlocks; i++) {
let dat = data.slice(k, k + shortBlockLen - blockEccLen + (i < numShortBlocks ? 0 : 1));
k += dat.length;
const ecc = QrCode.reedSolomonComputeRemainder(dat, rsDiv);
if (i < numShortBlocks)
dat.push(0);
blocks.push(dat.concat(ecc));
}
// Interleave (not concatenate) the bytes from every block into a single sequence
let result = [];
for (let i = 0; i < blocks[0].length; i++) {
blocks.forEach((block, j) => {
// Skip the padding byte in short blocks
if (i != shortBlockLen - blockEccLen || j >= numShortBlocks)
result.push(block[i]);
});
}
assert(result.length == rawCodewords);
return result;
}
// Draws the given sequence of 8-bit codewords (data and error correction) onto the entire
// data area of this QR Code. Function modules need to be marked off before this is called.
drawCodewords(data) {
if (data.length != Math.floor(QrCode.getNumRawDataModules(this.version) / 8))
throw new RangeError("Invalid argument");
let i = 0; // Bit index into the data
// Do the funny zigzag scan
for (let right = this.size - 1; right >= 1; right -= 2) { // Index of right column in each column pair
if (right == 6)
right = 5;
for (let vert = 0; vert < this.size; vert++) { // Vertical counter
for (let j = 0; j < 2; j++) {
const x = right - j; // Actual x coordinate
const upward = ((right + 1) & 2) == 0;
const y = upward ? this.size - 1 - vert : vert; // Actual y coordinate
if (!this.isFunction[y][x] && i < data.length * 8) {
this.modules[y][x] = getBit(data[i >>> 3], 7 - (i & 7));
i++;
}
// If this QR Code has any remainder bits (0 to 7), they were assigned as
// 0/false/light by the constructor and are left unchanged by this method
}
}
}
assert(i == data.length * 8);
}
// XORs the codeword modules in this QR Code with the given mask pattern.
// The function modules must be marked and the codeword bits must be drawn
// before masking. Due to the arithmetic of XOR, calling applyMask() with
// the same mask value a second time will undo the mask. A final well-formed
// QR Code needs exactly one (not zero, two, etc.) mask applied.
applyMask(mask) {
if (mask < 0 || mask > 7)
throw new RangeError("Mask value out of range");
for (let y = 0; y < this.size; y++) {
for (let x = 0; x < this.size; x++) {
let invert;
switch (mask) {
case 0:
invert = (x + y) % 2 == 0;
break;
case 1:
invert = y % 2 == 0;
break;
case 2:
invert = x % 3 == 0;
break;
case 3:
invert = (x + y) % 3 == 0;
break;
case 4:
invert = (Math.floor(x / 3) + Math.floor(y / 2)) % 2 == 0;
break;
case 5:
invert = x * y % 2 + x * y % 3 == 0;
break;
case 6:
invert = (x * y % 2 + x * y % 3) % 2 == 0;
break;
case 7:
invert = ((x + y) % 2 + x * y % 3) % 2 == 0;
break;
default: throw new Error("Unreachable");
}
if (!this.isFunction[y][x] && invert)
this.modules[y][x] = !this.modules[y][x];
}
}
}
// Calculates and returns the penalty score based on state of this QR Code's current modules.
// This is used by the automatic mask choice algorithm to find the mask pattern that yields the lowest score.
getPenaltyScore() {
let result = 0;
// Adjacent modules in row having same color, and finder-like patterns
for (let y = 0; y < this.size; y++) {
let runColor = false;
let runX = 0;
let runHistory = [0, 0, 0, 0, 0, 0, 0];
for (let x = 0; x < this.size; x++) {
if (this.modules[y][x] == runColor) {
runX++;
if (runX == 5)
result += QrCode.PENALTY_N1;
else if (runX > 5)
result++;
}
else {
this.finderPenaltyAddHistory(runX, runHistory);
if (!runColor)
result += this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3;
runColor = this.modules[y][x];
runX = 1;
}
}
result += this.finderPenaltyTerminateAndCount(runColor, runX, runHistory) * QrCode.PENALTY_N3;
}
// Adjacent modules in column having same color, and finder-like patterns
for (let x = 0; x < this.size; x++) {
let runColor = false;
let runY = 0;
let runHistory = [0, 0, 0, 0, 0, 0, 0];
for (let y = 0; y < this.size; y++) {
if (this.modules[y][x] == runColor) {
runY++;
if (runY == 5)
result += QrCode.PENALTY_N1;
else if (runY > 5)
result++;
}
else {
this.finderPenaltyAddHistory(runY, runHistory);
if (!runColor)
result += this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3;
runColor = this.modules[y][x];
runY = 1;
}
}
result += this.finderPenaltyTerminateAndCount(runColor, runY, runHistory) * QrCode.PENALTY_N3;
}
// 2*2 blocks of modules having same color
for (let y = 0; y < this.size - 1; y++) {
for (let x = 0; x < this.size - 1; x++) {
const color = this.modules[y][x];
if (color == this.modules[y][x + 1] &&
color == this.modules[y + 1][x] &&
color == this.modules[y + 1][x + 1])
result += QrCode.PENALTY_N2;
}
}
// Balance of dark and light modules
let dark = 0;
for (const row of this.modules)
dark = row.reduce((sum, color) => sum + (color ? 1 : 0), dark);
const total = this.size * this.size; // Note that size is odd, so dark/total != 1/2
// Compute the smallest integer k >= 0 such that (45-5k)% <= dark/total <= (55+5k)%
const k = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1;
assert(0 <= k && k <= 9);
result += k * QrCode.PENALTY_N4;
assert(0 <= result && result <= 2568888); // Non-tight upper bound based on default values of PENALTY_N1, ..., N4
return result;
}
/*-- Private helper functions --*/
// Returns an ascending list of positions of alignment patterns for this version number.
// Each position is in the range [0,177), and are used on both the x and y axes.
// This could be implemented as lookup table of 40 variable-length lists of integers.
getAlignmentPatternPositions() {
if (this.version == 1)
return [];
else {
const numAlign = Math.floor(this.version / 7) + 2;
const step = (this.version == 32) ? 26 :
Math.ceil((this.version * 4 + 4) / (numAlign * 2 - 2)) * 2;
let result = [6];
for (let pos = this.size - 7; result.length < numAlign; pos -= step)
result.splice(1, 0, pos);
return result;
}
}
// Returns the number of data bits that can be stored in a QR Code of the given version number, after
// all function modules are excluded. This includes remainder bits, so it might not be a multiple of 8.
// The result is in the range [208, 29648]. This could be implemented as a 40-entry lookup table.
static getNumRawDataModules(ver) {
if (ver < QrCode.MIN_VERSION || ver > QrCode.MAX_VERSION)
throw new RangeError("Version number out of range");
let result = (16 * ver + 128) * ver + 64;
if (ver >= 2) {
const numAlign = Math.floor(ver / 7) + 2;
result -= (25 * numAlign - 10) * numAlign - 55;
if (ver >= 7)
result -= 36;
}
assert(208 <= result && result <= 29648);
return result;
}
// Returns the number of 8-bit data (i.e. not error correction) codewords contained in any
// QR Code of the given version number and error correction level, with remainder bits discarded.
// This stateless pure function could be implemented as a (40*4)-cell lookup table.
static getNumDataCodewords(ver, ecl) {
return Math.floor(QrCode.getNumRawDataModules(ver) / 8) -
QrCode.ECC_CODEWORDS_PER_BLOCK[ecl.ordinal][ver] *
QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver];
}
// Returns a Reed-Solomon ECC generator polynomial for the given degree. This could be
// implemented as a lookup table over all possible parameter values, instead of as an algorithm.
static reedSolomonComputeDivisor(degree) {
if (degree < 1 || degree > 255)
throw new RangeError("Degree out of range");
// Polynomial coefficients are stored from highest to lowest power, excluding the leading term which is always 1.
// For example the polynomial x^3 + 255x^2 + 8x + 93 is stored as the uint8 array [255, 8, 93].
let result = [];
for (let i = 0; i < degree - 1; i++)
result.push(0);
result.push(1); // Start off with the monomial x^0
// Compute the product polynomial (x - r^0) * (x - r^1) * (x - r^2) * ... * (x - r^{degree-1}),
// and drop the highest monomial term which is always 1x^degree.
// Note that r = 0x02, which is a generator element of this field GF(2^8/0x11D).
let root = 1;
for (let i = 0; i < degree; i++) {
// Multiply the current product by (x - r^i)
for (let j = 0; j < result.length; j++) {
result[j] = QrCode.reedSolomonMultiply(result[j], root);
if (j + 1 < result.length)
result[j] ^= result[j + 1];
}
root = QrCode.reedSolomonMultiply(root, 0x02);
}
return result;
}
// Returns the Reed-Solomon error correction codeword for the given data and divisor polynomials.
static reedSolomonComputeRemainder(data, divisor) {
let result = divisor.map(_ => 0);
for (const b of data) { // Polynomial division
const factor = b ^ result.shift();
result.push(0);
divisor.forEach((coef, i) => result[i] ^= QrCode.reedSolomonMultiply(coef, factor));
}
return result;
}
// Returns the product of the two given field elements modulo GF(2^8/0x11D). The arguments and result
// are unsigned 8-bit integers. This could be implemented as a lookup table of 256*256 entries of uint8.
static reedSolomonMultiply(x, y) {
if (x >>> 8 != 0 || y >>> 8 != 0)
throw new RangeError("Byte out of range");
// Russian peasant multiplication
let z = 0;
for (let i = 7; i >= 0; i--) {
z = (z << 1) ^ ((z >>> 7) * 0x11D);
z ^= ((y >>> i) & 1) * x;
}
assert(z >>> 8 == 0);
return z;
}
// Can only be called immediately after a light run is added, and
// returns either 0, 1, or 2. A helper function for getPenaltyScore().
finderPenaltyCountPatterns(runHistory) {
const n = runHistory[1];
assert(n <= this.size * 3);
const core = n > 0 && runHistory[2] == n && runHistory[3] == n * 3 && runHistory[4] == n && runHistory[5] == n;
return (core && runHistory[0] >= n * 4 && runHistory[6] >= n ? 1 : 0)
+ (core && runHistory[6] >= n * 4 && runHistory[0] >= n ? 1 : 0);
}
// Must be called at the end of a line (row or column) of modules. A helper function for getPenaltyScore().
finderPenaltyTerminateAndCount(currentRunColor, currentRunLength, runHistory) {
if (currentRunColor) { // Terminate dark run
this.finderPenaltyAddHistory(currentRunLength, runHistory);
currentRunLength = 0;
}
currentRunLength += this.size; // Add light border to final run
this.finderPenaltyAddHistory(currentRunLength, runHistory);
return this.finderPenaltyCountPatterns(runHistory);
}
// Pushes the given value to the front and drops the last value. A helper function for getPenaltyScore().
finderPenaltyAddHistory(currentRunLength, runHistory) {
if (runHistory[0] == 0)
currentRunLength += this.size; // Add light border to initial run
runHistory.pop();
runHistory.unshift(currentRunLength);
}
}
/*-- Constants and tables --*/
// The minimum version number supported in the QR Code Model 2 standard.
QrCode.MIN_VERSION = 1;
// The maximum version number supported in the QR Code Model 2 standard.
QrCode.MAX_VERSION = 40;
// For use in getPenaltyScore(), when evaluating which mask is best.
QrCode.PENALTY_N1 = 3;
QrCode.PENALTY_N2 = 3;
QrCode.PENALTY_N3 = 40;
QrCode.PENALTY_N4 = 10;
QrCode.ECC_CODEWORDS_PER_BLOCK = [
// Version: (note that index 0 is for padding, and is set to an illegal value)
//0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level
[-1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], // Low
[-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28], // Medium
[-1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], // Quartile
[-1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], // High
];
QrCode.NUM_ERROR_CORRECTION_BLOCKS = [
// Version: (note that index 0 is for padding, and is set to an illegal value)
//0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level
[-1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25], // Low
[-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49], // Medium
[-1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68], // Quartile
[-1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81], // High
];
qrcodegen.QrCode = QrCode;
// Appends the given number of low-order bits of the given value
// to the given buffer. Requires 0 <= len <= 31 and 0 <= val < 2^len.
function appendBits(val, len, bb) {
if (len < 0 || len > 31 || val >>> len != 0)
throw new RangeError("Value out of range");
for (let i = len - 1; i >= 0; i--) // Append bit by bit
bb.push((val >>> i) & 1);
}
// Returns true iff the i'th bit of x is set to 1.
function getBit(x, i) {
return ((x >>> i) & 1) != 0;
}
// Throws an exception if the given condition is false.
function assert(cond) {
if (!cond)
throw new Error("Assertion error");
}
/*---- Data segment class ----*/
/*
* A segment of character/binary/control data in a QR Code symbol.
* Instances of this class are immutable.
* The mid-level way to create a segment is to take the payload data
* and call a static factory function such as QrSegment.makeNumeric().
* The low-level way to create a segment is to custom-make the bit buffer
* and call the QrSegment() constructor with appropriate values.
* This segment class imposes no length restrictions, but QR Codes have restrictions.
* Even in the most favorable conditions, a QR Code can only hold 7089 characters of data.
* Any segment longer than this is meaningless for the purpose of generating QR Codes.
*/
class QrSegment {
/*-- Static factory functions (mid level) --*/
// Returns a segment representing the given binary data encoded in
// byte mode. All input byte arrays are acceptable. Any text string
// can be converted to UTF-8 bytes and encoded as a byte mode segment.
static makeBytes(data) {
let bb = [];
for (const b of data)
appendBits(b, 8, bb);
return new QrSegment(QrSegment.Mode.BYTE, data.length, bb);
}
// Returns a segment representing the given string of decimal digits encoded in numeric mode.
static makeNumeric(digits) {
if (!QrSegment.isNumeric(digits))
throw new RangeError("String contains non-numeric characters");
let bb = [];
for (let i = 0; i < digits.length;) { // Consume up to 3 digits per iteration
const n = Math.min(digits.length - i, 3);
appendBits(parseInt(digits.substr(i, n), 10), n * 3 + 1, bb);
i += n;
}
return new QrSegment(QrSegment.Mode.NUMERIC, digits.length, bb);
}
// Returns a segment representing the given text string encoded in alphanumeric mode.
// The characters allowed are: 0 to 9, A to Z (uppercase only), space,
// dollar, percent, asterisk, plus, hyphen, period, slash, colon.
static makeAlphanumeric(text) {
if (!QrSegment.isAlphanumeric(text))
throw new RangeError("String contains unencodable characters in alphanumeric mode");
let bb = [];
let i;
for (i = 0; i + 2 <= text.length; i += 2) { // Process groups of 2
let temp = QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)) * 45;
temp += QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i + 1));
appendBits(temp, 11, bb);
}
if (i < text.length) // 1 character remaining
appendBits(QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)), 6, bb);
return new QrSegment(QrSegment.Mode.ALPHANUMERIC, text.length, bb);
}
// Returns a new mutable list of zero or more segments to represent the given Unicode text string.
// The result may use various segment modes and switch modes to optimize the length of the bit stream.
static makeSegments(text) {
// Select the most efficient segment encoding automatically
if (text == "")
return [];
else if (QrSegment.isNumeric(text))
return [QrSegment.makeNumeric(text)];
else if (QrSegment.isAlphanumeric(text))
return [QrSegment.makeAlphanumeric(text)];
else
return [QrSegment.makeBytes(QrSegment.toUtf8ByteArray(text))];
}
// Returns a segment representing an Extended Channel Interpretation
// (ECI) designator with the given assignment value.
static makeEci(assignVal) {
let bb = [];
if (assignVal < 0)
throw new RangeError("ECI assignment value out of range");
else if (assignVal < (1 << 7))
appendBits(assignVal, 8, bb);
else if (assignVal < (1 << 14)) {
appendBits(0b10, 2, bb);
appendBits(assignVal, 14, bb);
}
else if (assignVal < 1000000) {
appendBits(0b110, 3, bb);
appendBits(assignVal, 21, bb);
}
else
throw new RangeError("ECI assignment value out of range");
return new QrSegment(QrSegment.Mode.ECI, 0, bb);
}
// Tests whether the given string can be encoded as a segment in numeric mode.
// A string is encodable iff each character is in the range 0 to 9.
static isNumeric(text) {
return QrSegment.NUMERIC_REGEX.test(text);
}
// Tests whether the given string can be encoded as a segment in alphanumeric mode.
// A string is encodable iff each character is in the following set: 0 to 9, A to Z
// (uppercase only), space, dollar, percent, asterisk, plus, hyphen, period, slash, colon.
static isAlphanumeric(text) {
return QrSegment.ALPHANUMERIC_REGEX.test(text);
}
/*-- Constructor (low level) and fields --*/
// Creates a new QR Code segment with the given attributes and data.
// The character count (numChars) must agree with the mode and the bit buffer length,
// but the constraint isn't checked. The given bit buffer is cloned and stored.
constructor(
// The mode indicator of this segment.
mode,
// The length of this segment's unencoded data. Measured in characters for
// numeric/alphanumeric/kanji mode, bytes for byte mode, and 0 for ECI mode.
// Always zero or positive. Not the same as the data's bit length.
numChars,
// The data bits of this segment. Accessed through getData().
bitData) {
this.mode = mode;
this.numChars = numChars;
this.bitData = bitData;
if (numChars < 0)
throw new RangeError("Invalid argument");
this.bitData = bitData.slice(); // Make defensive copy
}
/*-- Methods --*/
// Returns a new copy of the data bits of this segment.
getData() {
return this.bitData.slice(); // Make defensive copy
}
// (Package-private) Calculates and returns the number of bits needed to encode the given segments at
// the given version. The result is infinity if a segment has too many characters to fit its length field.
static getTotalBits(segs, version) {
let result = 0;
for (const seg of segs) {
const ccbits = seg.mode.numCharCountBits(version);
if (seg.numChars >= (1 << ccbits))
return Infinity; // The segment's length doesn't fit the field's bit width
result += 4 + ccbits + seg.bitData.length;
}
return result;
}
// Returns a new array of bytes representing the given string encoded in UTF-8.
static toUtf8ByteArray(str) {
str = encodeURI(str);
let result = [];
for (let i = 0; i < str.length; i++) {
if (str.charAt(i) != "%")
result.push(str.charCodeAt(i));
else {
result.push(parseInt(str.substr(i + 1, 2), 16));
i += 2;
}
}
return result;
}
}
/*-- Constants --*/
// Describes precisely all strings that are encodable in numeric mode.
QrSegment.NUMERIC_REGEX = /^[0-9]*$/;
// Describes precisely all strings that are encodable in alphanumeric mode.
QrSegment.ALPHANUMERIC_REGEX = /^[A-Z0-9 $%*+.\/:-]*$/;
// The set of all legal characters in alphanumeric mode,
// where each character value maps to the index in the string.
QrSegment.ALPHANUMERIC_CHARSET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:";
qrcodegen.QrSegment = QrSegment;
})(qrcodegen || (qrcodegen = {}));
/*---- Public helper enumeration ----*/
(function (qrcodegen) {
var QrCode;
(function (QrCode) {
/*
* The error correction level in a QR Code symbol. Immutable.
*/
class Ecc {
/*-- Constructor and fields --*/
constructor(
// In the range 0 to 3 (unsigned 2-bit integer).
ordinal,
// (Package-private) In the range 0 to 3 (unsigned 2-bit integer).
formatBits) {
this.ordinal = ordinal;
this.formatBits = formatBits;
}
}
/*-- Constants --*/
Ecc.LOW = new Ecc(0, 1); // The QR Code can tolerate about 7% erroneous codewords
Ecc.MEDIUM = new Ecc(1, 0); // The QR Code can tolerate about 15% erroneous codewords
Ecc.QUARTILE = new Ecc(2, 3); // The QR Code can tolerate about 25% erroneous codewords
Ecc.HIGH = new Ecc(3, 2); // The QR Code can tolerate about 30% erroneous codewords
QrCode.Ecc = Ecc;
})(QrCode = qrcodegen.QrCode || (qrcodegen.QrCode = {}));
})(qrcodegen || (qrcodegen = {}));
/*---- Public helper enumeration ----*/
(function (qrcodegen) {
var QrSegment;
(function (QrSegment) {
/*
* Describes how a segment's data bits are interpreted. Immutable.
*/
class Mode {
/*-- Constructor and fields --*/
constructor(
// The mode indicator bits, which is a uint4 value (range 0 to 15).
modeBits,
// Number of character count bits for three different version ranges.
numBitsCharCount) {
this.modeBits = modeBits;
this.numBitsCharCount = numBitsCharCount;
}
/*-- Method --*/
// (Package-private) Returns the bit width of the character count field for a segment in
// this mode in a QR Code at the given version number. The result is in the range [0, 16].
numCharCountBits(ver) {
return this.numBitsCharCount[Math.floor((ver + 7) / 17)];
}
}
/*-- Constants --*/
Mode.NUMERIC = new Mode(0x1, [10, 12, 14]);
Mode.ALPHANUMERIC = new Mode(0x2, [9, 11, 13]);
Mode.BYTE = new Mode(0x4, [8, 16, 16]);
Mode.KANJI = new Mode(0x8, [8, 10, 12]);
Mode.ECI = new Mode(0x7, [0, 0, 0]);
QrSegment.Mode = Mode;
})(QrSegment = qrcodegen.QrSegment || (qrcodegen.QrSegment = {}));
})(qrcodegen || (qrcodegen = {}));
export { qrcodegen };
+4 -2
View File
@@ -18,7 +18,9 @@ import { Text, View, useIsFocused } from '@ps5-react/core';
const written = sized.reduce((sum, job2) => sum + Math.min(job2.written, job2.total), 0);
return { count: busy.length, percent: total ? Math.floor(written / total * 100) : null };
}
function DownloadsBadge() {
// `inset` is the item's own horizontal padding, so the bar runs from the start of the icon to the
// end of the count: 24 on a full NavItem, 14 on a compact one.
function DownloadsBadge({ inset = 24 }) {
const [state, setState] = (0, useState)(IDLE);
const focused = useIsFocused();
(0, useEffect)(() => {
@@ -38,7 +40,7 @@ import { Text, View, useIsFocused } from '@ps5-react/core';
if (!state.count) return null;
return /* @__PURE__ */ (0, jsxs)(Fragment, { children: [
/* @__PURE__ */ (0, jsx)(Text, { style: [{ fontFamily: "Inter-Regular", fontSize: 23 }, { color: focused ? colors.fg : "#f2f2f3a6" }], children: String(state.count) }),
state.percent === null ? null : /* @__PURE__ */ (0, jsx)(View, { style: { position: "absolute", left: 24, right: 24, bottom: 6, height: 5, overflow: "hidden", borderRadius: 14999, backgroundColor: "#ffffff33" }, children: /* @__PURE__ */ (0, jsx)(View, { style: [{ height: "100%", backgroundColor: "#f2f2f3" }, { width: `${state.percent}%` }] }) })
state.percent === null ? null : /* @__PURE__ */ (0, jsx)(View, { style: { position: "absolute", left: inset, right: inset, bottom: 6, height: 5, overflow: "hidden", borderRadius: 14999, backgroundColor: "#ffffff33" }, children: /* @__PURE__ */ (0, jsx)(View, { style: [{ height: "100%", backgroundColor: "#f2f2f3" }, { width: `${state.percent}%` }] }) })
] });
}
+74 -75
View File
@@ -4,16 +4,12 @@
import '../assets/Inter-Regular.ttf';
import { jsx, jsxs } from 'react/jsx-runtime';
import { useRef, useState } from 'react';
import { useRef } from 'react';
import { focusEnd, useLocale, useT } from '../i18n/index.js';
import { getGame } from '../state/catalog.js';
import { useIsImmersive, useNavActions, useNavState } from '../state/nav.jsx';
import { useWishlist } from '../state/store.jsx';
import { ButtonGlyph } from './ButtonHints.jsx';
import { DownloadToast } from './DownloadToast.jsx';
import { DownloadsBadge } from './DownloadsBadge.jsx';
import { FocusRing } from './FocusRing.jsx';
import { LanguageSheet } from './LanguageSheet.jsx';
import { Logo } from './Logo.jsx';
import { NavItem } from './NavItem.jsx';
import { NavPill, useNavLayouts } from './NavPill.jsx';
@@ -21,16 +17,14 @@ import { useSections } from './useSections.js';
import { VERSION } from '../version.js';
import { FocusScope, Text, View } from '@ps5-react/core';
var BADGE = /* @__PURE__ */ (0, jsx)(DownloadsBadge, {});
var BADGE = /* @__PURE__ */ (0, jsx)(DownloadsBadge, { inset: 14 });
function Header({ children }) {
const t2 = useT();
const { rtl } = useLocale();
const { section, shown, on, step, keyOf } = useSections();
const wishlist = useWishlist();
const favorites = wishlist.filter((id) => getGame(id)).length;
const { shown, on, step, keyOf } = useSections();
const nav = useNavActions();
const { top } = useNavState();
const [choosingLanguage, setChoosingLanguage] = (0, useState)(false);
const immersive = useIsImmersive();
const [layouts, track] = useNavLayouts();
const down = top.name === "browse" ? "page" : top.name === "homebrew" ? "homebrew-page" : "screen";
@@ -43,9 +37,7 @@ import { FocusScope, Text, View } from '@ps5-react/core';
if (action !== "l1" && action !== "r1") return false;
// Right-to-left the sections run leftward, so L1 is the one that moves on (issue #30).
const forward = action === (rtl ? "l1" : "r1");
if (forward && section === "files") setChoosingLanguage(true);
else
step(forward);
step(forward);
return true;
};
const onBack = () => {
@@ -55,69 +47,72 @@ import { FocusScope, Text, View } from '@ps5-react/core';
return /* @__PURE__ */ (0, jsxs)(FocusScope, { onAction, onBack, children: [
children,
/* @__PURE__ */ (0, jsx)(DownloadToast, { actionRef: toastAction }),
choosingLanguage ? /* @__PURE__ */ (0, jsx)(LanguageSheet, { onClose: () => setChoosingLanguage(false) }) : null,
/* @__PURE__ */ (0, jsx)(View, { style: immersive ? { position: "absolute", left: 0, right: 0, top: 0, zIndex: 40, display: "none" } : { position: "absolute", left: 0, right: 0, top: 0, zIndex: 40 }, children: /* @__PURE__ */ (0, jsx)(FocusScope, { focusKey: "header", inert: immersive, children: /* @__PURE__ */ (0, jsxs)(View, { onLayout: track("header"), style: { height: 96, flexDirection: "row", alignItems: "center", justifyContent: "space-between", backgroundColor: "#000000", paddingHorizontal: 48 }, children: [
/* @__PURE__ */ (0, jsx)(NavPill, { section: shown, layouts }),
/* @__PURE__ */ (0, jsxs)(View, { style: { flex: 1, flexDirection: "row", alignItems: "center", justifyContent: "flex-end", gap: 24 }, children: [
/* @__PURE__ */ (0, jsxs)(View, { style: { flexDirection: "row", alignItems: "center", gap: 0 }, children: [
// As far from Search as R1 is from the settings button (that row's gap of 6).
/* @__PURE__ */ (0, jsx)(View, { style: rtl ? { marginLeft: 6 } : { marginRight: 6 }, children: /* @__PURE__ */ (0, jsx)(ButtonGlyph, { button: rtl ? "r1" : "l1" }) }),
/* @__PURE__ */ (0, jsx)(NavItem, {
focusKey: keyOf("search"),
icon: "search",
label: t2("nav.search"),
current: shown === "search",
onPress: on.search,
onLayout: track("search"),
nextFocusDown: down
}),
/* @__PURE__ */ (0, jsx)(NavItem, {
focusKey: keyOf("discover"),
label: t2("nav.discover"),
current: shown === "discover",
onPress: on.discover,
onLayout: track("discover"),
nextFocusDown: down
})
] })
/* Original left-side text navigation, now with icons alongside the labels. */
/* @__PURE__ */ (0, jsxs)(View, { style: { flexDirection: "row", alignItems: "center", gap: 0 }, children: [
/* Overdrive's mark is the header's label, not a stop: the D-pad starts on Discover, and no
focus ring lands on the word. */
/* @__PURE__ */ (0, jsxs)(View, {
style: { marginLeft: 0, flexDirection: "row", alignItems: "center", borderRadius: 6, paddingLeft: 0, paddingRight: 24 },
children: [
/* @__PURE__ */ (0, jsxs)(View, { style: { position: "relative" }, children: [
/* @__PURE__ */ (0, jsx)(Logo, { enlarged: true }),
/* @__PURE__ */ (0, jsx)(Text, {
style: { position: "absolute", left: 66, top: 39, fontFamily: "Inter-Regular", fontSize: 16, lineHeight: 18, color: "#8a8a94" },
children: "v" + VERSION
})
] })
]
}),
/* @__PURE__ */ (0, jsx)(NavItem, {
focusKey: keyOf("discover"),
icon: "compass",
label: t2("nav.discover"),
current: shown === "discover",
onPress: on.discover,
onLayout: track("discover"),
nextFocusDown: down
}),
/* @__PURE__ */ (0, jsx)(NavItem, {
focusKey: keyOf("browse"),
icon: "controller",
label: t2("nav.browse"),
current: shown === "browse",
onPress: on.browse,
onLayout: track("browse"),
nextFocusDown: down
}),
/* @__PURE__ */ (0, jsx)(NavItem, {
focusKey: keyOf("homebrew"),
icon: "homebrew",
label: t2("nav.homebrew"),
current: shown === "homebrew",
onPress: on.homebrew,
onLayout: track("homebrew"),
nextFocusDown: down
}),
/* @__PURE__ */ (0, jsx)(NavItem, {
focusKey: keyOf("search"),
icon: "search",
label: t2("nav.search"),
current: shown === "search",
onPress: on.search,
onLayout: track("search"),
nextFocusDown: down
}),
] }),
/* @__PURE__ */ (0, jsxs)(View, { focusKey: "nav:home", onPress: on.discover, nextFocusDown: down, style: { marginHorizontal: 22, flexDirection: "row", alignItems: "center", gap: 12, borderRadius: 6, paddingHorizontal: 12 }, children: [
/* @__PURE__ */ (0, jsx)(FocusRing, {}),
/* @__PURE__ */ (0, jsx)(Logo, {}),
/* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 21, color: "#8a8a94" }, children: `v${VERSION}` })
] }),
/* @__PURE__ */ (0, jsxs)(View, { style: { flex: 1, flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: 24 }, children: [
/* @__PURE__ */ (0, jsxs)(View, { style: { flexDirection: "row", alignItems: "center", gap: 0 }, children: [
/* @__PURE__ */ (0, jsx)(
NavItem,
{
focusKey: keyOf("browse"),
label: t2("nav.browse"),
current: shown === "browse",
onPress: on.browse,
onLayout: track("browse"),
nextFocusDown: down
}
),
/* @__PURE__ */ (0, jsx)(
NavItem,
{
focusKey: keyOf("homebrew"),
label: t2("nav.homebrew"),
current: shown === "homebrew",
onPress: on.homebrew,
onLayout: track("homebrew"),
nextFocusDown: down
}
)
] }),
/* @__PURE__ */ (0, jsxs)(View, { style: { flexDirection: "row", alignItems: "center", gap: 6 }, children: [
/* Keep the four fixed-width utility icons on the right; they never grow captions. */
/* @__PURE__ */ (0, jsx)(View, { style: { width: 232, flexDirection: "row", alignItems: "center", justifyContent: "flex-end" }, children:
/* @__PURE__ */ (0, jsxs)(View, { style: { flexDirection: "row", alignItems: "center", gap: 12 }, children: [
/* @__PURE__ */ (0, jsx)(
NavItem,
{
focusKey: keyOf("favorites"),
icon: "heart",
label: favorites ? String(favorites) : null,
iconFill: shown === "favorites",
compact: true,
current: shown === "favorites",
onPress: on.favorites,
onLayout: track("favorites"),
@@ -130,6 +125,7 @@ import { FocusScope, Text, View } from '@ps5-react/core';
{
focusKey: keyOf("downloads"),
icon: "download",
compact: true,
current: shown === "downloads",
onPress: on.downloads,
onLayout: track("downloads"),
@@ -142,6 +138,7 @@ import { FocusScope, Text, View } from '@ps5-react/core';
{
focusKey: keyOf("files"),
icon: "folder",
compact: true,
current: shown === "files",
onPress: on.files,
onLayout: track("files"),
@@ -151,16 +148,18 @@ import { FocusScope, Text, View } from '@ps5-react/core';
/* @__PURE__ */ (0, jsx)(
NavItem,
{
focusKey: "nav:language",
focusKey: keyOf("settings"),
icon: "settings",
onPress: () => setChoosingLanguage(true),
compact: true,
current: shown === "settings",
onPress: on.settings,
onLayout: track("settings"),
nextFocusDown: down,
...focusEnd(rtl, "nav:language")
...focusEnd(rtl, keyOf("settings"))
}
),
/* @__PURE__ */ (0, jsx)(ButtonGlyph, { button: rtl ? "l1" : "r1" })
)
] })
] })
})
] }) }) })
] });
}
+77
View File
@@ -10,6 +10,10 @@ import { px } from './scale.js';
var ring = (cx, cy, r) => `M${cx + r} ${cy}a${r} ${r} 0 1 1-${2 * r} 0a${r} ${r} 0 1 1 ${2 * r} 0`;
var ICONS = {
search: [ring(11, 11, 8), "m21 21-4.3-4.3"],
// The header's tab icons (lucide 0.462, as the rest).
compass: [ring(12, 12, 10), "m16.2 7.8-2.5 5.9-5.9 2.5 2.5-5.9 5.9-2.5Z"],
controller: ["M6.8 8h10.4a4 4 0 0 1 3.8 2.8l1.1 5.3a2.8 2.8 0 0 1-4.5 2.8l-2.5-2.1H8.9l-2.5 2.1a2.8 2.8 0 0 1-4.5-2.8L3 10.8A4 4 0 0 1 6.8 8Z", "M8 11v4", "M6 13h4", "M16 12h.01", "M18 14h.01"],
homebrew: ["M3 10.5 12 3l9 7.5V20a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1Z"],
searchX: [ring(11, 11, 8), "m21 21-4.3-4.3", "m13.5 8.5-5 5", "m8.5 8.5 5 5"],
chevronDown: ["m6 9 6 6 6-6"],
chevronUp: ["m18 15-6-6-6 6"],
@@ -42,6 +46,8 @@ import { px } from './scale.js';
],
space: ["M22 17v1c0 .5-.5 1-1 1H3c-.5 0-1-.5-1-1v-1"],
globe: [ring(12, 12, 10), "M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20", "M2 12h20"],
// A chain of two links: "From a link" carries this rather than a globe.
link: ["M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71", "M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"],
// lucide 0.462 "settings" and "languages", the set the rest of these come from.
settings: [
"M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z",
@@ -55,6 +61,8 @@ import { px } from './scale.js';
"m7.5 4.27 9 5.15"
],
disc: [ring(12, 12, 10), ring(12, 12, 2)],
// lucide 0.462 gauge, for the speed limit's tile.
gauge: ["m12 14 4-4", "M3.34 19a10 10 0 1 1 17.32 0"],
// lucide 0.462 cpu, lightbulb and file-minus, for the download tips.
cpu: ["M6 4h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z", "M10 9h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1z", "M15 2v2", "M15 20v2", "M2 15h2", "M2 9h2", "M20 15h2", "M20 9h2", "M9 2v2", "M9 20v2"],
lightbulb: ["M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5", "M9 18h6", "M10 22h4"],
@@ -75,9 +83,78 @@ import { px } from './scale.js';
]
};
var MIRRORED = { chevronLeft: "chevronRight", chevronRight: "chevronLeft", arrowLeft: "arrowRight" };
// The download services' own marks (issues #89, #20): filled shapes in their own colours, as their
// owners draw them, rather than the outline set above. TorBox's is torbox.app's own logo
// (torbox.app/assets/logo-bb7a9579.svg); the torrent one is qBittorrent's, the client the
// torrent card sets up (qBittorrent's src/icons/qbittorrent-tray.svg, its gradient as one
// colour). Both are drawn at `size`, in the box that fits their proportions.
var BRANDS = {
torbox: {
viewBox: "367 308 766 884",
paths: [
{ d: "M749.99 749.99 749.99 1191.96 367.25 970.97 367.25 529.01Z", fill: "#00444d" },
{ d: "M1132.75 529.01 1132.75 970.97 749.99 1191.96 749.99 749.99 872.87 679.05 956.71 630.66Z", fill: "#34ba90" },
{ d: "M1132.75 529.01 749.99 749.99 367.25 529.01 749.99 308.04Z", fill: "#52a153" },
{ d: "M1043.04 739.36 958.66 1057.08 952.4 851.84 839.71 915.39 872.87 679.05 956.71 630.66 931.81 799.21Z", fill: "#ffffff" }
]
},
qbittorrent: {
// qBittorrent's own mark (src/icons/qbittorrent-tray.svg): the pale rim, the blue disc, then
// the two letters. Its gradient is drawn as one colour a brand icon can hold.
viewBox: "0 0 1024 1024",
paths: [
{ d: "M512 0a512 512 0 1 0 0 1024a512 512 0 1 0 0-1024Z", fill: "#daefff" },
{ d: "M512 16a496 496 0 1 0 0 992a496 496 0 1 0 0-992Z", fill: "#4f8ed4" },
{ d: "m712.898 332.399q66.657 0 103.38 45.671 37.03 45.364 37.03 128.684 0 83.32-37.34 129.61-37.03 45.98-103.07 45.98-33.02 0-60.484-12.035-27.156-12.344-45.672-37.649h-3.703l-10.8 43.512h-36.724v-480.172h51.227v116.65q0 39.191-2.469 70.359h2.47q35.796-50.61 106.155-50.61zm-7.406 42.894q-52.46 0-75.605 30.242-23.145 29.934-23.145 101.219 0 71.285 23.762 102.145 23.761 30.55 76.222 30.55 47.215 0 70.36-34.254 23.144-34.562 23.144-99.058 0-66.04-23.144-98.442-23.145-32.402-71.594-32.402z", fill: "#ffffff" },
{ d: "m317.273 639.45q51.227 0 74.68-27.466 23.453-27.464 24.996-92.578v-11.418q0-70.976-24.07-102.144-24.07-31.168-76.223-31.168-45.055 0-69.125 35.18-23.762 34.87-23.762 98.75 0 63.879 23.454 97.515 23.761 33.328 70.05 33.328zm-7.715 42.894q-65.421 0-102.144-45.98-36.723-45.981-36.723-128.376 0-83.011 37.032-129.609 37.03-46.598 103.07-46.598 69.433 0 106.773 52.461h2.778l7.406-46.289h40.426v490.047h-51.227v-144.73q0-30.86 3.395-52.461h-4.012q-35.488 51.535-106.774 51.535z", fill: "#c8e8ff" }
]
},
realdebrid: {
// Real-Debrid's own mark (fcdn.real-debrid.com/0840/images/logo.svg): a ring of two arcs, each
// with its darker face, drawn verbatim.
viewBox: "0 0 264 264",
paths: [
{ d: "M229.9,113.8l-3.7,13c-1-23.3-10.7-37-10.7-37c0.3,39.4-5.6,57.2-14.6,74.8c-0.7,1.4-1.4,2.7-2.1,4.1 c-0.1,0.1-0.1,0.2-0.2,0.3c0.1-0.1,0.1-0.2,0.2-0.3c-13,23.3-37.8,39.1-66.3,39.1c-35.4,0-65.3-24.4-73.5-57.4 c4.3,19.2,11,36.7,36.1,66.8c0,0-16.1-4.4-31.7-21.9l5.4,12.4c0,0-26.8-16-35.9-56.5c9,46.8,50.3,82.2,99.7,82.2 c39,0,72.8-22.1,89.9-54.3C240.3,142.6,229.9,113.8,229.9,113.8z", fill: "#9FD1EB" },
{ d: "M68.8,207.8l-5.4-12.4c15.6,17.4,31.7,21.9,31.7,21.9C70,187.1,63.3,169.6,59,150.4 c-1.5-5.9-2.3-12.1-2.3-18.4c0-41.8,34-75.8,75.8-75.8c1.8,0,3.6,0.1,5.3,0.2c0,0-0.1,0-0.1,0c0,0,0.1,0,0.1,0 c1.5,0.1,3,0.3,4.5,0.4c19.8,2.2,38,6.8,71,28.8c0,0-6.1-15.6-25.1-29.2l12.9,4.1c0,0-18.7-24.9-59.7-29.7 c-2.9-0.3-5.9-0.4-8.9-0.4C76.6,30.5,31.1,76,31.1,132c0,6.6,0.6,13,1.8,19.3C42,191.8,68.8,207.8,68.8,207.8z", fill: "#B8D995" },
{ d: "M229.9,116.8c0,0,2.8,7.8,3,20.4c0.3-14.3-3-23.4-3-23.4l-3.7,13c-1-23.3-10.7-37-10.7-37c0,1,0,2,0,3 c0.2,0.3,9.7,13.9,10.7,37L229.9,116.8z", fill: "#88B3C9" },
{ d: "M132.6,210.8c28.5,0,53.3-15.8,66.3-39.1c0.7-1.3,1.5-2.7,2.1-4.1c8.8-17.4,14.7-35,14.6-73.3 c-0.2,36.3-6,53.4-14.6,70.3c-0.7,1.4-1.4,2.7-2.1,4.1c-0.1,0.1-0.1,0.2-0.2,0.3c0.1-0.1,0.1-0.2,0.2-0.3 c-13,23.3-37.8,39.1-66.3,39.1c-35.4,0-65.3-24.4-73.5-57.4c1.1,5.1,2.5,10.1,4.2,15.3C75.1,192.2,101.7,210.8,132.6,210.8z", fill: "#88B3C9" },
{ d: "M132.6,33.5c3,0,5.9,0.1,8.9,0.4c30.5,3.6,48.6,18.2,55.9,25.5l3.8,1.2c0,0-18.7-24.9-59.7-29.7 c-2.9-0.3-5.9-0.4-8.9-0.4C76.6,30.5,31.1,76,31.1,132c0,0.5,0,1,0,1.5C31.9,78.2,77.1,33.5,132.6,33.5z", fill: "#A0BD82" },
{ d: "M67,206.5c1.2,0.8,1.9,1.2,1.9,1.2l-2.9-6.7c-0.8-0.8-1.6-1.7-2.5-2.6L67,206.5z", fill: "#A0BD82" },
{ d: "M211.1,84.2c0.8,0.5,1.5,1,2.3,1.5c0,0-4.7-12-18.8-24.2l-6.3-2C200.9,68.6,207.9,78.6,211.1,84.2z", fill: "#A0BD82" },
{ d: "M59,153.4c4.1,18.3,10.4,35,32.6,62.6c2.2,0.9,3.5,1.3,3.5,1.3C70,187.1,63.3,169.6,59,150.4 c-1.3-5.3-2.1-10.9-2.3-16.6c0,0.4,0,0.8,0,1.1C56.8,141.4,57.6,147.5,59,153.4z", fill: "#A0BD82" }
]
},
premiumize: {
// premiumize.me's own icon (premiumize.me/icon_normal.svg): the red figure with its yellow
// check, its circle written as a path like the rest.
viewBox: "0 0 240 240",
paths: [
{ d: "M76.5-0.138a47.5 47.5 0 1 0 95 0a47.5 47.5 0 1 0-95 0z", fill: "#aa0000" },
{ d: "M125,64.831A65,62.532 0 0 0 60,127.362v65h130v-65A65,62.532 0 0 0 125,64.831Z", fill: "#aa0000" },
{ d: "m5,97.362l110,25l130,-130c-33.227,57.785-74.464,116.996-120,185z", fill: "#ffcc00" }
]
}
};
function Icon({ name, size = 16, color = "#f2f2f3", fill: fill2 = "none", strokeWidth = 2, fixed = false }) {
const { rtl } = useLocale();
const drawn = rtl && !fixed ? MIRRORED[name] ?? name : name;
const brand = BRANDS[drawn];
if (brand) {
const boxWidth = Number(brand.viewBox.split(" ")[2]);
const boxHeight = Number(brand.viewBox.split(" ")[3]);
const width = boxWidth > boxHeight ? size : Math.round(size * boxWidth / boxHeight * 100) / 100;
const height = boxWidth > boxHeight ? Math.round(size * boxHeight / boxWidth * 100) / 100 : size;
return /* @__PURE__ */ (0, jsx)(Svg, { width: px(width), height: px(height), viewBox: brand.viewBox, children: brand.paths.map((path) => /* @__PURE__ */ (0, jsx)(
Path,
{
d: path.d,
fill: path.fill,
stroke: "none",
fillRule: path.fillRule
},
path.d
)) });
}
return /* @__PURE__ */ (0, jsx)(Svg, { width: px(size), height: px(size), viewBox: "0 0 24 24", children: ICONS[drawn].map((d) => /* @__PURE__ */ (0, jsx)(
Path,
{
-135
View File
@@ -1,135 +0,0 @@
// ../pub-game-store/native/ui/LanguageSheet.jsx
// reconstructed from the esbuild bundle in eboot.bin
// MODIFIED: the sheet is the app's settings: a download speed limit, navigation vibration, then the languages.
import '../assets/Inter-Regular.ttf';
import { Fragment, jsx, jsxs } from 'react/jsx-runtime';
import { formatPercent2 } from '../i18n/core.js';
import { chooseLanguage, languageChoice, systemLocale, useT } from '../i18n/index.js';
import { LOCALES } from '../i18n/locales.js';
import { FocusRing } from './FocusRing.jsx';
import { Icon } from './Icon.jsx';
import { SideSheet } from './SideSheet.jsx';
import { colors } from './colors.js';
import { LEVELS, setVibrationLevel, useVibrationLevel } from './haptics.js';
import { LIMITS, setSpeedLimit, useSpeedLimit } from '../state/speed-limit.js';
import { setDeletePackages, useDeletePackages } from '../state/package-cleanup.js';
import { Text, View, useIsFocused } from '@ps5-react/core';
var nameOf = (code) => LOCALES.find((locale2) => locale2.code === code).name;
function LanguageFace({ name, detail: detail2, checked }) {
const focused = useIsFocused();
return /* @__PURE__ */ (0, jsxs)(Fragment, { children: [
/* @__PURE__ */ (0, jsx)(FocusRing, {}),
/* @__PURE__ */ (0, jsxs)(View, { style: { flex: 1, gap: 3 }, children: [
/* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 23, fontWeight: 600, color: "#f2f2f3" }, children: name }),
/* @__PURE__ */ (0, jsx)(Text, { style: [{ fontFamily: "Inter-Regular", fontSize: 21, lineHeight: 30 }, { color: focused ? colors.fg : colors.muted }], children: detail2 })
] }),
checked ? /* @__PURE__ */ (0, jsx)(Icon, { name: "check", color: colors.fg }) : /* @__PURE__ */ (0, jsx)(View, { style: { width: 24, height: 24 } })
] });
}
function Row4({ code, name, detail: detail2, checked, onPress }) {
return /* @__PURE__ */ (0, jsx)(View, { focusKey: `language:${code}`, onPress, style: (_state) => [{ position: "relative", flexDirection: "row", alignItems: "center", gap: 24, borderRadius: 12, paddingHorizontal: 18, paddingVertical: 18 }, _state.focused ? { backgroundColor: "#212125" } : null], children: /* @__PURE__ */ (0, jsx)(LanguageFace, { name, detail: detail2, checked }) });
}
// One segment of the speed limit (issue #15): every value in view, the chosen one filled. The D-pad
// moves along the row and Cross picks, so no value has to be typed.
function SpeedSegment({ limit, chosen }) {
const t2 = useT();
return /* @__PURE__ */ (0, jsxs)(View, { focusKey: `settings:speed:${limit}`, autoFocus: chosen, onPress: () => setSpeedLimit(limit), style: { position: "relative", flex: limit ? 1 : 2, height: 60, alignItems: "center", justifyContent: "center", borderRadius: 12, backgroundColor: chosen ? colors.fg : colors.surface2 }, children: [
/* @__PURE__ */ (0, jsx)(FocusRing, {}),
/* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 21, fontWeight: 600, color: chosen ? colors.accentFg : colors.muted, numberOfLines: 1 }, children: limit ? String(limit) : t2("settings.speedUnlimited") })
] });
}
function SpeedLimit() {
const t2 = useT();
const limit = useSpeedLimit();
return /* @__PURE__ */ (0, jsxs)(View, { style: { gap: 18, paddingHorizontal: 18, paddingTop: 6, paddingBottom: 18 }, children: [
/* @__PURE__ */ (0, jsxs)(View, { style: { gap: 3 }, children: [
/* @__PURE__ */ (0, jsxs)(View, { style: { flexDirection: "row", justifyContent: "space-between", alignItems: "center" }, children: [
/* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 23, fontWeight: 600, color: colors.fg }, children: t2("settings.speedLimit") }),
/* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 21, color: colors.fg }, children: limit ? t2("units.speed", { n: String(limit) }) : t2("settings.speedUnlimited") })
] }),
/* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 21, color: colors.muted, lineHeight: 30 }, children: t2("settings.speedLimitDetail") })
] }),
/* @__PURE__ */ (0, jsx)(View, { style: { flexDirection: "row", gap: 9 }, children: LIMITS.map((value) => /* @__PURE__ */ (0, jsx)(SpeedSegment, { limit: value, chosen: value === limit }, value)) })
] });
}
// Whether a PKG is deleted once installed, laid out like the speed limit: both choices in view.
function PackagesSegment({ on, chosen }) {
const t2 = useT();
return /* @__PURE__ */ (0, jsxs)(View, { focusKey: `settings:packages:${on ? "on" : "off"}`, onPress: () => setDeletePackages(on), style: { position: "relative", flex: 1, height: 60, alignItems: "center", justifyContent: "center", borderRadius: 12, backgroundColor: chosen ? colors.fg : colors.surface2 }, children: [
/* @__PURE__ */ (0, jsx)(FocusRing, {}),
/* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 21, fontWeight: 600, color: chosen ? colors.accentFg : colors.muted, numberOfLines: 1 }, children: t2(on ? "settings.on" : "settings.off") })
] });
}
function DeletePackages() {
const t2 = useT();
const on = useDeletePackages();
return /* @__PURE__ */ (0, jsxs)(View, { style: { gap: 18, paddingHorizontal: 18, paddingTop: 6, paddingBottom: 18 }, children: [
/* @__PURE__ */ (0, jsxs)(View, { style: { gap: 3 }, children: [
/* @__PURE__ */ (0, jsxs)(View, { style: { flexDirection: "row", justifyContent: "space-between", alignItems: "center" }, children: [
/* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 23, fontWeight: 600, color: colors.fg }, children: t2("settings.deletePackages") }),
/* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 21, color: colors.fg }, children: t2(on ? "settings.on" : "settings.off") })
] }),
/* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 21, color: colors.muted, lineHeight: 30 }, children: t2("settings.deletePackagesDetail") })
] }),
/* @__PURE__ */ (0, jsx)(View, { style: { flexDirection: "row", gap: 9 }, children: [true, false].map((value) => /* @__PURE__ */ (0, jsx)(PackagesSegment, { on: value, chosen: value === on }, String(value))) })
] });
}
// The vibration level (issue #32), laid out like the speed limit: every step in view, Cross picks.
var levelName = (t2, level) => level ? formatPercent2(level) : t2("settings.vibrationOff");
function VibrationSegment({ level, chosen }) {
const t2 = useT();
return /* @__PURE__ */ (0, jsxs)(View, { focusKey: `settings:vibration:${level}`, onPress: () => setVibrationLevel(level), style: { position: "relative", flex: level ? 1 : 2, height: 60, alignItems: "center", justifyContent: "center", borderRadius: 12, backgroundColor: chosen ? colors.fg : colors.surface2 }, children: [
/* @__PURE__ */ (0, jsx)(FocusRing, {}),
/* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 21, fontWeight: 600, color: chosen ? colors.accentFg : colors.muted, numberOfLines: 1 }, children: levelName(t2, level) })
] });
}
function VibrationLevel() {
const t2 = useT();
const level = useVibrationLevel();
return /* @__PURE__ */ (0, jsxs)(View, { style: { gap: 18, paddingHorizontal: 18, paddingTop: 6, paddingBottom: 18 }, children: [
/* @__PURE__ */ (0, jsxs)(View, { style: { gap: 3 }, children: [
/* @__PURE__ */ (0, jsxs)(View, { style: { flexDirection: "row", justifyContent: "space-between", alignItems: "center" }, children: [
/* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 23, fontWeight: 600, color: colors.fg }, children: t2("settings.vibration") }),
/* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 21, color: colors.fg }, children: levelName(t2, level) })
] }),
/* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 21, color: colors.muted, lineHeight: 30 }, children: t2("settings.vibrationDetail") })
] }),
/* @__PURE__ */ (0, jsx)(View, { style: { flexDirection: "row", gap: 9 }, children: LEVELS.map((value) => /* @__PURE__ */ (0, jsx)(VibrationSegment, { level: value, chosen: value === level }, value)) })
] });
}
function SectionLabel({ icon, title, detail: detail2 }) {
const text = /* @__PURE__ */ (0, jsxs)(View, { style: { flex: 1, gap: 3 }, children: [
/* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 19, fontWeight: 600, color: colors.muted, letterSpacing: 1.5 }, children: title }),
detail2 ? /* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 19, color: colors.muted, lineHeight: 27 }, children: detail2 }) : null
] });
return /* @__PURE__ */ (0, jsxs)(View, { style: { flexDirection: "row", alignItems: "flex-start", gap: 18, paddingHorizontal: 18, paddingTop: 18, paddingBottom: 6 }, children: [
icon ? /* @__PURE__ */ (0, jsx)(Icon, { name: icon, size: 26, color: colors.muted }) : null,
text
] });
}
function LanguageSheet({ onClose }) {
const t2 = useT();
const choice2 = languageChoice();
const pick2 = (code) => {
chooseLanguage(code);
onClose();
};
// The speed limit and vibration stay open on press, so the change shows (or is felt) at once; a
// language choice still closes the sheet as before. Focus opens on the chosen speed, at the top.
return /* @__PURE__ */ (0, jsx)(SideSheet, { title: t2("settings.title"), onClose, children: /* @__PURE__ */ (0, jsxs)(View, { style: { gap: 6, padding: 9, paddingBottom: 48 }, children: [
/* @__PURE__ */ (0, jsx)(SectionLabel, { icon: "download", title: t2("settings.downloads") }),
/* @__PURE__ */ (0, jsx)(SpeedLimit, {}),
/* @__PURE__ */ (0, jsx)(DeletePackages, {}),
/* @__PURE__ */ (0, jsx)(SectionLabel, { title: t2("settings.controller") }),
/* @__PURE__ */ (0, jsx)(VibrationLevel, {}),
/* @__PURE__ */ (0, jsx)(SectionLabel, { icon: "languages", title: t2("language.title"), detail: t2("language.subtitle") }),
/* @__PURE__ */ (0, jsx)(Row4, { code: "system", name: t2("language.system"), detail: nameOf(systemLocale()), checked: choice2 === null, onPress: () => pick2(null) }),
LOCALES.map(
({ code, name }) => /* @__PURE__ */ (0, jsx)(Row4, { code, name, detail: t2(`language.name.${code}`), checked: choice2 === code, onPress: () => pick2(code) }, code)
)
] }) });
}
export { LanguageSheet };
+2
View File
@@ -19,6 +19,8 @@ import { View } from '@ps5-react/core';
browse: [HINT.select, HINT.top, HINT.sections],
game: [HINT.select, HINT.back, HINT.favorite, HINT.sections],
homebrew: [HINT.select, HINT.back, HINT.favorite, HINT.sections],
favorites: [HINT.select, HINT.back, HINT.favorite, HINT.sections],
settings: [HINT.select, HINT.back, HINT.sections],
search: [HINT.select, HINT.favorite, HINT.clear, HINT.back, HINT.sections]
};
var registered2 = [];
+9 -5
View File
@@ -7,14 +7,18 @@ import { Path, Svg, Text, View } from '@ps5-react/core';
import { colors } from './colors.js';
import { px } from './scale.js';
// The dial and its needle alone, the mark beside the word (and on its own where the word won't fit).
function LogoMark({ size = 28, color = colors.fg }) {
return /* @__PURE__ */ (0, jsxs)(Svg, { width: px(size), height: px(size), viewBox: "0 0 32 32", children: [
/* @__PURE__ */ (0, jsx)(Path, { d: "M26.34 12.24A11 11 0 1 1 19.76 5.66", fill: "none", stroke: color, strokeWidth: 4.5 }),
/* @__PURE__ */ (0, jsx)(Path, { d: "M28.4 3.6 17.6 18.1a2.9 2.9 0 1 1-3.7-3.7Z", fill: color })
] });
}
function Logo({ color = colors.fg }) {
return /* @__PURE__ */ (0, jsxs)(View, { style: { flexDirection: "row", alignItems: "center", gap: 12 }, children: [
/* @__PURE__ */ (0, jsxs)(Svg, { width: px(28), height: px(28), viewBox: "0 0 32 32", children: [
/* @__PURE__ */ (0, jsx)(Path, { d: "M26.34 12.24A11 11 0 1 1 19.76 5.66", fill: "none", stroke: color, strokeWidth: 4.5 }),
/* @__PURE__ */ (0, jsx)(Path, { d: "M28.4 3.6 17.6 18.1a2.9 2.9 0 1 1-3.7-3.7Z", fill: color })
] }),
/* @__PURE__ */ (0, jsx)(LogoMark, { color }),
/* @__PURE__ */ (0, jsx)(Text, { style: [{ fontFamily: "Inter-Regular", fontSize: 29, fontWeight: 600, letterSpacing: -0.85 }, { color }], children: "overdrive" })
] });
}
export { Logo };
export { Logo, LogoMark };
+19 -7
View File
@@ -7,21 +7,33 @@ import { memo } from 'react';
import { FocusRing } from './FocusRing.jsx';
import { Icon } from './Icon.jsx';
import { colors } from './colors.js';
import { Text, View, useIsFocused } from '@ps5-react/core';
import { Text, View, motion, useIsFocused } from '@ps5-react/core';
function Content2({ label, icon, iconFill, trailing, current }) {
function NavContent({ label, icon, iconFill, trailing, current, focusLabel, suppressActiveLabel, iconAfterLabel }) {
const focused = useIsFocused();
const showLabel = Boolean(focusLabel && (focused || (current && !suppressActiveLabel)));
const color = current || focused ? colors.fg : "#f2f2f3a6";
return /* @__PURE__ */ (0, jsxs)(Fragment, { children: [
/* @__PURE__ */ (0, jsx)(FocusRing, { round: "full" }),
icon && /* @__PURE__ */ (0, jsx)(Icon, { name: icon, size: 18, color, fill: iconFill ? color : "none" }),
label && /* @__PURE__ */ (0, jsx)(Text, { style: [{ fontFamily: "Inter-Regular", fontSize: 23 }, { color }], children: label }),
icon && !iconAfterLabel && /* @__PURE__ */ (0, jsx)(Icon, { name: icon, size: 18, color, fill: iconFill ? color : "none" }),
label && (!focusLabel || !showLabel) && /* @__PURE__ */ (0, jsx)(Text, { style: [{ fontFamily: "Inter-Regular", fontSize: 23 }, { color }], children: label }),
showLabel && /* @__PURE__ */ (0, jsx)(motion.View, {
initial: { opacity: 0, x: -14 },
animate: { opacity: 1, x: 0 },
transition: { type: "tween", duration: 0.22, ease: [0.16, 1, 0.3, 1] },
style: { justifyContent: "center" },
children: /* @__PURE__ */ (0, jsx)(Text, {
style: { fontFamily: "Inter-Regular", fontSize: 21, color: colors.fg, numberOfLines: 1 },
children: focusLabel
})
}, "focus-label"),
icon && iconAfterLabel && /* @__PURE__ */ (0, jsx)(Icon, { name: icon, size: 18, color, fill: iconFill ? color : "none" }),
trailing && /* @__PURE__ */ (0, jsx)(Icon, { name: trailing, size: 14, color })
] });
}
var NavItem = (0, memo)(function NavItem2({ label, icon, iconFill, trailing, current = false, children, ...rest2 }) {
return /* @__PURE__ */ (0, jsxs)(View, { ...rest2, style: { position: "relative", height: 60, flexDirection: "row", alignItems: "center", gap: 9, borderRadius: 14999, paddingHorizontal: 24 }, children: [
/* @__PURE__ */ (0, jsx)(Content2, { label, icon, iconFill, trailing, current }),
var NavItem = (0, memo)(function NavItemView({ label, icon, iconFill, trailing, focusLabel, iconAfterLabel = false, suppressActiveLabel = false, compact = false, current = false, children, ...rest2 }) {
return /* @__PURE__ */ (0, jsxs)(View, { ...rest2, style: { position: "relative", height: 60, flexDirection: "row", alignItems: "center", gap: 9, borderRadius: 14999, paddingHorizontal: compact ? 14 : 24 }, children: [
/* @__PURE__ */ (0, jsx)(NavContent, { label, icon, iconFill, trailing, current, focusLabel, suppressActiveLabel, iconAfterLabel }),
children
] });
});
+3 -2
View File
@@ -23,10 +23,11 @@ import { px } from './scale.js';
const end = left == null ? `L${R} ${H}A${R} ${R} 0 0 1 0 ${H - R}Z` : `L${left - SLANT / 2} ${H}Z`;
return start + top + end;
}
function PlatformBadge({ platforms, style }) {
// `scale` draws the same badge larger, as the download choice's mark of the PS5.
function PlatformBadge({ platforms, style, scale = 1 }) {
const order = [...platforms].sort().reverse();
const width = order.length * W;
return /* @__PURE__ */ (0, jsx)(View, { style, children: /* @__PURE__ */ (0, jsx)(Svg, { width: px(width), height: px(H), viewBox: `0 0 ${width} ${H}`, children: order.map((platform, i) => {
return /* @__PURE__ */ (0, jsx)(View, { style, children: /* @__PURE__ */ (0, jsx)(Svg, { width: px(width * scale), height: px(H * scale), viewBox: `0 0 ${width} ${H}`, children: order.map((platform, i) => {
const { fill: fill2, ink: ink2, edge } = STYLE[platform];
const word = WORDS[platform], scale = TYPE / CAP2;
const x = i * W + (W - (word.right - word.left) * scale) / 2 - word.left * scale;
+54
View File
@@ -0,0 +1,54 @@
// A QR code drawn with plain Views: the phone login's approval page, scanned off the TV. Each row's
// dark modules are joined into runs, so a version-4 code (33 modules) takes a few hundred Views, not
// a thousand; the white square carries the four-module quiet zone a phone camera needs.
import { jsx } from 'react/jsx-runtime';
import { useMemo } from 'react';
import { qrcodegen } from '../third_party/qrcodegen.js';
import { View } from '@ps5-react/core';
// [[start, length], ...] of the dark runs in each row; null when the text does not fit a QR code.
function qrRuns(text) {
let code;
try {
code = qrcodegen.QrCode.encodeText(String(text || ""), qrcodegen.QrCode.Ecc.MEDIUM);
} catch {
return null;
}
const rows = [];
for (let y = 0; y < code.size; y++) {
const runs = [];
for (let x = 0; x < code.size;) {
if (!code.getModule(x, y)) {
x++;
continue;
}
const start = x;
while (x < code.size && code.getModule(x, y)) x++;
runs.push([start, x - start]);
}
rows.push(runs);
}
return { size: code.size, rows };
}
var QUIET = 4;
function QRCode({ text, size = 360 }) {
const matrix = (0, useMemo)(() => qrRuns(text), [text]);
if (!matrix) return /* @__PURE__ */ (0, jsx)(View, { style: { width: size, height: size, borderRadius: 18, backgroundColor: "#ffffff" } });
// Whole pixels per module, so no row blurs into the next on the TV.
const module = Math.max(2, Math.floor(size / (matrix.size + QUIET * 2)));
const side = module * (matrix.size + QUIET * 2);
return /* @__PURE__ */ (0, jsx)(View, {
style: { width: side, height: side, borderRadius: 18, backgroundColor: "#ffffff", padding: module * QUIET },
children: matrix.rows.map((runs, y) => /* @__PURE__ */ (0, jsx)(View, {
style: { width: module * matrix.size, height: module, flexDirection: "row" },
children: runs.map(([start, length], index) => /* @__PURE__ */ (0, jsx)(View, {
style: { position: "absolute", left: start * module, top: 0, width: length * module, height: module, backgroundColor: "#000000" }
}, index))
}, y))
});
}
export { QRCode, qrRuns };
+340
View File
@@ -0,0 +1,340 @@
// Settings, rebuilt as the PS5's own settings are: every entry a tile that shows its value, and
// pressing it opens that setting's page — its choices on the left, what the focused choice does on
// the right. The order is General first, then the Downloads settings, then the linked services.
// Services wear the same brand marks the Downloads page draws.
//
// The page's content lives in settings-data.js; this file is layout and what a press does. Back (○)
// leaves a setting for the tiles, and leaves the tiles for the app.
import '../assets/Inter-Regular.ttf';
import '../assets/JetBrainsMono-Regular.ttf';
import { Fragment, jsx, jsxs } from 'react/jsx-runtime';
import { useState } from 'react';
import { chooseLanguage, languageChoice, systemLocale, useT } from '../i18n/index.js';
import en from '../i18n/locales/en.json';
import { useNavActions } from '../state/nav.jsx';
import { setService, useServices } from '../state/download-services.js';
import { setSpeedLimit, useSpeedLimit } from '../state/speed-limit.js';
import { setDeletePackages, useDeletePackages } from '../state/package-cleanup.js';
import { setDownloadRoot, useDownloadRoot } from '../state/download-root.js';
import { storageRoots } from '../state/downloads.js';
import { setPkgMode, usePkgMode } from '../state/pkg-mode.js';
import { setVibrationLevel, useVibrationLevel } from './haptics.js';
import { GROUPS, ITEMS, currentOf, optionsOf, selectedIndex, sideOf, valueOf } from './settings-data.js';
import { Button2 } from './Button.jsx';
import { FocusRing } from './FocusRing.jsx';
import { Icon } from './Icon.jsx';
import { LogoMark } from './Logo.jsx';
import { PlatformBadge } from './PlatformBadge.jsx';
import { HINT, useHints } from './Legend.jsx';
import { colors } from './colors.js';
import { formatBytes } from '../state/size.js';
import { URLDialog } from '../downloads/URLDialog.jsx';
import { PhoneSetup } from '../downloads/PhoneSetup.jsx';
import { FileSystem, FocusScope, ScrollView, Text, View, useIsFocused } from '@ps5-react/core';
// The screen is 1920 wide and the tiles sit inside two 96 px margins, each row inset 14 more: two
// columns, 18 apart, come to 841 each.
var PAGE_PAD = 96;
var INSET = 14;
var GAP = 18;
var TILE_W = (1920 - PAGE_PAD * 2 - INSET * 2 - GAP) / 2;
var HOME_HINTS = [HINT.select, HINT.back];
var PAGE_HINTS = [HINT.select, HINT.back];
var TITLES = /* @__PURE__ */ new Map();
// A language's own word for "Settings", for the sample on its page: read once per language.
function titleIn(code) {
if (TITLES.has(code)) return TITLES.get(code);
let text = en["settings.title"];
try {
text = JSON.parse(FileSystem.readFile(`${FileSystem.appDir}/i18n/locales/${code}.json`))["settings.title"] || text;
} catch (error) {
console.log(`[settings] ${code} not read: ${error.message}`);
}
TITLES.set(code, text);
return text;
}
// Every setting's value, from the modules that save it to settings.json. The drives are measured
// once, as the page opens, rather than on every focus move.
function useSettingsValues() {
const services = useServices();
const limit = useSpeedLimit();
const root = useDownloadRoot();
const mode = usePkgMode();
const cleanup = useDeletePackages();
const vibration = useVibrationLevel();
const [drives] = useState(() => {
let roots = [];
try {
roots = storageRoots();
} catch (error) {
console.log(`[settings] drives not listed: ${error.message}`);
}
return roots.map((value) => {
try {
const usage = FileSystem.diskUsage(value);
return { root: value, free: usage.free, total: usage.total };
} catch (error) {
console.log(`[settings] ${value}: ${error.message}`);
return { root: value, free: 0, total: 0 };
}
});
});
return { services, limit, root, mode, cleanup, vibration, choice: languageChoice(), drives };
}
// A tile: its mark, its name, the value it has now, and the chevron into its page.
function Tile({ id, item, value, onPress }) {
const t2 = useT();
const focused = useIsFocused();
return /* @__PURE__ */ (0, jsx)(View, {
focusKey: `settings:tile:${id}`,
onPress,
style: (state) => [{ position: "relative", width: TILE_W, height: 128, flexDirection: "row", alignItems: "center", gap: 26, borderRadius: 20, paddingLeft: 26, paddingRight: 34, backgroundColor: colors.surface }, state.focused ? { backgroundColor: colors.surface2 } : null],
children: /* @__PURE__ */ (0, jsxs)(Fragment, { children: [
/* @__PURE__ */ (0, jsx)(FocusRing, { round: "lg" }),
/* @__PURE__ */ (0, jsx)(View, { style: { width: 76, height: 76, borderRadius: 18, alignItems: "center", justifyContent: "center", backgroundColor: focused ? colors.surface3 : colors.surface2 }, children: item.brand
? /* @__PURE__ */ (0, jsx)(Icon, { name: item.brand, size: 44, color: colors.fg })
: /* @__PURE__ */ (0, jsx)(Icon, { name: item.icon, size: 36, color: colors.fg }) }),
/* @__PURE__ */ (0, jsxs)(View, { style: { flex: 1, gap: 6 }, children: [
/* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 27, fontWeight: 600, color: colors.fg, letterSpacing: -0.3, numberOfLines: 1, ellipsizeMode: "tail" }, children: t2(item.title) }),
/* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 21, color: colors.muted, numberOfLines: 1, ellipsizeMode: "tail" }, children: value })
] }),
/* @__PURE__ */ (0, jsx)(Icon, { name: "chevronRight", size: 26, color: focused ? colors.fg : colors.muted })
] })
});
}
function Section({ group, ctx, t2, onOpen }) {
const items = group.items.filter((id) => id !== "disk" || ctx.values.drives.length > 1);
if (!items.length) return null;
// Two tiles a row, as the grid draws them: built by hand rather than wrapped, so a rounding of
// the row's width can never push the second column down to a row of its own.
const rows = [];
for (let at = 0; at < items.length; at += 2) rows.push(items.slice(at, at + 2));
return /* @__PURE__ */ (0, jsxs)(View, { style: { marginBottom: 26 }, children: [
/* @__PURE__ */ (0, jsx)(Text, { style: { marginLeft: 4, marginRight: 4, marginBottom: 16, fontFamily: "Inter-Regular", fontSize: 19, fontWeight: 600, letterSpacing: 2.4, color: colors.subtle }, children: t2(group.title) }),
/* @__PURE__ */ (0, jsx)(View, { style: { gap: GAP }, children: rows.map((pair) => /* @__PURE__ */ (0, jsx)(View, { style: { flexDirection: "row", gap: GAP }, children: pair.map((id) => /* @__PURE__ */ (0, jsx)(Tile, {
id, item: ITEMS[id], value: valueOf(id, ctx, t2), onPress: () => onOpen(id)
}, id)) }, pair.join(":"))) })
] });
}
function Home({ ctx, t2, onOpen }) {
return /* @__PURE__ */ (0, jsxs)(View, { style: { flex: 1, paddingTop: 34, paddingHorizontal: PAGE_PAD }, children: [
/* @__PURE__ */ (0, jsx)(View, { style: { height: 60, justifyContent: "center" }, children: /* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 44, fontWeight: 600, color: colors.fg, letterSpacing: -1.2 }, children: t2("settings.title") }) }),
/* @__PURE__ */ (0, jsx)(ScrollView, { style: { flex: 1, marginTop: 18 }, children: /* @__PURE__ */ (0, jsx)(View, { style: { paddingTop: 14, paddingHorizontal: INSET, paddingBottom: 90 }, children: GROUPS.map((group) => /* @__PURE__ */ (0, jsx)(Section, { group, ctx, t2, onOpen }, group.key)) }) })
] });
}
// A row of a setting's page: a radio that fills when it is the choice, or the square an action
// carries its icon in, then the name, the line under it, and what the row holds now.
function Option({ id, index, row, t2, selected, onFocus, onPress }) {
const focused = useIsFocused();
return /* @__PURE__ */ (0, jsxs)(View, {
focusKey: `settings:option:${id}:${index}`,
onPress,
onFocus,
style: [{ position: "relative", minHeight: 88, flexDirection: "row", alignItems: "center", gap: 22, borderRadius: 18, paddingVertical: 18, paddingHorizontal: 28, backgroundColor: focused ? colors.surface2 : colors.surface }],
children: [
/* @__PURE__ */ (0, jsx)(FocusRing, { round: "lg" }),
row.action
? /* @__PURE__ */ (0, jsx)(View, { style: { width: 34, height: 34, borderRadius: 12, alignItems: "center", justifyContent: "center", backgroundColor: colors.surface2 }, children: /* @__PURE__ */ (0, jsx)(Icon, { name: row.action, size: 22, color: colors.fg }) })
: /* @__PURE__ */ (0, jsx)(View, { style: { width: 34, height: 34, borderRadius: 17, borderWidth: 3, borderColor: selected ? colors.fg : colors.subtle, alignItems: "center", justifyContent: "center", backgroundColor: selected ? colors.fg : "transparent" }, children: selected ? /* @__PURE__ */ (0, jsx)(Icon, { name: "check", size: 20, color: colors.accentFg }) : null }),
/* @__PURE__ */ (0, jsxs)(View, { style: { flex: 1, gap: 4 }, children: [
/* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 27, fontWeight: 600, color: colors.fg }, children: row.label }),
row.detail ? /* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 20, color: colors.muted, lineHeight: 28 }, children: row.detail }) : null
] }),
row.tag ? /* @__PURE__ */ (0, jsx)(View, { style: { borderRadius: 999, backgroundColor: colors.ok, paddingVertical: 5, paddingHorizontal: 14 }, children: /* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 17, fontWeight: 600, color: colors.accentFg }, children: row.tag }) }) : null,
// The marks sit on the row itself, with no box behind them.
row.mark ? /* @__PURE__ */ (0, jsx)(View, { style: { flexDirection: "row", alignItems: "center", gap: 10 }, children: row.mark === "console"
? /* @__PURE__ */ (0, jsx)(PlatformBadge, { platforms: ["ps5"], scale: 1.5 })
: row.mark === "overdrive" ? /* @__PURE__ */ (0, jsx)(LogoMark, { size: 36 }) : /* @__PURE__ */ (0, jsxs)(Fragment, { children: [
/* @__PURE__ */ (0, jsx)(PlatformBadge, { platforms: ["ps5"], scale: 1.05 }),
/* @__PURE__ */ (0, jsx)(LogoMark, { size: 24 })
] }) }) : null,
row.valueText ? /* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 20, color: row.valueText === t2("add.notSet") ? colors.subtle : colors.muted, numberOfLines: 1, ellipsizeMode: "head" }, children: row.valueText }) : null
]
});
}
// The vibration level, drawn: four bars, as many lit as the level holds.
function Preview({ level }) {
const lit = Math.round(level / 25);
return /* @__PURE__ */ (0, jsxs)(View, { style: { alignItems: "center", gap: 20, paddingVertical: 8 }, children: [
// The pad's own mark sits a little high in its square: the glyph's drawing leans below the
// centre of its box, so a margin under it is what puts it in the middle to the eye.
/* @__PURE__ */ (0, jsx)(View, { style: { width: 200, height: 200, borderRadius: 56, alignItems: "center", justifyContent: "center", backgroundColor: colors.surface2 }, children: /* @__PURE__ */ (0, jsx)(View, { style: { marginBottom: 16 }, children: /* @__PURE__ */ (0, jsx)(Icon, { name: "controller", size: 96, color: colors.fg }) }) }),
/* @__PURE__ */ (0, jsx)(View, { style: { flexDirection: "row", gap: 10 }, children: [0, 1, 2, 3].map((step) => /* @__PURE__ */ (0, jsx)(View, { style: { width: 44, height: 6, borderRadius: 3, backgroundColor: step < lit ? colors.fg : colors.surface3 } }, String(step))) })
] });
}
// The panel on the right of a page: an eyebrow, the marks or the sample, the big line, the rows,
// the drives, and the note that says what the choice does. Its width is what the row leaves after
// the 980 left column and the 72 between them, so its lines know where to wrap.
var SIDE_W = 1920 - PAGE_PAD * 2 - 980 - 72;
function Side({ data, t2 }) {
return /* @__PURE__ */ (0, jsxs)(View, { style: { width: SIDE_W, borderRadius: 26, backgroundColor: colors.surface, paddingVertical: 44, paddingHorizontal: 48, gap: 26 }, children: [
data.eyebrow ? /* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 18, fontWeight: 600, letterSpacing: 2.4, color: colors.subtle }, children: data.eyebrow }) : null,
data.sample ? /* @__PURE__ */ (0, jsxs)(View, { style: { gap: 6 }, children: [
/* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 60, fontWeight: 600, color: colors.fg, letterSpacing: -1.4, writingDirection: data.rtl ? "rtl" : "ltr", textAlign: data.rtl ? "right" : "left" }, children: data.sample.title }),
/* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 26, color: colors.muted, writingDirection: data.rtl ? "rtl" : "ltr", textAlign: data.rtl ? "right" : "left" }, children: data.sample.name })
] }) : null,
data.preview !== void 0 ? /* @__PURE__ */ (0, jsx)(Preview, { level: data.preview }) : null,
data.marks ? /* @__PURE__ */ (0, jsxs)(View, { style: { flexDirection: "row", alignItems: "center", gap: 22 }, children: [
data.marks === "console" ? /* @__PURE__ */ (0, jsx)(PlatformBadge, { platforms: ["ps5"], scale: 2 }) : null,
data.marks === "overdrive" ? /* @__PURE__ */ (0, jsx)(LogoMark, { size: 56 }) : null,
data.marks === "ask" ? /* @__PURE__ */ (0, jsxs)(View, { style: { flexDirection: "row", alignItems: "center", gap: 14 }, children: [
/* @__PURE__ */ (0, jsx)(PlatformBadge, { platforms: ["ps5"], scale: 1.4 }),
/* @__PURE__ */ (0, jsx)(LogoMark, { size: 34 })
] }) : null,
data.markLabel ? /* @__PURE__ */ (0, jsx)(Text, { style: { flex: 1, fontFamily: "Inter-Regular", fontSize: 21, color: colors.muted, lineHeight: 30 }, children: data.markLabel }) : null
] }) : null,
data.big ? /* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 40, fontWeight: 600, color: colors.fg, letterSpacing: -0.8 }, children: data.big }) : null,
data.rows && data.rows.length ? /* @__PURE__ */ (0, jsx)(View, { style: { gap: 14 }, children: data.rows.map((row) => /* @__PURE__ */ (0, jsxs)(View, { style: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", gap: 16, borderRadius: 14, paddingVertical: 14, paddingHorizontal: 18, backgroundColor: row.on ? colors.fg : colors.surface2 }, children: [
/* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 22, color: row.on ? colors.accentFg : colors.muted }, children: row.label }),
/* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 22, fontWeight: 600, color: row.on ? colors.accentFg : colors.fg }, children: row.value })
] }, row.label)) }) : null,
data.drives && data.drives.length ? /* @__PURE__ */ (0, jsx)(View, { style: { gap: 14 }, children: data.drives.map((drive) => /* @__PURE__ */ (0, jsxs)(View, { style: [{ gap: 10, borderRadius: 16, paddingVertical: 18, paddingHorizontal: 20, backgroundColor: colors.surface2 }, drive.on ? { borderWidth: 3, borderColor: colors.fg } : null], children: [
/* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 22, fontWeight: 600, color: colors.fg }, children: drive.name }),
drive.total ? /* @__PURE__ */ (0, jsxs)(Fragment, { children: [
/* @__PURE__ */ (0, jsx)(View, { style: { height: 12, borderRadius: 6, backgroundColor: colors.surface3 }, children: /* @__PURE__ */ (0, jsx)(View, { style: { width: Math.max(2, Math.round((drive.total - drive.free) / drive.total * 100)) + "%", height: 12, borderRadius: 6, backgroundColor: colors.fg } }) }),
/* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 19, color: colors.muted }, children: t2("files.free", { free: formatBytes(drive.free), total: formatBytes(drive.total) }) })
] }) : null
] }, drive.name)) }) : null,
data.small ? /* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 21, color: colors.muted, lineHeight: 32 }, children: data.small }) : null
] });
}
// A setting's page: its name and what it is, the choices under it, and what the focused choice
// does beside them. The block is short or tall as its choices are, and the page centres it: the
// list stops at a screenful and scrolls past that (a language list is longer than the screen).
var LIST_MAX_H = 620;
function Detail({ id, ctx, t2, onPick }) {
const [focused, setFocused] = useState(() => selectedIndex(id, ctx, t2));
const rows = optionsOf(id, ctx, t2);
const row = rows[Math.min(focused, rows.length - 1)];
const current = currentOf(id, ctx.values);
const item = ITEMS[id];
return /* @__PURE__ */ (0, jsx)(View, { style: { flex: 1, justifyContent: "center", paddingHorizontal: PAGE_PAD, paddingVertical: 30 }, children: /* @__PURE__ */ (0, jsxs)(View, { style: { flexDirection: "row", alignItems: "center", gap: 72 }, children: [
/* @__PURE__ */ (0, jsxs)(View, { style: { width: 980 }, children: [
item.brand
// A service's page opens with its own mark beside its name, as its tile draws it.
? /* @__PURE__ */ (0, jsxs)(View, { style: { flexDirection: "row", alignItems: "center", gap: 24, height: 76 }, children: [
/* @__PURE__ */ (0, jsx)(View, { style: { width: 76, height: 76, borderRadius: 18, alignItems: "center", justifyContent: "center", backgroundColor: colors.surface2 }, children: /* @__PURE__ */ (0, jsx)(Icon, { name: item.brand, size: 44, color: colors.fg }) }),
/* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 46, fontWeight: 600, color: colors.fg, letterSpacing: -1.2 }, children: t2(item.title) })
] })
: /* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 46, fontWeight: 600, color: colors.fg, letterSpacing: -1.2, lineHeight: 58 }, children: t2(item.title) }),
/* @__PURE__ */ (0, jsx)(Text, { style: { width: 800, marginTop: 4, marginBottom: 18, fontFamily: "Inter-Regular", fontSize: 24, color: colors.muted, lineHeight: 35 }, children: t2(item.lead) }),
/* @__PURE__ */ (0, jsx)(ScrollView, { style: { maxHeight: LIST_MAX_H }, children: /* @__PURE__ */ (0, jsx)(View, { style: { gap: 12, padding: 10, paddingBottom: 12 }, children: rows.map((entry, index) => /* @__PURE__ */ (0, jsx)(Option, {
id, index, row: entry, t2,
selected: entry.value !== void 0 && entry.value === current,
onFocus: () => setFocused(index),
onPress: () => onPick(id, entry, index)
}, entry.key)) }) })
] }),
/* @__PURE__ */ (0, jsx)(Side, { data: sideOf(id, ctx, row, t2), t2 })
] }) });
}
function UnlinkDialog({ service, t2, onConfirm, onCancel }) {
return /* @__PURE__ */ (0, jsx)(FocusScope, {
trap: true, autoFocus: true,
onBack: () => { onCancel(); return true; },
onAction: () => true,
children: /* @__PURE__ */ (0, jsx)(View, { style: { position: "absolute", top: 0, right: 0, bottom: 0, left: 0, zIndex: 50, alignItems: "center", justifyContent: "center", backgroundColor: "#000000f2" }, children: /* @__PURE__ */ (0, jsxs)(View, { style: { width: 900, gap: 24, padding: 48, borderRadius: 18, borderWidth: 2, borderColor: colors.line, backgroundColor: colors.surface }, children: [
/* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 34, fontWeight: 600, color: colors.fg, lineHeight: 46 }, children: t2("settings.unlinkTitle", { service: t2(`add.${service}Name`) }) }),
/* @__PURE__ */ (0, jsx)(Text, { style: { fontFamily: "Inter-Regular", fontSize: 21, color: colors.muted, lineHeight: 30 }, children: t2("settings.unlinkBody") }),
/* @__PURE__ */ (0, jsxs)(View, { style: { flexDirection: "row", gap: 12, paddingTop: 6 }, children: [
/* @__PURE__ */ (0, jsx)(Button2, { label: t2("settings.unlink"), focusKey: "settings:unlink:yes", autoFocus: true, onPress: onConfirm }),
/* @__PURE__ */ (0, jsx)(Button2, { label: t2("add.cancel"), variant: "outline", onPress: onCancel })
] })
] }) })
});
}
function SettingsScreen({ onBack }) {
const t2 = useT();
const values = useSettingsValues();
const { focusLater } = useNavActions();
const [page, setPage] = useState(null);
const [dialog, setDialog] = useState(null);
useHints(page ? PAGE_HINTS : HOME_HINTS);
const ctx = { values, drives: values.drives, system: systemLocale(), titleIn };
const optionKey = (id, index) => `settings:option:${id}:${index}`;
const open = (id) => {
setPage(id);
focusLater(optionKey(id, selectedIndex(id, ctx, t2)));
};
const closePage = () => {
setPage(null);
focusLater(`settings:tile:${page}`);
};
const closeDialog = (index) => {
setDialog(null);
focusLater(optionKey(page, index));
};
const fieldIndex = (field) => optionsOf(page, ctx, t2).findIndex((entry) => entry.field === field);
const pick = (id, row, index) => {
if (row.value !== void 0) {
if (id === "vibration") setVibrationLevel(row.value);
else if (id === "language") chooseLanguage(row.value === "system" ? null : row.value);
else if (id === "speed") setSpeedLimit(row.value);
else if (id === "disk") setDownloadRoot(row.value === "auto" ? null : row.value);
else if (id === "pkg") setPkgMode(row.value);
else if (id === "cleanup") setDeletePackages(row.value);
return;
}
if (row.phone) return setDialog({ type: "phone", service: row.phone, index });
if (row.unlink) return setDialog({ type: "unlink", service: row.unlink, index });
if (row.field) return setDialog({ type: "field", field: row.field, service: page, value: row.initial || "", index });
};
return /* @__PURE__ */ (0, jsxs)(View, { style: { flex: 1 }, children: [
// The tiles stay mounted under a setting's page, hidden and out of navigation: Back finds them
// as they were left, at the scroll the player had, instead of a fresh grid at the top.
/* @__PURE__ */ (0, jsx)(View, { style: page ? { flex: 1, display: "none" } : { flex: 1 }, children: /* @__PURE__ */ (0, jsx)(FocusScope, {
focusKey: "settings:home", autoFocus: true, inert: page !== null,
onBack: () => {
onBack();
return true;
},
children: /* @__PURE__ */ (0, jsx)(Home, { ctx, t2, onOpen: open })
}) }),
page ? /* @__PURE__ */ (0, jsx)(FocusScope, {
focusKey: `settings:page:${page}`, autoFocus: true, trap: true,
onBack: () => {
closePage();
return true;
},
children: /* @__PURE__ */ (0, jsx)(Detail, { id: page, ctx, t2, onPick: pick })
}) : null,
dialog && dialog.type === "phone" ? /* @__PURE__ */ (0, jsx)(PhoneSetup, {
service: dialog.service,
onField: (field) => setDialog({ type: "field", field, service: dialog.service, value: values.services[field] || "", index: Math.max(0, fieldIndex(field)) }),
onDone: () => closeDialog(dialog.index),
onCancel: () => closeDialog(dialog.index)
}) : null,
dialog && dialog.type === "field" ? /* @__PURE__ */ (0, jsx)(URLDialog, {
title: t2(`add.field.${dialog.field}`),
detail: t2(`add.fieldBody.${dialog.field}`),
initial: dialog.value,
submit: (value) => setService(dialog.field, value),
onClose: () => closeDialog(dialog.index)
}) : null,
dialog && dialog.type === "unlink" ? /* @__PURE__ */ (0, jsx)(UnlinkDialog, {
service: dialog.service,
t2,
onConfirm: () => {
setService(dialog.service, "");
// The unlink row was the last one and it is gone with the key.
closeDialog(Math.max(0, dialog.index - 1));
},
onCancel: () => closeDialog(dialog.index)
}) : null
] });
}
export { SettingsScreen };
+221
View File
@@ -0,0 +1,221 @@
// The new Settings page's content, kept apart from its layout: three groups of tiles, each tile's
// current value, the choices its own page lists and the panel that says what the focused choice
// does. Keys are i18n keys and everything else is plain data, so SettingsScreen.jsx stays markup.
//
// The groups are the PS5's order: General first, then what Downloads does, then the services a link
// or a magnet can be handed to. "Disk for downloads" shows only when there is more than one drive,
// as before (issue #103).
import { formatPercent2 } from '../i18n/core.js';
import { LOCALES, isRTL } from '../i18n/locales.js';
import { driveName } from '../state/drive-names.js';
import { LIMITS } from '../state/speed-limit.js';
import { MODES } from '../state/pkg-mode.js';
import { formatBytes } from '../state/size.js';
import { LEVELS } from './haptics.js';
var GROUPS = [
{ key: "general", title: "settings.group.general", items: ["vibration", "language"] },
{ key: "downloads", title: "settings.group.downloads", items: ["speed", "disk", "pkg", "cleanup"] },
{ key: "services", title: "settings.group.services", items: ["torbox", "realdebrid", "premiumize", "qbittorrent"] }
];
// `brand` draws the service's own logo, the same mark the Downloads page puts on its cards.
var ITEMS = {
vibration: { icon: "controller", title: "settings.vibration", lead: "settings.vibrationDetail" },
language: { icon: "languages", title: "language.title", lead: "language.subtitle" },
speed: { icon: "gauge", title: "settings.speedLimit", lead: "settings.speedLimitDetail" },
disk: { icon: "hardDrive", title: "settings.disk", lead: "settings.diskDetail" },
pkg: { icon: "package", title: "settings.pkgMode", lead: "settings.pkgModeDetail" },
cleanup: { icon: "trash", title: "settings.deletePackages", lead: "settings.deletePackagesDetail" },
torbox: { brand: "torbox", title: "add.torboxName", lead: "settings.torboxLead" },
realdebrid: { brand: "realdebrid", title: "add.realdebridName", lead: "settings.realdebridLead" },
premiumize: { brand: "premiumize", title: "add.premiumizeName", lead: "settings.premiumizeLead" },
qbittorrent: { brand: "qbittorrent", title: "add.qbittorrentName", lead: "settings.qbittorrentLead" }
};
// The services a page opens, and the two whose account is linked from the phone with a QR code
// (device-login.js); the typed key stays beside it, as on the Downloads page.
var SERVICES = ["torbox", "realdebrid", "premiumize", "qbittorrent"];
var PHONE = ["torbox", "realdebrid"];
var MODE_DETAIL = { ask: "settings.pkgAskDetail", console: "settings.pkgConsoleDetail", overdrive: "settings.pkgOverdriveDetail" };
function mask(key) {
return `••••${key.slice(-4)}`;
}
function localeName(code) {
const found = LOCALES.find((locale) => locale.code === code);
return found ? found.name : code;
}
// "50%" or "Off", the one wording the vibration step has everywhere it shows.
function levelText(t2, level) {
return level ? formatPercent2(level) : t2("settings.vibrationOff");
}
/** The value a page's radio list marks as chosen. */
function currentOf(id, values) {
switch (id) {
case "vibration": return values.vibration;
case "language": return values.choice ?? "system";
case "speed": return values.limit;
case "disk": return values.root ?? "auto";
case "pkg": return values.mode;
case "cleanup": return values.cleanup;
default: return null;
}
}
/** What the tile under a setting's name reads. */
function valueOf(id, ctx, t2) {
const values = ctx.values;
if (id === "vibration") return levelText(t2, values.vibration);
if (id === "language") return values.choice ? localeName(values.choice) : `${t2("language.system")} · ${localeName(ctx.system)}`;
if (id === "speed") return values.limit ? `${values.limit} MB/s` : t2("settings.speedUnlimited");
if (id === "disk") return values.root ? driveName(values.root) : t2("settings.diskAuto");
if (id === "pkg") return t2(`settings.pkgMode.${values.mode}`);
if (id === "cleanup") return t2(values.cleanup ? "settings.on" : "settings.off");
if (id === "qbittorrent") return values.services.address || t2("add.notSet");
if (SERVICES.includes(id)) {
const key = values.services[id];
return key ? mask(key) : t2("add.serviceNeeds");
}
return "";
}
// A row is a choice when it has `value` (the radio fills in), or an action when it has `action`
// (the icon its square carries): a phone login, a field to type, an unlink.
function serviceRows(service, values, t2) {
if (service === "qbittorrent") {
const { address, user, password } = values.services;
return [
{ key: "address", action: "globe", label: t2("add.field.address"), valueText: address || t2("add.notSet"), field: "address", initial: address },
{ key: "user", action: "controller", label: t2("add.field.user"), valueText: user || t2("add.notSet"), field: "user", initial: user },
{ key: "password", action: "controller", label: t2("add.field.password"), valueText: password ? "••••••••" : t2("add.notSet"), field: "password", initial: password }
];
}
const saved = values.services[service];
const rows = [];
if (PHONE.includes(service)) rows.push({ key: "phone", action: "link", label: t2("add.serviceLink"), phone: service });
rows.push({ key: "key", action: "file", label: t2(`add.field.${service}`), valueText: saved ? mask(saved) : t2("add.notSet"), field: service, initial: saved });
if (saved) rows.push({ key: "unlink", action: "x", label: t2("settings.unlink"), unlink: service });
return rows;
}
/** The rows a setting's page lists, top to bottom. */
function optionsOf(id, ctx, t2) {
const values = ctx.values;
if (id === "vibration") return LEVELS.map((level) => ({ key: `level:${level}`, label: levelText(t2, level), value: level }));
if (id === "language") return [
{ key: "system", label: t2("language.system"), detail: localeName(ctx.system), value: "system" },
...LOCALES.map(({ code }) => ({ key: code, label: localeName(code), detail: t2(`language.name.${code}`), value: code }))
];
if (id === "speed") return LIMITS.map((limit) => ({
key: `limit:${limit}`, value: limit,
label: limit ? `${limit} MB/s` : t2("settings.speedUnlimited"),
detail: limit ? "" : t2("settings.speedUnlimitedDetail")
}));
if (id === "disk") return [
{ key: "auto", label: t2("settings.diskAuto"), detail: t2("settings.diskAutoDetail"), value: "auto" },
...ctx.drives.map((drive) => ({
key: drive.root, value: drive.root, label: driveName(drive.root),
detail: drive.total ? t2("files.free", { free: formatBytes(drive.free), total: formatBytes(drive.total) }) : ""
}))
];
if (id === "pkg") return MODES.map((mode) => ({
key: `mode:${mode}`, value: mode, mark: mode,
label: t2(`settings.pkgMode.${mode}`),
detail: t2(MODE_DETAIL[mode]),
tag: mode === "overdrive" ? t2("settings.faster") : ""
}));
if (id === "cleanup") return [
{ key: "on", label: t2("settings.on"), value: true },
{ key: "off", label: t2("settings.off"), detail: t2("settings.cleanupOffDetail"), value: false }
];
if (SERVICES.includes(id)) return serviceRows(id, values, t2);
return [];
}
/** The row a page's focus starts on: the chosen one, or the first. */
function selectedIndex(id, ctx, t2) {
const rows = optionsOf(id, ctx, t2);
const current = currentOf(id, ctx.values);
const found = rows.findIndex((row) => row.value !== void 0 && row.value === current);
return found < 0 ? 0 : found;
}
// "34 min" / "1 h 19 min": a 50 GB game at a speed, with the source at full speed.
function downloadTime(limit, t2) {
const minutes = Math.round(50e9 / (limit * 1048576) / 60);
if (minutes < 60) return t2("settings.minutes", { n: minutes });
return t2("settings.hoursMinutes", { h: Math.floor(minutes / 60), m: String(minutes % 60).padStart(2, "0") });
}
/**
* The panel on the right of a page. Plain data: an eyebrow, a big line, rows (label, value, on),
* drives (name, a bar, free of total), a sample of the app in another language, the vibration
* preview, the marks of where a PKG goes, and the note underneath.
*/
function sideOf(id, ctx, row, t2) {
const values = ctx.values;
const chosen = row && row.value !== void 0 ? row.value : currentOf(id, values);
if (id === "vibration") return {
eyebrow: t2("settings.vibrationSide"),
preview: chosen,
small: chosen ? t2("settings.vibrationSideOn", { level: chosen }) : t2("settings.vibrationSideOff")
};
if (id === "language") {
const code = chosen === "system" ? ctx.system : chosen;
return {
eyebrow: t2("settings.languageSide"),
sample: { title: ctx.titleIn(code), name: localeName(code) },
rtl: isRTL(code),
small: isRTL(code) ? t2("settings.languageSideRtl") : t2("settings.languageSideNote")
};
}
if (id === "speed") return {
eyebrow: t2("settings.speedSide"),
rows: LIMITS.map((limit) => ({
label: limit ? `${limit} MB/s` : t2("settings.speedUnlimited"),
value: limit ? downloadTime(limit, t2) : t2("settings.speedSideBest"),
on: limit === chosen
})),
small: t2("settings.speedSideNote")
};
if (id === "disk") return {
eyebrow: t2("settings.diskSide"),
drives: ctx.drives.map((drive) => ({ name: driveName(drive.root), free: drive.free, total: drive.total, on: drive.root === chosen })),
small: chosen === "auto" ? t2("settings.diskSideAuto") : t2("settings.diskSideNote")
};
if (id === "pkg") return {
eyebrow: t2("settings.pkgSide"),
marks: chosen,
markLabel: chosen === "console" ? t2("settings.pkgSideConsole") : chosen === "overdrive" ? t2("settings.pkgSideOverdrive") : "",
big: t2(`settings.pkgMode.${chosen}`),
small: t2(MODE_DETAIL[chosen])
};
if (id === "cleanup") return {
eyebrow: t2("settings.cleanupSide"),
big: t2(chosen ? "settings.on" : "settings.off"),
small: t2(chosen ? "settings.cleanupSideOn" : "settings.cleanupSideOff")
};
if (id === "qbittorrent") {
const { address, user } = values.services;
return address
? { eyebrow: t2("add.qbittorrentName"), big: address, rows: user ? [{ label: t2("add.field.user"), value: user }] : [], small: t2("settings.qbitSideSet") }
: { eyebrow: t2("add.qbittorrentName"), big: t2("add.notSet"), small: t2("add.fieldBody.address") };
}
if (SERVICES.includes(id)) {
const key = values.services[id];
if (key) return { eyebrow: t2("settings.account"), big: t2("add.phone.linked"), small: t2("settings.serviceSideLinked") };
return {
eyebrow: PHONE.includes(id) ? t2("add.phone.title") : t2("settings.account"),
big: t2("add.serviceNeeds"),
small: PHONE.includes(id) ? t2("settings.servicePhone") : t2(`add.fieldBody.${id}`)
};
}
return {};
}
export { GROUPS, ITEMS, currentOf, localeName, optionsOf, selectedIndex, sideOf, valueOf };
+11 -7
View File
@@ -7,15 +7,18 @@ import { useNavActions, useNavState } from '../state/nav.jsx';
import { useQueryActive, useStoreActions, useStoreState } from '../state/store.jsx';
var KEYS2 = {
search: "nav:search",
discover: "nav:discover",
browse: "nav:browse",
homebrew: "nav:homebrew",
search: "nav:search",
favorites: "nav:favorites",
downloads: "nav:downloads",
files: "nav:files"
files: "nav:files",
settings: "nav:settings"
};
var TABS = ["search", "discover", "browse", "homebrew", "favorites", "downloads", "files"];
var TABS = ["discover", "browse", "homebrew", "search", "favorites", "downloads", "files", "settings"];
// Sections that are their own screen rather than a view of the library.
var SCREENS = ["homebrew", "search", "favorites", "downloads", "files", "settings"];
// The tab tracks the navigation entry that opened the library until its filters or sort change.
var pinned = { section: "discover", filters: NO_FILTERS, sort: "newest" };
function useShowLibrary() {
@@ -42,8 +45,8 @@ import { useQueryActive, useStoreActions, useStoreState } from '../state/store.j
const nav = useNavActions();
const { top } = useNavState();
const show = useShowLibrary();
const underneath = top.name === "downloads" || top.name === "files" || top.name === "homebrew" ? top.name : sectionOf(store, queryActive);
const section = top.name === "search" ? "search" : top.name === "game" ? null : underneath;
const underneath = SCREENS.includes(top.name) ? top.name : sectionOf(store, queryActive);
const section = top.name === "game" ? null : underneath;
const topName = (0, useRef)(top.name);
topName.current = top.name;
const [on] = (0, useState)(() => ({
@@ -52,12 +55,13 @@ import { useQueryActive, useStoreActions, useStoreState } from '../state/store.j
discover: () => show(NO_FILTERS, "newest", "spotlight:cta", "discover"),
browse: () => show(NO_FILTERS, "newest", void 0, "browse"),
homebrew: () => topName.current !== "homebrew" && nav.push({ name: "homebrew" }, "nav:homebrew"),
favorites: () => show({ ...NO_FILTERS, wishlist: true }, "newest", void 0, "favorites"),
favorites: () => topName.current !== "favorites" && nav.push({ name: "favorites" }, "nav:favorites"),
settings: () => topName.current !== "settings" && nav.push({ name: "settings" }, "nav:settings"),
downloads: () => topName.current !== "downloads" && nav.push({ name: "downloads" }, "nav:downloads"),
files: () => topName.current !== "files" && nav.push({ name: "files" }, "nav:files")
}));
const step = (forward) => {
const current = top.name === "search" ? "search" : underneath;
const current = underneath;
const next = TABS[TABS.indexOf(current) + (forward ? 1 : -1)];
if (!next) return;
if (next === "downloads" || next === "files" || next === "homebrew") {
+1 -1
View File
@@ -2,6 +2,6 @@
// reconstructed from the esbuild bundle in eboot.bin
var VERSION = "1.0.9";
var VERSION = "1.0.10";
export { VERSION };
+16 -1
View File
@@ -16,6 +16,14 @@
"dev Same window, rebuilt and restarted on every save under app/. State resets.",
"test The framework's checks; run after changing rendering or input in ps5-react/.",
"",
"macOS only: setup:mac creates ps5-react/.venv with Pillow; preview:mac, dev:mac and",
" doctor:mac run through that venv, with LLVM 18, Node and the MacPorts/SDL2",
" libraries when present. The commands above stay unchanged for other hosts.",
"build:mac The PS5 app only, through that venv, into ps5-react/dist/PPSA99058. No sudo.",
"package:mac Local only: builds Overdrive.exfat from the app above with newfs_exfat/hdiutil.",
" It writes no update.zip; the CI keeps producing that on Linux.",
"image:mac build:mac, then package:mac.",
"",
"CC=clang CXX=clang++: the preview is compiled with clang on every platform, since GCC",
"stops on a bounds warning in archive_preflight.cpp."
],
@@ -25,6 +33,13 @@
"build:app": "bash packaging/build.sh --no-package",
"preview": "cd ps5-react && CC=clang CXX=clang++ python3 tools/cli.py preview --app-dir ../app",
"dev": "cd ps5-react && CC=clang CXX=clang++ python3 tools/cli.py preview --app-dir ../app --watch",
"test": "cd ps5-react && CC=clang CXX=clang++ npm test"
"test": "cd ps5-react && CC=clang CXX=clang++ npm test",
"setup:mac": "[ -x ps5-react/.venv/bin/python ] || python3 -m venv ps5-react/.venv; ps5-react/.venv/bin/pip install -r ps5-react/requirements.txt",
"doctor:mac": "cd ps5-react && sh tools/mac-run.sh doctor",
"preview:mac": "cd ps5-react && CC=clang CXX=clang++ sh tools/mac-run.sh preview --app-dir ../app",
"dev:mac": "cd ps5-react && CC=clang CXX=clang++ sh tools/mac-run.sh preview --app-dir ../app --watch",
"build:mac": "bash packaging/build-mac.sh",
"package:mac": "bash packaging/make-exfat-mac.sh ps5-react/dist/PPSA99058 Overdrive.exfat OVERDRIVE",
"image:mac": "npm run build:mac && npm run package:mac"
}
}
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Builds the PS5 app on macOS.
#
# Uses ps5-react/.venv for Pillow (verify.py imports it and Homebrew's python3
# is externally managed), LLVM 18 from Homebrew, and ninja from Homebrew. The
# desktop preview's MacPorts/SDL2 environment does not apply here.
#
# Packaging is a separate, local-only step: packaging/make-exfat-mac.sh.
set -euo pipefail
root="$(cd "$(dirname "$0")/.." && pwd)"
venv="$root/ps5-react/.venv"
if [ ! -x "$venv/bin/python" ]; then
echo "error: $venv is missing; run 'npm run setup:mac' from the repository root." >&2
exit 1
fi
llvm="${LLVM:-}"
if [ -z "$llvm" ]; then
for candidate in /usr/local/opt/llvm@18/bin /opt/homebrew/opt/llvm@18/bin; do
if [ -x "$candidate/clang" ]; then llvm=$candidate; break; fi
done
fi
if [ -z "$llvm" ] || [ ! -x "$llvm/clang" ]; then
echo "error: LLVM 18 not found. Install it with 'brew install llvm@18' or set LLVM=<its bin directory>." >&2
exit 1
fi
PATH="$venv/bin:$llvm:/opt/local/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
export PATH
# The PS5 build does not link the desktop's libraries, but a MacPorts setup keeps
# CMake away from a mixed Homebrew/MacPorts prefix when one is present.
prefixes=
if [ -d "$HOME/.local/overdrive-sdl2" ]; then
prefixes=$HOME/.local/overdrive-sdl2
fi
if [ -x /opt/local/bin/cmake ]; then
prefixes=${prefixes:+$prefixes:}/opt/local
fi
if [ -n "$prefixes" ]; then
export CMAKE_PREFIX_PATH="$prefixes${CMAKE_PREFIX_PATH:+:$CMAKE_PREFIX_PATH}"
fi
exec bash "$root/packaging/build.sh" --no-package
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env bash
# Packages a built PS5 app folder as the exFAT image ShadowMount+ installs, on
# macOS. Local only: CI keeps using make-exfat.sh on Linux, which also writes
# update.zip. This script writes the image alone.
#
# usage: make-exfat-mac.sh <app-folder> <out.exfat> [label] [cluster-size]
set -euo pipefail
app="${1:-}"
out="${2:-}"
label="${3:-OVERDRIVE}"
cluster="${4:-64K}"
if [ -z "$app" ] || [ -z "$out" ]; then
echo "usage: $0 <app-folder> <out.exfat> [label] [cluster-size]" >&2
exit 2
fi
[ -d "$app" ] || { echo "error: no such app folder: $app" >&2; exit 1; }
[ -f "$app/eboot.bin" ] || { echo "error: $app has no eboot.bin, it is not a built PS5 app" >&2; exit 1; }
for tool in hdiutil newfs_exfat fsck_exfat; do
command -v "$tool" >/dev/null || { echo "error: $tool not found; this script is macOS only" >&2; exit 1; }
done
case "$cluster" in
32K|32k) cluster_bytes=32768 ;;
64K|64k) cluster_bytes=65536 ;;
128K|128k) cluster_bytes=131072 ;;
*) echo "error: cluster size must be 32K, 64K or 128K" >&2; exit 2 ;;
esac
mount_point="/Volumes/$label"
if mount | grep -F " on $mount_point " >/dev/null; then
echo "error: $mount_point is already mounted; unmount it first" >&2
exit 1
fi
# Size the image from what exFAT actually consumes: every file takes whole
# clusters, and the app ships thousands of small JSON files, so the payload's
# apparent size underestimates the volume. This is the same formula as
# make-exfat.sh, with macOS's stat instead of GNU find -printf.
slack=$(( ${EXFAT_SLACK_MB:-128} * 1024 * 1024 ))
clusters=$(find "$app" -type f -exec stat -f%z {} + |
awk -v c="$cluster_bytes" '{ n += int(($1 + c - 1) / c) } END { print n + 0 }')
directories=$(find "$app" -type d | wc -l | tr -d ' ')
payload=$(( (clusters + directories) * cluster_bytes ))
size=$(( (payload + slack + 1048575) / 1048576 * 1048576 ))
printf '→ exFAT: %s MiB, %s clusters, label %s\n' "$((size / 1048576))" "$cluster" "$label"
if [ -e "$out" ]; then
echo "→ replacing $out"
fi
rm -f "$out"
truncate -s "$size" "$out"
dev=
mounted=0
cleanup() {
if [ "$mounted" = 1 ]; then
hdiutil detach "$mount_point" >/dev/null 2>&1 ||
hdiutil detach -force "$mount_point" >/dev/null 2>&1 || true
fi
if [ -n "$dev" ]; then
hdiutil detach "$dev" >/dev/null 2>&1 || true
fi
}
trap cleanup EXIT
dev=$(hdiutil attach -imagekey diskimage-class=CRawDiskImage -nomount "$out" |
awk '/^\/dev\/disk/{print $1; exit}')
[ -n "$dev" ] || { echo "error: hdiutil could not attach $out" >&2; exit 1; }
newfs_exfat -R -v "$label" -b "$cluster_bytes" "$dev" >/dev/null
hdiutil detach "$dev" >/dev/null
dev=
attach=$(hdiutil attach -imagekey diskimage-class=CRawDiskImage -nobrowse "$out")
dev=$(printf '%s\n' "$attach" | awk '$3 ~ /^\/Volumes\// {print $1; exit}')
[ -n "$dev" ] || dev=$(printf '%s\n' "$attach" | awk '/^\/dev\/disk/{print $1; exit}')
for _ in 1 2 3 4 5 6 7 8 9 10; do
[ -d "$mount_point" ] && break
sleep 1
done
[ -d "$mount_point" ] || { echo "error: $mount_point did not mount" >&2; exit 1; }
mounted=1
cp -R "$app/." "$mount_point/"
# Finder and Spotlight may add their own directories; they are not part of the app.
rm -rf "$mount_point/.Spotlight-V100" "$mount_point/.fseventsd" "$mount_point/.Trashes"
src=$(find "$app" -type f | wc -l | tr -d ' ')
dst=$(find "$mount_point" -type f \
-not -path "$mount_point/.Spotlight-V100/*" \
-not -path "$mount_point/.fseventsd/*" \
-not -path "$mount_point/.Trashes/*" | wc -l | tr -d ' ')
[ "$src" = "$dst" ] || { echo "error: wrote $dst files, the app has $src" >&2; exit 1; }
sync
hdiutil detach "$mount_point" >/dev/null || hdiutil detach -force "$mount_point" >/dev/null
mounted=0
dev=
# Check the finished image itself: its filesystem, and the app's entry point.
dev=$(hdiutil attach -imagekey diskimage-class=CRawDiskImage -nomount "$out" |
awk '/^\/dev\/disk/{print $1; exit}')
[ -n "$dev" ] || { echo "error: hdiutil could not reattach $out" >&2; exit 1; }
fsck_exfat -n "$dev" >/dev/null
hdiutil detach "$dev" >/dev/null
dev=
echo "wrote $out ($(stat -f%z "$out") bytes, $src files)"
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# Single-instance preview launcher for Overdrive.
# Invoked by both npm run preview and npm run dev. Keep all source/UI changes
# intact, only terminate stale Overdrive GUI instances and old live watchers.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
python3 - "$ROOT" <<'PY'
from pathlib import Path
import os
import signal
import sys
import time
root = Path(sys.argv[1]).resolve()
preview_binary = root / 'ps5-react/.build/app/desktop/ps5-react-preview'
ps5_dir = root / 'ps5-react'
own = os.getpid()
found = []
# Process discovery reads /proc, which only Linux has: on macOS and Windows the
# script starts the preview directly instead of failing before it launches.
if not Path('/proc').is_dir():
print('Old previews not checked: this system has no /proc', flush=True)
sys.exit(0)
for entry in Path('/proc').iterdir():
if not entry.name.isdigit():
continue
pid = int(entry.name)
if pid == own:
continue
try:
args = (entry / 'cmdline').read_bytes().split(b'\0')
args = [a.decode(errors='replace') for a in args if a]
if not args:
continue
name = Path(args[0]).name
# Only touch this Overdrive checkout's own GUI preview executable.
own_preview = (name == 'ps5-react-preview' and
Path(args[0]).resolve() == preview_binary)
# A leftover --watch process would launch a second window when a
# source file changes, so it must be closed as well.
old_watcher = ('cli.py' in ' '.join(args) and
'preview' in args and '--watch' in args and
(entry / 'cwd').resolve() == ps5_dir)
if own_preview or old_watcher:
found.append((pid, 'preview' if own_preview else 'watcher'))
except (FileNotFoundError, ProcessLookupError, PermissionError, OSError):
pass
for pid, kind in found:
try:
os.kill(pid, signal.SIGTERM)
print(f'Closing old Overdrive {kind}: PID {pid}', flush=True)
except ProcessLookupError:
pass
def alive(pid):
try:
stat = Path(f'/proc/{pid}/stat').read_text()
return stat.rsplit(')', 1)[1].strip().split()[0] not in ('Z', 'X')
except (FileNotFoundError, ProcessLookupError):
return False
for _ in range(40):
if not any(alive(pid) for pid, _ in found):
break
time.sleep(0.1)
for pid, kind in found:
if alive(pid):
try:
os.kill(pid, signal.SIGKILL)
print(f'Closed unresponsive old {kind}: PID {pid}', flush=True)
except ProcessLookupError:
pass
print(f'Previous Overdrive previews closed: {len(found)}', flush=True)
PY
cd "$ROOT/ps5-react"
exec env CC=clang CXX=clang++ python3 tools/cli.py preview --app-dir ../app "$@"
+18 -5
View File
@@ -8,7 +8,8 @@
# preview (SDL2, OpenGL, curl, libarchive, OpenSSL, clang), the PS5 build
# (LLVM 18, ninja, nasm, exfatprogs) and the tests (bsdtar), then Pillow for
# the asset baking and the framework's npm packages.
# macOS (Homebrew): the same through brew and pip.
# macOS (Homebrew): the same through brew, plus a ps5-react/.venv for Pillow
# (Homebrew's python3 refuses a global pip install).
set -euo pipefail
root="$(cd "$(dirname "$0")/.." && pwd)"
@@ -28,8 +29,10 @@ Darwin)
command -v brew >/dev/null || { echo "error: Homebrew is needed: https://brew.sh" >&2; exit 1; }
echo "→ Homebrew formulae"
brew install cmake sdl2 llvm@18 nasm ninja curl libarchive xz openssl@3 python node
echo "→ Pillow"
python3 -m pip install -r "$root/ps5-react/requirements.txt"
echo "→ Python environment"
[ -x "$root/ps5-react/.venv/bin/python" ] || python3 -m venv "$root/ps5-react/.venv"
"$root/ps5-react/.venv/bin/pip" install --disable-pip-version-check \
-r "$root/ps5-react/requirements.txt"
;;
*)
echo "error: unsupported OS $(uname -s)" >&2
@@ -41,10 +44,20 @@ echo "→ framework npm packages"
(cd "$root/ps5-react" && npm ci)
echo "→ checking"
python3 -c "import PIL; print('Pillow', PIL.__version__)"
for tool in cmake clang ninja nasm node mkfs.exfat; do
case "$(uname -s)" in
Darwin)
"$root/ps5-react/.venv/bin/python" -c "import PIL; print('Pillow', PIL.__version__)"
tools="cmake clang ninja nasm node newfs_exfat hdiutil"
;;
*)
python3 -c "import PIL; print('Pillow', PIL.__version__)"
tools="cmake clang ninja nasm node mkfs.exfat"
;;
esac
for tool in $tools; do
printf '%-10s %s\n' "$tool" "$(command -v "$tool" || echo MISSING)"
done
echo
echo "Ready. Next: npm run preview (desktop window), npm run build (Overdrive.exfat)."
echo "On macOS the :mac variants (preview:mac, dev:mac, build:mac) use ps5-react/.venv."
echo "The first preview and the first build download and compile the pinned dependencies; that takes a while."
+40
View File
@@ -1,5 +1,45 @@
# Changelog
## Unreleased — package links served by the app
- `Packages.proxy({url, total, file, token, port})` (ABI v21) serves `url` for the console's
download queue on the console's LAN address (and on `127.0.0.1`), behind a 256-bit token. The
console asks a link with a `HEAD` first, and a pre-signed S3 or R2 link answers it 403 (it signs
only the `GET`), so the console refused it with `0x80B22403`. The app answers the `HEAD` from the
size it read and fetches each `GET` range from the origin. The link works while the app runs; a
later session serves the same URL again from its token and port. `Packages.proxyStats()` reports
what the console fetched of each link, `Packages.proxyRemove(token)` forgets one.
- `Packages.startHelper({port, secret, log})` runs that server as `pkg-helper.elf`, a payload sent
to the loader on 9021 that outlives the app, controlled over HTTP from 127.0.0.1 only: the
console's own Downloads keep fetching a package after the app has closed. A checkpoint's identity no longer includes the storage root's device and
inode, which a USB drive changes at every boot; a checkpoint written with the old identity still
resumes.
## Unreleased — debrid download nodes
- A download whose link is a debrid service's CDN (`tb-cdn.io`, `real-debrid.com`, `premiumize.me`)
gets eight connections at most, whatever the app asked for, and honours the server's `Retry-After`
in full (up to five minutes rather than one). Those nodes count connections per account and shed
the rest with HTTP 429; a probe answered 429 is retried with the same wait, and every attempt is
logged with the node, the status and the wait it asked for. Every other host downloads exactly as
before.
## Unreleased — packages installed from a link
- `Packages.install({url, name, contentId})` (ABI v20) hands an http(s) link to the console's own
download queue: the console fetches and installs the package in the background, with the app
closed too and no copy on a drive first. A PS4 package needs its content ID, which the app reads
from the header with a ranged request; a PS5 one goes without. `pkg-installer.elf` speaks `PKI2`
(a fourth line, the content ID) for paths and links alike.
## Unreleased — certificates for old firmwares
- Networking apps ship Mozilla's root certificates (curl's `cacert.pem`, pinned as `caBundle`). At
start the PS5 host writes the console's own store plus the bundle into one file in the cache and
hands curl that path, so sites whose roots an old firmware never received (4.03: GitHub, image
hosts) verify. Any failure keeps the system store alone, as before; trust is only added.
`python3 tools/ca_bundle.py` pins the newest copy after checking that OpenSSL loads every certificate.
## Unreleased — requests beside a download
- `Http.request` (and every task without a file destination) runs on a network thread of its own,
+5
View File
@@ -156,5 +156,10 @@
"patches/ffmpeg-mov-hls-seek.patch",
"patches/ffmpeg-hls-init-section.patch"
]
},
"caBundle": {
"version": "2026-10-20",
"url": "https://curl.se/ca/cacert-2026-10-20.pem",
"sha256": "796f1389bfea13c0ed78f663b765baaff54a508e5483786bd6ece73c5e3e3cbe"
}
}
+1
View File
@@ -11,6 +11,7 @@ No proprietary Sony modules are redistributed.
| ProsperoStore elevation client | Optional cooperative filesystem-access client and pinned helper build. GPL-3.0-or-later; revision `22dca63607cf5f1f95006eda6ffb802607bed699`. |
| PacBrew archive ports | Same hash-pinned v0.40.2 distribution: libarchive 3.7.4 (BSD), liblzma 5.4.6 (primarily public domain), bzip2 (bzip2 license), plus zlib/zstd/OpenSSL. Exact notice sources and hashes are in `licenses/archives-SOURCES.json`. |
| PacBrew networking ports | Optional SHA-256-pinned v0.40.2 archive: curl 8.18.0 (curl license), OpenSSL 3.5.2 (Apache-2.0), zlib, zstd and libpsl; individual notices and source links follow the pinned ProsperoStore `third_party/PACBREW_LICENSES.json`. Native-title `compat.c` and `netdb.c` reuse that pinned reference under GPL-3.0-or-later. |
| Mozilla CA certificates | The root store curl publishes as `cacert-<date>.pem` (MPL-2.0), SHA-256-pinned (`caBundle` in the lock file; `tools/ca_bundle.py` pins the newest dated copy and checks it). Networking apps ship it as `certs/cacert.pem`; at start the console merges it with its own store, which old firmwares let fall behind. Builds copy `licenses/MPL-2.0.txt` to `notices/cacert-MPL-2.0.txt`. |
| Lapy helper | Optional exact-title one-shot helper: mpereiraesaa/PS5-Lapy-JB-Daemon `54a095c0f19161825e845daa760a03b446e654fa`, MIT; protocol LGPL-2.1-or-later, ps5log GPL-3.0-or-later. The helper uses the app SDK v0.42 with `patches/lapy-sdk-042-attributes.patch`; source revision and ps5log hash are pinned in the lock file. |
| ps5-opengl | SDK 1.0.0; GPL and per-component/Mesa licenses. The release includes sources and notices. Its repository (github.com/blackbearreloaded/ps5-opengl) is gone; the same archive, checked by its SHA-256, is kept at [MyLuxy/overdrive-deps](https://github.com/MyLuxy/overdrive-deps/releases/tag/ps5-opengl-v1.0.0). |
| React / QuickJS-ng | React 18.3.1, reconciler 0.29.2, QuickJS-ng 0.15.0; MIT. npm lockfile and QuickJS commit are pinned. |
+39
View File
@@ -542,6 +542,45 @@ stops reporting only: the console finishes an install it accepted. The preview
fails each request. Fake packages need a kernel that accepts them (kstuff); on
firmware above 11.60, PS5 fake game packages install but do not launch.
`Packages.install({url, name, contentId})` hands an `http://` or `https://` link
to the console's own download queue instead: the console fetches and installs
the package in the background, also once the app has closed, with no copy on a
drive first. The console sends no cookies or headers of the app's, so only a
link that works on its own fits. A PS4 package needs its `contentId` (36
characters at offset 0x40 of its header, which the app reads with a ranged
request); a PS5 one goes without. Its progress shows in the console's
Downloads; `cancel()` stops reporting here only.
`Packages.proxy({url, total, file, token, port})` (ABI v21) serves `url` for
the console's queue and returns `{url, localUrl, token, port}`: `url` is
`http://<LAN address>:<port>/<token>/<file>.pkg` (empty when the console has no
route out) and `localUrl` the same on `127.0.0.1`. The console asks a link with
a `HEAD` before it fetches, and a pre-signed S3 or R2 link signs only the `GET`
it was made for: the origin answers that `HEAD` 403 and the console refuses the
package (`0x80B22403`). The app answers the `HEAD` from `total`, the size it read
with a ranged `GET`, and fetches each `GET` range the console asks from `url`,
with the same transport as downloads. The server listens on every interface, so
each link carries a 256-bit token. Answers carry `Content-Type: application/pkg`,
`Accept-Ranges: bytes` and `Cache-Control: no-store`; past 16 connections the
console gets `503` with `Retry-After: 1`. The link works only while the app
runs. A later session passes the `token` and `port` it got back to serve the same
URL again, which the console's queue reconnects to; a taken port is logged and
replaced. `Packages.proxyStats()` returns `{token, total, delivered, requests,
idleMs}` for each link (`delivered` counts body bytes sent, a range asked twice
twice) and `Packages.proxyRemove(token)` forgets one. Its first requests and any
origin error are logged as `pkg-proxy:` lines.
`Packages.startHelper({port, secret, log})` (ABI v21, PS5 only) runs the same
server as a payload of its own, `pkg-helper.elf`, sent to the loader on
`127.0.0.1:9021`, so the console's queue keeps fetching after the app closes. It
resolves to `{port}`; the app then adds links to the helper and reads them over
HTTP at `http://127.0.0.1:<port>/control/<secret>/` (`ping`, `stats`, `add`,
`remove`; see `native/shared/pkg_proxy.hpp`), which answers only to the console
itself. A later session reaches the same helper on the same port and secret. The
helper writes `log`, uses the app's certificate store, resolves names over
DNS-over-HTTPS, and ends once every link was fetched whole and nothing asked for
ten minutes, or nothing asked for an hour.
## Archive extraction
```js
+373
View File
@@ -0,0 +1,373 @@
Mozilla Public License Version 2.0
==================================
1. Definitions
--------------
1.1. "Contributor"
means each individual or legal entity that creates, contributes to
the creation of, or owns Covered Software.
1.2. "Contributor Version"
means the combination of the Contributions of others (if any) used
by a Contributor and that particular Contributor's Contribution.
1.3. "Contribution"
means Covered Software of a particular Contributor.
1.4. "Covered Software"
means Source Code Form to which the initial Contributor has attached
the notice in Exhibit A, the Executable Form of such Source Code
Form, and Modifications of such Source Code Form, in each case
including portions thereof.
1.5. "Incompatible With Secondary Licenses"
means
(a) that the initial Contributor has attached the notice described
in Exhibit B to the Covered Software; or
(b) that the Covered Software was made available under the terms of
version 1.1 or earlier of the License, but not also under the
terms of a Secondary License.
1.6. "Executable Form"
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in
a separate file or files, that is not Covered Software.
1.8. "License"
means this document.
1.9. "Licensable"
means having the right to grant, to the maximum extent possible,
whether at the time of the initial grant or subsequently, any and
all of the rights conveyed by this License.
1.10. "Modifications"
means any of the following:
(a) any file in Source Code Form that results from an addition to,
deletion from, or modification of the contents of Covered
Software; or
(b) any new file in Source Code Form that contains any Covered
Software.
1.11. "Patent Claims" of a Contributor
means any patent claim(s), including without limitation, method,
process, and apparatus claims, in any patent Licensable by such
Contributor that would be infringed, but for the grant of the
License, by the making, using, selling, offering for sale, having
made, import, or transfer of either its Contributions or its
Contributor Version.
1.12. "Secondary License"
means either the GNU General Public License, Version 2.0, the GNU
Lesser General Public License, Version 2.1, the GNU Affero General
Public License, Version 3.0, or any later versions of those
licenses.
1.13. "Source Code Form"
means the form of the work preferred for making modifications.
1.14. "You" (or "Your")
means an individual or a legal entity exercising rights under this
License. For legal entities, "You" includes any entity that
controls, is controlled by, or is under common control with You. For
purposes of this definition, "control" means (a) the power, direct
or indirect, to cause the direction or management of such entity,
whether by contract or otherwise, or (b) ownership of more than
fifty percent (50%) of the outstanding shares or beneficial
ownership of such entity.
2. License Grants and Conditions
--------------------------------
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
(a) under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or
as part of a Larger Work; and
(b) under Patent Claims of such Contributor to make, use, sell, offer
for sale, have made, import, and otherwise transfer either its
Contributions or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution
become effective for each Contribution on the date the Contributor first
distributes such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under
this License. No additional rights or licenses will be implied from the
distribution or licensing of Covered Software under this License.
Notwithstanding Section 2.1(b) above, no patent license is granted by a
Contributor:
(a) for any code that a Contributor has removed from Covered Software;
or
(b) for infringements caused by: (i) Your and any other third party's
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
(c) under Patent Claims infringed by Covered Software in the absence of
its Contributions.
This License does not grant any rights in the trademarks, service marks,
or logos of any Contributor (except as may be necessary to comply with
the notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this
License (see Section 10.2) or under the terms of a Secondary License (if
permitted under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its
Contributions are its original creation(s) or it has sufficient rights
to grant the rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under
applicable copyright doctrines of fair use, fair dealing, or other
equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
in Section 2.1.
3. Responsibilities
-------------------
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under
the terms of this License. You must inform recipients that the Source
Code Form of the Covered Software is governed by the terms of this
License, and how they can obtain a copy of this License. You may not
attempt to alter or restrict the recipients' rights in the Source Code
Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
(a) such Covered Software must also be made available in Source Code
Form, as described in Section 3.1, and You must inform recipients of
the Executable Form how they can obtain a copy of such Source Code
Form by reasonable means in a timely manner, at a charge no more
than the cost of distribution to the recipient; and
(b) You may distribute such Executable Form under the terms of this
License, or sublicense it under different terms, provided that the
license for the Executable Form does not attempt to limit or alter
the recipients' rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for
the Covered Software. If the Larger Work is a combination of Covered
Software with a work governed by one or more Secondary Licenses, and the
Covered Software is not Incompatible With Secondary Licenses, this
License permits You to additionally distribute such Covered Software
under the terms of such Secondary License(s), so that the recipient of
the Larger Work may, at their option, further distribute the Covered
Software under the terms of either this License or such Secondary
License(s).
3.4. Notices
You may not remove or alter the substance of any license notices
(including copyright notices, patent notices, disclaimers of warranty,
or limitations of liability) contained within the Source Code Form of
the Covered Software, except that You may alter any license notices to
the extent required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on
behalf of any Contributor. You must make it absolutely clear that any
such warranty, support, indemnity, or liability obligation is offered by
You alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
---------------------------------------------------
If it is impossible for You to comply with any of the terms of this
License with respect to some or all of the Covered Software due to
statute, judicial order, or regulation then You must: (a) comply with
the terms of this License to the maximum extent possible; and (b)
describe the limitations and the code they affect. Such description must
be placed in a text file included with all distributions of the Covered
Software under this License. Except to the extent prohibited by statute
or regulation, such description must be sufficiently detailed for a
recipient of ordinary skill to be able to understand it.
5. Termination
--------------
5.1. The rights granted under this License will terminate automatically
if You fail to comply with any of its terms. However, if You become
compliant, then the rights granted under this License from a particular
Contributor are reinstated (a) provisionally, unless and until such
Contributor explicitly and finally terminates Your grants, and (b) on an
ongoing basis, if such Contributor fails to notify You of the
non-compliance by some reasonable means prior to 60 days after You have
come back into compliance. Moreover, Your grants from a particular
Contributor are reinstated on an ongoing basis if such Contributor
notifies You of the non-compliance by some reasonable means, this is the
first time You have received notice of non-compliance with this License
from such Contributor, and You become compliant prior to 30 days after
Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions,
counter-claims, and cross-claims) alleging that a Contributor Version
directly or indirectly infringes any patent, then the rights granted to
You by any and all Contributors for the Covered Software under Section
2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
end user license agreements (excluding distributors and resellers) which
have been validly granted by You or Your distributors under this License
prior to termination shall survive termination.
************************************************************************
* *
* 6. Disclaimer of Warranty *
* ------------------------- *
* *
* Covered Software is provided under this License on an "as is" *
* basis, without warranty of any kind, either expressed, implied, or *
* statutory, including, without limitation, warranties that the *
* Covered Software is free of defects, merchantable, fit for a *
* particular purpose or non-infringing. The entire risk as to the *
* quality and performance of the Covered Software is with You. *
* Should any Covered Software prove defective in any respect, You *
* (not any Contributor) assume the cost of any necessary servicing, *
* repair, or correction. This disclaimer of warranty constitutes an *
* essential part of this License. No use of any Covered Software is *
* authorized under this License except under this disclaimer. *
* *
************************************************************************
************************************************************************
* *
* 7. Limitation of Liability *
* -------------------------- *
* *
* Under no circumstances and under no legal theory, whether tort *
* (including negligence), contract, or otherwise, shall any *
* Contributor, or anyone who distributes Covered Software as *
* permitted above, be liable to You for any direct, indirect, *
* special, incidental, or consequential damages of any character *
* including, without limitation, damages for lost profits, loss of *
* goodwill, work stoppage, computer failure or malfunction, or any *
* and all other commercial damages or losses, even if such party *
* shall have been informed of the possibility of such damages. This *
* limitation of liability shall not apply to liability for death or *
* personal injury resulting from such party's negligence to the *
* extent applicable law prohibits such limitation. Some *
* jurisdictions do not allow the exclusion or limitation of *
* incidental or consequential damages, so this exclusion and *
* limitation may not apply to You. *
* *
************************************************************************
8. Litigation
-------------
Any litigation relating to this License may be brought only in the
courts of a jurisdiction where the defendant maintains its principal
place of business and such litigation shall be governed by laws of that
jurisdiction, without reference to its conflict-of-law provisions.
Nothing in this Section shall prevent a party's ability to bring
cross-claims or counter-claims.
9. Miscellaneous
----------------
This License represents the complete agreement concerning the subject
matter hereof. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent
necessary to make it enforceable. Any law or regulation which provides
that the language of a contract shall be construed against the drafter
shall not be used to construe this License against a Contributor.
10. Versions of the License
---------------------------
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version
of the License under which You originally received the Covered Software,
or under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a
modified version of this License if you rename the license and remove
any references to the name of the license steward (except to note that
such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary
Licenses
If You choose to distribute Source Code Form that is Incompatible With
Secondary Licenses under the terms of this version of the License, the
notice described in Exhibit B of this License must be attached.
Exhibit A - Source Code Form License Notice
-------------------------------------------
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at https://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular
file, then You may include the notice in a location (such as a LICENSE
file in a relevant directory) where a recipient would be likely to look
for such a notice.
You may add additional accurate notices of copyright ownership.
Exhibit B - "Incompatible With Secondary Licenses" Notice
---------------------------------------------------------
This Source Code Form is "Incompatible With Secondary Licenses", as
defined by the Mozilla Public License, v. 2.0.
+1 -1
View File
@@ -41,7 +41,7 @@ target_compile_options(sheenbidi PRIVATE -w)
target_include_directories(sheenbidi PUBLIC "${SB_ROOT}/Headers" PRIVATE "${SB_ROOT}/Source")
# Same engine options on desktop and PS5, rather than the upstream demo's profile.
add_subdirectory("${CMAKE_CURRENT_SOURCE_DIR}/../ps5" runtime)
add_executable(ps5-react-preview host.cpp host_platform.cpp network_platform.cpp ../shared/network.cpp ../shared/network_api.cpp ../shared/archives.cpp ../shared/archive_writer.cpp rar_worker_process.cpp ../shared/archive_preflight.cpp ../shared/rar5_password.cpp ../shared/archive_api.cpp ../shared/package_api.cpp package_installer.cpp ../shared/browser_capture_api.cpp browser_capture.cpp ../shared/lan.cpp ../shared/lan_api.cpp ../shared/image_loader.cpp ../shared/image_api.cpp ../shared/sound_api.cpp ../shared/audio_sink.cpp ../shared/audio_output_api.cpp ../shared/host_api.cpp ../shared/removals.cpp "${HUI_ROOT}/src/audio/mixer.cpp" ../shared/gl_presenter.cpp ../shared/frame_stats.cpp ../shared/js_heap.cpp ../shared/js_profiler.cpp ../shared/damage_tracker.cpp ../shared/render_workers.cpp ../shared/input_script.cpp ../shared/screenshot.cpp ../shared/text_shaper.cpp ../shared/video_player.cpp ../shared/video_subtitles.cpp ../shared/video_api.cpp ../shared/gl_video.cpp ../shared/gl_upscale.cpp video_decoder.cpp control.cpp "${APP_LAYOUT}/assets.generated.c" "${APP_LAYOUT}/sounds.generated.c")
add_executable(ps5-react-preview host.cpp host_platform.cpp network_platform.cpp ../shared/network.cpp ../shared/network_api.cpp ../shared/archives.cpp ../shared/archive_writer.cpp rar_worker_process.cpp ../shared/archive_preflight.cpp ../shared/rar5_password.cpp ../shared/archive_api.cpp ../shared/package_api.cpp ../shared/pkg_proxy.cpp package_installer.cpp ../shared/browser_capture_api.cpp browser_capture.cpp ../shared/lan.cpp ../shared/lan_api.cpp ../shared/image_loader.cpp ../shared/image_api.cpp ../shared/sound_api.cpp ../shared/audio_sink.cpp ../shared/audio_output_api.cpp ../shared/host_api.cpp ../shared/removals.cpp "${HUI_ROOT}/src/audio/mixer.cpp" ../shared/gl_presenter.cpp ../shared/frame_stats.cpp ../shared/js_heap.cpp ../shared/js_profiler.cpp ../shared/damage_tracker.cpp ../shared/render_workers.cpp ../shared/input_script.cpp ../shared/screenshot.cpp ../shared/text_shaper.cpp ../shared/video_player.cpp ../shared/video_subtitles.cpp ../shared/video_api.cpp ../shared/gl_video.cpp ../shared/gl_upscale.cpp video_decoder.cpp control.cpp "${APP_LAYOUT}/assets.generated.c" "${APP_LAYOUT}/sounds.generated.c")
target_compile_features(ps5-react-preview PRIVATE cxx_std_20)
target_compile_options(ps5-react-preview PRIVATE -Wall -Wextra -Werror)
target_include_directories(ps5-react-preview PRIVATE ../shared "${APP_GENERATED}" "${STB_IMAGE_DIR}" "${HUI_ROOT}/src" "${ER_ROOT}/engine/text")
+6 -1
View File
@@ -5,6 +5,7 @@
#include "image_loader.hpp"
#include "archives.hpp"
#include "packages.hpp"
#include "pkg_proxy.hpp"
#include "removals.hpp"
#include "browser_capture.hpp"
#include "lan.hpp"
@@ -85,7 +86,10 @@ constexpr std::pair<SDL_Scancode, int> kKeyButtons[] = {
constexpr float kLightTravel = 0.4f;
float key_travel(SDL_Scancode key) {
if (!SDL_GetKeyboardState(nullptr)[key]) return 0;
// Under QWERTZ the logical Z key can occupy the SDL_SCANCODE_Y position.
// Accept both physical layouts for the L2 keyboard shortcut.
const Uint8* keys = SDL_GetKeyboardState(nullptr);
if (!keys[key] && !(key == SDL_SCANCODE_Z && keys[SDL_SCANCODE_Y])) return 0;
return SDL_GetModState() & KMOD_SHIFT ? kLightTravel : 1;
}
@@ -182,6 +186,7 @@ struct Host {
browser_capture::stop();
lan::stop();
packages::stop();
pkg_proxy::stop();
removals::stop();
network::stop();
video_layers::stop();
@@ -11,7 +11,7 @@ std::mutex guard;
std::vector<Snapshot> pending;
std::uint32_t next_id = 1;
}
std::uint32_t install(std::string, std::string, std::string&) {
std::uint32_t install(std::string, std::string, std::string, std::string&) {
std::lock_guard lock(guard);
Snapshot snapshot;
snapshot.id = next_id++;
@@ -28,4 +28,10 @@ std::vector<Snapshot> poll() {
return out;
}
void stop() {}
// The preview serves packages from the app itself (pkg_proxy.cpp): there is no payload loader here.
std::uint32_t start_helper(std::uint32_t, std::string, std::string, std::string& error) {
error = "The package helper runs on a PS5 only.";
return 0;
}
std::vector<HelperSnapshot> poll_helper() { return {}; }
}
+2
View File
@@ -6,6 +6,7 @@
#include "network.hpp"
#include "archives.hpp"
#include "packages.hpp"
#include "pkg_proxy.hpp"
#include "removals.hpp"
#include "browser_capture.hpp"
#include "lan.hpp"
@@ -683,6 +684,7 @@ bool run_proof() {
browser_capture::stop();
lan::stop();
packages::stop();
pkg_proxy::stop();
removals::stop();
network::stop();
if (runtime) er_runtime_shutdown();
+58 -3
View File
@@ -6,6 +6,8 @@
#include "app_config.hpp"
#include "filesystem_access.hpp"
#include "platform/ps5/system.hpp"
#include <cstdio>
#include <string>
#include <unistd.h>
#if PS5_REACT_NETWORKING
extern "C" {
@@ -15,14 +17,67 @@ int sceNetPoolCreate(const char*, int, int);
int sceNetPoolDestroy(int);
}
namespace network {
namespace { int pool = -1; bool initialized = false; }
namespace {
int pool = -1; bool initialized = false;
constexpr const char* kSystemStore = "/system/common/cert/CA_LIST.cer";
constexpr const char* kCertificate = "-----BEGIN CERTIFICATE-----";
char store[256] = "/system/common/cert/CA_LIST.cer";
bool read_file(const char* path, std::string& out) {
FILE* file = std::fopen(path, "rb");
if (!file) return false;
out.clear();
char buffer[16384];
std::size_t n;
while ((n = std::fread(buffer, 1, sizeof buffer, file)) > 0) out.append(buffer, n);
const bool ok = !std::ferror(file);
std::fclose(file);
return ok;
}
// Old firmwares' store lacks roots that sites moved to since (4.03 fails GitHub and the cover servers).
// The app ships Mozilla's (certs/cacert.pem); both go into one file in the cache, passed to curl by path
// so each transfer pool loads one store and reuses it. Anything missing keeps the system store alone.
void merge_certificates() {
char bundle[256], merged[256];
std::string system, extra, current;
if (!resolve_filesystem_path("/app0/certs/cacert.pem", bundle, sizeof bundle) ||
!resolve_filesystem_path("/cache0/ca-bundle.pem", merged, sizeof merged)) {
hui::sys::log("[PS5-REACT] certificates: no path for the bundle; system store only"); return;
}
if (!read_file(bundle, extra) || extra.find(kCertificate) == std::string::npos) {
hui::sys::log("[PS5-REACT] certificates: %s unreadable; system store only", bundle); return;
}
if (!read_file(kSystemStore, system) || system.find(kCertificate) == std::string::npos) {
hui::sys::log("[PS5-REACT] certificates: the system store is not PEM; system store only"); return;
}
if (system.back() != '\n') system += '\n';
system += extra;
extra = std::string();
if (!read_file(merged, current) || current != system) {
const std::string temporary = std::string(merged) + ".tmp";
bool written = false;
if (FILE* file = std::fopen(temporary.c_str(), "wb")) {
written = std::fwrite(system.data(), 1, system.size(), file) == system.size();
written = std::fclose(file) == 0 && written;
}
if (!written || std::rename(temporary.c_str(), merged) != 0) {
std::remove(temporary.c_str());
hui::sys::log("[PS5-REACT] certificates: cannot write %s; system store only", merged); return;
}
}
std::snprintf(store, sizeof store, "%s", merged);
hui::sys::log("[PS5-REACT] certificates: system store + bundle, %zu bytes", system.size());
}
}
bool platform_start(std::string& error) {
if (!console_filesystem_accessible()) {
error = "native networking requires successful console filesystem elevation"; return false;
}
if (access("/system/common/cert/CA_LIST.cer", R_OK) != 0) {
if (access(kSystemStore, R_OK) != 0) {
error = "native networking cannot read the system certificate store"; return false;
}
merge_certificates();
initialized = sceNetInit() == 0;
pool = sceNetPoolCreate("ps5-react-http", 1024*1024, 0);
if (pool < 0) {
@@ -36,7 +91,7 @@ void platform_stop() {
if (initialized) sceNetTerm();
pool = -1; initialized = false;
}
const char* ca_path() { return "/system/common/cert/CA_LIST.cer"; }
const char* ca_path() { return store; }
// The title's heap (platform app_heap.c), so download telemetry shows how close it runs.
extern "C" void hui_heap_stats(std::size_t* live_bytes, std::size_t* peak_bytes, std::size_t* blocks, std::size_t* failures);
extern "C" void hui_heap_capacity(std::size_t* size, std::size_t* flexible_before, std::size_t* flexible_after);
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff. Show more