├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── docker-image.yml ├── CNAME ├── Dockerfile ├── README.md ├── css └── main.css ├── guides ├── Pasta Tool Walkthrough.md └── img │ ├── 1-login.png │ ├── 1.1-allow-popups.png │ ├── 1.2-plex-auth.png │ ├── 2-server-list.png │ ├── 2.1-select-server.png │ ├── 2.2-select-library.png │ ├── 3-select-series.png │ ├── 4-select-entire-series.png │ ├── 4.1-select-lang-prefs.png │ └── 4.2-lang-pref-completed.png ├── images ├── Logo-Transparent.png ├── Logo_Title_Large.png ├── Text-Transparent.png ├── Text_Logo-Transparent.png ├── android-chrome-192-maskable.png ├── android-chrome-192.png ├── android-chrome-512.png ├── apple_icon180.png ├── favicon.png ├── favicon_transparent.png └── icon196.png ├── index.html ├── js ├── bootstrap-history-tabs.js ├── detect-browser.js └── main.js └── manifest.json /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Build and Push Docker Image 2 | 3 | on: 4 | release: 5 | types: 6 | - published 7 | 8 | jobs: 9 | build-and-push: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | # Checkout the repository 14 | - name: Checkout code 15 | uses: actions/checkout@v3 16 | 17 | # Log in to Docker Hub 18 | - name: Log in to Docker Hub 19 | uses: docker/login-action@v2 20 | with: 21 | username: ${{ secrets.DOCKER_USERNAME }} 22 | password: ${{ secrets.DOCKER_PASSWORD }} 23 | 24 | # Build the Docker image with both the tag and latest 25 | - name: Build Docker Image 26 | run: | 27 | docker build -t ${{ secrets.DOCKER_USERNAME }}/pasta:${{ github.event.release.tag_name }} . 28 | docker tag ${{ secrets.DOCKER_USERNAME }}/pasta:${{ github.event.release.tag_name }} ${{ secrets.DOCKER_USERNAME }}/pasta:latest 29 | 30 | # Push the Docker image with the release tag 31 | - name: Push Docker Image with Release Tag 32 | run: | 33 | docker push ${{ secrets.DOCKER_USERNAME }}/pasta:${{ github.event.release.tag_name }} 34 | 35 | # Push the Docker image as latest 36 | - name: Push Docker Image as Latest 37 | run: | 38 | docker push ${{ secrets.DOCKER_USERNAME }}/pasta:latest 39 | -------------------------------------------------------------------------------- /CNAME: -------------------------------------------------------------------------------- 1 | www.pastatool.com -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM httpd:2.4 2 | COPY . /usr/local/apache2/htdocs/ 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PASTA 2 | Audio and Subtitle Track Changer for Plex 3 | 4 | DockerHub Link: https://hub.docker.com/r/cglatot/pasta 5 | 6 | Unraid Installation: This is now available on the Commmunity Applications list thanks to https://github.com/selfhosters/unRAID-CA-templates 7 | 8 | Encountered a bug, or have a feature request? Log it here: https://github.com/cglatot/pasta/issues 9 | 10 | Enjoying the tool? Considering adding to my coffee / energy drink fund :) 11 | 12 | [![ko-fi](https://www.ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/cglatot) 13 | 14 | ## What is PASTA? 15 | Do you watch TV Shows with multiple languages and subtitles and wish you could change them for the entire show, rather than needing to do it for *every. single. episode*? 16 | Or maybe you aren't sure what the difference is between those 2 English (SRT) and English (SRT) subtitle files. Then PASTA is for you! 17 | PASTA allows you to connect to your Plex server and view more details about the audio tracks and subtitles, as well as set the tracks and subtitles or entire shows, or single episodes very quickly. 18 | 19 | ## How do I use PASTA? 20 | I built PASTA to be as step-by-step as possible and to take you through it, so you should be able to just close this pop-up and follow along. 21 | There are some things I would like to point out, however: 22 | 27 | 28 | ## About PASTA 29 | When I first began developing this for myself, I was calling it *Audio Track Automation for Plex*, so adding "subtitles" to it, and rearranging the letters gave birth to PASTA. 30 | PASTA was born out of a desire, one that I had seen others have as well, but that I had only seen one other solution for. However, it was in command line and I wanted something a bit more appealing to look at, and something I could use from anywhere. Initially I was only building this for myself but I thought that others might find use for it as well, so here we are! 31 | 32 | PASTA runs entirely client-side. This means that you are not passing anything to someones server to do this (other than the Plex Server), and it also means I don't have to worry about standing up a server to do that side of things either :). PASTA runs off of Github Pages. Feel free to have a look, download it yourself and use it locally, or make suggestions. I'm by no means finished with PASTA - I still have plenty of ideas for how I can add more to it, as well as fix any bugs that crop up. 33 | 34 | ## Docker 35 | 36 | Here is an example compose to help you get started creating a container. 37 | 38 | 39 | 40 | ```yaml 41 | --- 42 | version: "3" 43 | 44 | services: 45 | pasta: 46 | image: cglatot/pasta 47 | container_name: pasta 48 | ports: 49 | - 8087:80 50 | restart: unless-stopped 51 | ``` -------------------------------------------------------------------------------- /css/main.css: -------------------------------------------------------------------------------- 1 | /*========================== 2 | DEFAULTS 3 | ==========================*/ 4 | 5 | body { 6 | background-color: rgb(51,51,51); 7 | background-image: radial-gradient(rgba(100, 50, 0, 0.75), black 120%), repeating-linear-gradient( 8 | rgba(0,0,0,0.75), 9 | rgba(0,0,0,0.75) 2px, 10 | transparent 2px, 11 | transparent 4px 12 | ); 13 | height: 100vh; 14 | } 15 | 16 | nav { 17 | background: rgb(7,7,7); 18 | } 19 | 20 | h1, h2, h3, h4, h5 { 21 | color: #e5a00d; 22 | } 23 | 24 | .card { 25 | background-color: rgba(25,25,25,0.85); 26 | } 27 | 28 | small { 29 | color: rgb(190,190,190); 30 | } 31 | 32 | .titleNavBar .titleImage { 33 | width: auto; 34 | height: auto; 35 | max-width: 100%; 36 | max-height: 3em; 37 | } 38 | 39 | .titleNavBar small { 40 | color: rgb(190,190,190); 41 | font-size: 0.7em; 42 | font-variant: small-caps; 43 | } 44 | 45 | a { 46 | color: #F39C12; 47 | text-decoration: none; 48 | background-color: transparent; 49 | } 50 | 51 | a:hover { 52 | color: rgb(240,240,240); 53 | text-decoration: none; 54 | background-color: transparent; 55 | } 56 | 57 | .text-muted { 58 | color: rgb(190,190,190) !important; 59 | } 60 | 61 | label, p { 62 | color: rgb(240,240,240); 63 | } 64 | 65 | #insecureWarning { 66 | display: none; 67 | } 68 | 69 | #loginInfoAlert { 70 | display: none; 71 | } 72 | 73 | #new-pin-container { 74 | display: none; 75 | } 76 | 77 | #waitOnPinAuth { 78 | display: none; 79 | } 80 | 81 | /*========================== 82 | MODALS 83 | ==========================*/ 84 | 85 | #progressModalTitle { 86 | width: 100%; 87 | } 88 | 89 | .modal-content { 90 | background-color: rgba(25,25,25,1); 91 | color: rgb(240,240,240); 92 | } 93 | 94 | .modal-content .modal-body strong { 95 | color: #e5a00d; 96 | } 97 | 98 | .modal-content .close { 99 | color: rgb(240,240,240); 100 | opacity: 1; 101 | } 102 | 103 | .modal-header { 104 | border-bottom: 1px solid #aaa; 105 | } 106 | 107 | .modal-footer { 108 | border-top: 1px solid #aaa; 109 | } 110 | 111 | /*========================== 112 | FORM CONTROLS 113 | ==========================*/ 114 | 115 | .form-control { 116 | background: rgba(240, 240, 240, 0.7); 117 | border: 1px solid #aaa; 118 | } 119 | 120 | .form-control:focus { 121 | background: rgba(240, 240, 240, 0.95); 122 | border-color: rgb(192, 118, 0.8); 123 | box-shadow: 0 0 0 0.2rem rgba(243,156,18,.25); 124 | color: #222; 125 | } 126 | 127 | .form-control.is-valid { 128 | background-color: rgba(239, 255, 243, 0.7); 129 | } 130 | 131 | .form-control.is-valid:focus { 132 | background-color: rgba(239, 255, 243, 0.95); 133 | color: #222; 134 | } 135 | 136 | .form-control.is-invalid { 137 | background-color: rgba(255, 239, 243, 0.7); 138 | } 139 | 140 | .form-control.is-invalid:focus { 141 | background-color: rgba(255, 239, 243, 0.95); 142 | color: #222; 143 | } 144 | 145 | #confirmForget { 146 | margin-left: 0.25em; 147 | display: none; 148 | } 149 | 150 | #forgetDivider, #forgetDetailsSection { 151 | display: none; 152 | } 153 | 154 | #url-auth-over-container { 155 | display: none; 156 | } 157 | 158 | #authed-pin-container { 159 | display: none; 160 | } 161 | 162 | #confirmForgetPin { 163 | margin-left: 0.25em; 164 | display: none; 165 | } 166 | 167 | /*========================== 168 | ALERTS 169 | ==========================*/ 170 | 171 | .alert-warning a:hover { 172 | color: #856404 173 | } 174 | 175 | #successToast { 176 | position: fixed; 177 | top: 5%; 178 | left: 0; 179 | right: 0; 180 | max-width: fit-content; 181 | margin: auto; 182 | z-index: 999; 183 | } 184 | 185 | .toast.show { 186 | opacity: 0.9; 187 | } 188 | 189 | /*========================== 190 | NAV TABS 191 | ==========================*/ 192 | 193 | .nav-tabs { 194 | border-bottom: 1px solid #aaa; 195 | } 196 | 197 | .nav-tabs .nav-item.show .nav-link, .nav-tabs .nav-link.active { 198 | color: rgb(240,240,240); 199 | background-color: rgba(25,25,25,0.85); 200 | border-color: #aaa #aaa rgba(25,25,25,0.85); 201 | } 202 | 203 | /*========================== 204 | BUTTONS 205 | ==========================*/ 206 | 207 | .btn-secondary { 208 | color: #fff; 209 | background-color: #F39C12; 210 | border-color: #F39C12; 211 | } 212 | 213 | .btn-secondary:hover { 214 | color: #fff; 215 | background-color: #d4860b; 216 | border-color: #c87f0a; 217 | } 218 | 219 | .btn-secondary:focus { 220 | box-shadow: 0 0 0 0.2rem rgba(243,156,18,.25); 221 | } 222 | 223 | .btn-secondary:not(:disabled):not(.disabled):active { 224 | color: #fff; 225 | background-color: #c87f0a; 226 | border-color: #bc770a; 227 | } 228 | 229 | .btn-secondary.disabled, .btn-secondary:disabled { 230 | color: #fff; 231 | background-color: #F39C12; 232 | border-color: #F39C12; 233 | } 234 | 235 | #episodeOrSeriesBtns label, #pinOrAuthBtns label { 236 | cursor: pointer; 237 | } 238 | 239 | #episodeOrSeriesBtns label.active, #pinOrAuthBtns label.active { 240 | cursor: default; 241 | } 242 | 243 | #alphabetGroup { 244 | border-radius: 0.25rem; 245 | overflow: hidden; 246 | } 247 | 248 | #alphabetGroup.btn-group{ 249 | display: block; 250 | } 251 | 252 | #alphabetGroup button { 253 | border-radius: 10px; 254 | margin: 3px; 255 | width: 30px; 256 | height: 30px; 257 | padding: 0; 258 | } 259 | 260 | #alphabetGroup .btn-outline-dark { 261 | color: rgb(240,240,240); 262 | background-color: #444; 263 | border-color: #444; 264 | } 265 | 266 | #alphabetGroup .btn-outline-dark:hover { 267 | color: rgb(240,240,240); 268 | background-color: #333; 269 | border-color: #2c2c2c; 270 | } 271 | 272 | #alphabetGroup .btn-outline-dark.disabled, #alphabetGroup .btn-outline-dark:disabled { 273 | color: rgb(240,240,240); 274 | background-color: #444; 275 | border-color: #444; 276 | opacity: 0.45; 277 | } 278 | 279 | #alphabetGroup .btn-dark { 280 | color: rgb(240,240,240); 281 | background-color: #222; 282 | border-color: #222; 283 | } 284 | 285 | #alphabetGroup .btn-dark:hover { 286 | color: rgb(240,240,240); 287 | background-color: #2c2c2c; 288 | border-color: #252525; 289 | } 290 | 291 | #alphabetGroup .btn-dark.focus, #alphabetGroup .btn-dark:focus { 292 | box-shadow: 0 0 0 0.2rem rgba(82,82,82,.5); 293 | } 294 | 295 | #episodeOrSeriesBtns .btn-secondary, #pinOrAuthBtns .btn-secondary { 296 | color: rgb(240,240,240); 297 | background-color: #444; 298 | border-color: #444; 299 | } 300 | 301 | #episodeOrSeriesBtns .btn-secondary:hover, #pinOrAuthBtns .btn-secondary:hover { 302 | color: rgb(240,240,240); 303 | background-color: #333; 304 | border-color: #2c2c2c; 305 | } 306 | 307 | #episodeOrSeriesBtns .btn-secondary.active, #pinOrAuthBtns .btn-secondary.active { 308 | color: #fff; 309 | background-color: #F39C12; 310 | border-color: #F39C12; 311 | } 312 | 313 | #episodeOrSeriesBtns .btn-secondary.active.focus, #pinOrAuthBtns .btn-secondary.active.focus { 314 | box-shadow: 0 0 0 0.2rem rgba(243,156,18,.25); 315 | } 316 | 317 | #episodeOrSeriesBtns .btn-secondary.active:hover, #pinOrAuthBtns .btn-secondary.active:hover { 318 | color: #fff; 319 | background-color: #d4860b; 320 | border-color: #c87f0a; 321 | } 322 | 323 | /*========================== 324 | TABLES 325 | ==========================*/ 326 | 327 | table, td, tr, th { 328 | color: rgb(240,240,240); 329 | } 330 | 331 | .table td, .table th { 332 | border-top: 1px solid #aaa; 333 | } 334 | 335 | .table thead th { 336 | border-bottom: 2px solid #aaa; 337 | } 338 | 339 | .table-active>td, .table-active>th { 340 | background-color: transparent; 341 | } 342 | 343 | #serverTable tbody tr { 344 | cursor: pointer; 345 | } 346 | 347 | #libraryTable tbody tr { 348 | cursor: pointer; 349 | } 350 | 351 | #tvShowsTable tbody tr { 352 | cursor: pointer; 353 | } 354 | 355 | #seasonsTable tbody tr { 356 | cursor: pointer; 357 | } 358 | 359 | #episodesTable tbody tr { 360 | cursor: pointer; 361 | } 362 | 363 | #audioTable tbody tr { 364 | cursor: pointer; 365 | } 366 | 367 | #subtitleTable tbody tr { 368 | cursor: pointer; 369 | } 370 | 371 | .table-active { 372 | background-color: rgba(192, 118, 0.8,.20); 373 | } 374 | 375 | .table-hover tbody tr:hover { 376 | background-color: rgba(255,255,255,.1); 377 | transition: none; 378 | } 379 | 380 | .table-hover .table-active:hover { 381 | background-color: rgba(192, 118, 0.8,.4); 382 | transition: none; 383 | } 384 | 385 | @keyframes successFadeOut { 386 | 0% { background-color: rgba(0,188,140,0.4); } 387 | 15% { background-color: rgba(0,188,140,0.4); } 388 | 100% { background-color: rgba(192, 118, 0.8,.20); } 389 | } 390 | 391 | #audioTable tbody tr.success-transition { 392 | color: #fff; 393 | animation: successFadeOut 1.75s ease-out; 394 | } 395 | 396 | #subtitleTable tbody tr.success-transition { 397 | color: #fff; 398 | animation: successFadeOut 1.75s ease-out; 399 | } 400 | 401 | /*========================== 402 | MEDIA QUERIES 403 | ==========================*/ 404 | 405 | /* Extra small devices (phones, 600px and down) */ 406 | @media only screen and (max-width: 768px) { 407 | .titleNavBar .titleImage { 408 | max-height: 2em; 409 | } 410 | } 411 | 412 | @media only screen and (max-width: 1199px) { 413 | /*#alphabetGroup.btn-group .btn:not(:last-child):not(.dropdown-toggle) { 414 | border-bottom-left-radius: 0; 415 | } 416 | 417 | #alphabetGroup.btn-group .btn:not(:first-child) { 418 | border-top-right-radius: 0; 419 | }*/ 420 | } 421 | 422 | @media only screen and (min-width: 1400px){ 423 | .container { 424 | max-width: 1215px; 425 | } 426 | } -------------------------------------------------------------------------------- /guides/Pasta Tool Walkthrough.md: -------------------------------------------------------------------------------- 1 | # Pasta Tool Walkthrough 2 | 3 | Begin by logging into plex. You may have to allow popups. 4 | 5 | 6 | 7 | ![](./img/1.1-allow-popups.png) 8 | 9 | 10 | 11 | After a successful authentication, you will be shown a list of servers. Select your server then select the library that has the show which requires a language/subtitle change. 12 | 13 | 14 | 15 | 16 | 17 | Navigate by letter the show/series and select the appropriate one. 18 | 19 | 20 | 21 | Once you are at the show, select **Entire Series** 22 | 23 | 24 | 25 | Then select a single episode -- it usually doesnt matter which one. You will get language and subtitle options. Select the appropriate *audio track* or *subtitle track*. It should automatically change the subtitles and show you which episode's tracks were changed. 26 | 27 | ⚠️ 28 | 29 | In this example, the episode selected had the "correct" audio track already selected. In some cases, the default audio track may not be the one you want. To ensure consistency, you should reselect the language track you want to make sure the entire series uses the same track. 30 | 31 | 32 | 33 | 34 | 35 | All done! 36 | -------------------------------------------------------------------------------- /guides/img/1-login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cglatot/pasta/1a4674f42827ac5ddcffe3e990ec7720242dd1e0/guides/img/1-login.png -------------------------------------------------------------------------------- /guides/img/1.1-allow-popups.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cglatot/pasta/1a4674f42827ac5ddcffe3e990ec7720242dd1e0/guides/img/1.1-allow-popups.png -------------------------------------------------------------------------------- /guides/img/1.2-plex-auth.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cglatot/pasta/1a4674f42827ac5ddcffe3e990ec7720242dd1e0/guides/img/1.2-plex-auth.png -------------------------------------------------------------------------------- /guides/img/2-server-list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cglatot/pasta/1a4674f42827ac5ddcffe3e990ec7720242dd1e0/guides/img/2-server-list.png -------------------------------------------------------------------------------- /guides/img/2.1-select-server.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cglatot/pasta/1a4674f42827ac5ddcffe3e990ec7720242dd1e0/guides/img/2.1-select-server.png -------------------------------------------------------------------------------- /guides/img/2.2-select-library.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cglatot/pasta/1a4674f42827ac5ddcffe3e990ec7720242dd1e0/guides/img/2.2-select-library.png -------------------------------------------------------------------------------- /guides/img/3-select-series.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cglatot/pasta/1a4674f42827ac5ddcffe3e990ec7720242dd1e0/guides/img/3-select-series.png -------------------------------------------------------------------------------- /guides/img/4-select-entire-series.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cglatot/pasta/1a4674f42827ac5ddcffe3e990ec7720242dd1e0/guides/img/4-select-entire-series.png -------------------------------------------------------------------------------- /guides/img/4.1-select-lang-prefs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cglatot/pasta/1a4674f42827ac5ddcffe3e990ec7720242dd1e0/guides/img/4.1-select-lang-prefs.png -------------------------------------------------------------------------------- /guides/img/4.2-lang-pref-completed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cglatot/pasta/1a4674f42827ac5ddcffe3e990ec7720242dd1e0/guides/img/4.2-lang-pref-completed.png -------------------------------------------------------------------------------- /images/Logo-Transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cglatot/pasta/1a4674f42827ac5ddcffe3e990ec7720242dd1e0/images/Logo-Transparent.png -------------------------------------------------------------------------------- /images/Logo_Title_Large.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cglatot/pasta/1a4674f42827ac5ddcffe3e990ec7720242dd1e0/images/Logo_Title_Large.png -------------------------------------------------------------------------------- /images/Text-Transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cglatot/pasta/1a4674f42827ac5ddcffe3e990ec7720242dd1e0/images/Text-Transparent.png -------------------------------------------------------------------------------- /images/Text_Logo-Transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cglatot/pasta/1a4674f42827ac5ddcffe3e990ec7720242dd1e0/images/Text_Logo-Transparent.png -------------------------------------------------------------------------------- /images/android-chrome-192-maskable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cglatot/pasta/1a4674f42827ac5ddcffe3e990ec7720242dd1e0/images/android-chrome-192-maskable.png -------------------------------------------------------------------------------- /images/android-chrome-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cglatot/pasta/1a4674f42827ac5ddcffe3e990ec7720242dd1e0/images/android-chrome-192.png -------------------------------------------------------------------------------- /images/android-chrome-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cglatot/pasta/1a4674f42827ac5ddcffe3e990ec7720242dd1e0/images/android-chrome-512.png -------------------------------------------------------------------------------- /images/apple_icon180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cglatot/pasta/1a4674f42827ac5ddcffe3e990ec7720242dd1e0/images/apple_icon180.png -------------------------------------------------------------------------------- /images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cglatot/pasta/1a4674f42827ac5ddcffe3e990ec7720242dd1e0/images/favicon.png -------------------------------------------------------------------------------- /images/favicon_transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cglatot/pasta/1a4674f42827ac5ddcffe3e990ec7720242dd1e0/images/favicon_transparent.png -------------------------------------------------------------------------------- /images/icon196.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cglatot/pasta/1a4674f42827ac5ddcffe3e990ec7720242dd1e0/images/icon196.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PASTA | Audio & Subtitle Track Changer for Plex 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 28 | 29 | 30 | 31 | 33 | 34 | 35 | 36 | 37 | 39 | 42 | 45 | 46 | 47 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 70 | 71 | 72 | 131 | 132 | 133 | 152 | 153 | 154 | 159 | 160 | 161 |
162 |
163 |
164 | 165 | 166 | 180 | 181 | 182 | 183 |
184 |
186 | 187 |
188 |
189 | 190 | 199 | 200 | 201 |
202 |
203 |
204 | 208 | 212 |
213 |
214 |
215 | 216 | 217 | 225 | 226 | 227 |
228 |
229 |
230 | 231 |
232 | 233 |
234 |
235 |

You are logged in as: 236 | 237 | Click here to logout. 238 | 239 | 240 |

241 |
242 | 243 | 244 | 247 | 248 | 249 |
250 |
251 | 252 |
253 | 254 | 255 |
256 |
257 | 258 | 260 | This must be a local server, or a server 261 | publicly addressable. 262 |
263 |
264 | 265 | 266 | 267 | Find 269 | out how to get your X-Plex-token here. 270 | 271 |
272 |
273 | 274 | 275 | 276 | Forget my details 277 | 278 | 279 |
280 | 282 | 283 |
284 |
285 | 286 |
287 |
288 | 289 |
290 | 291 |
292 |
293 | 294 | 295 | 296 | 297 | 298 |
299 |
300 |

Plex Servers

301 |
302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 |
Name
312 |
313 |
314 |
315 | 316 | 317 | 318 |
319 |
320 | 321 |
322 |
323 | 324 | 325 | 326 |
327 |
328 |

Plex Libraries

329 |
330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 |
Name
340 |
341 |
342 |
343 | 344 |
345 |
346 | 347 |
348 |
349 |
351 | 352 | 354 | 356 | 358 | 360 | 362 | 364 | 366 | 368 | 370 | 372 | 374 | 376 | 378 | 380 | 382 | 384 | 386 | 388 | 390 | 392 | 394 | 396 | 398 | 400 | 402 | 404 | 406 |
407 |
408 |
409 | 410 | 411 | 412 |
413 |
414 |

TV Series

415 |
416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 |
TitleYear
427 |
428 |
429 |
430 | 431 |
432 |
433 | 434 |
435 |
436 |

Seasons

437 |
438 | 439 | 440 | 441 | 442 | 443 | 444 | 445 | 446 | 447 |
Title
448 |
449 |
450 |
451 | 452 | 453 | 454 |
455 |
456 |

Episodes

457 |
458 | 459 | 460 | 461 | 462 | 463 | 464 | 465 | 466 | 467 |
Title
468 |
469 |
470 |
471 | 472 | 473 | 474 |
475 |
476 |
477 | 481 | 485 | 489 |
490 |
491 |
492 | 493 | 494 | 495 |
496 |
497 |

498 |
499 |
500 | 501 | 502 | 503 |
504 |
505 |

Audio Tracks

506 |
507 | 508 | 509 | 510 | 511 | 512 | 513 | 514 | 515 | 516 | 517 | 518 | 519 |
NameTitleLanguageCode
520 |
521 |
522 |
523 |

Subtitle Tracks

524 |
525 | 526 | 527 | 528 | 529 | 530 | 531 | 532 | 533 | 534 | 535 | 536 | 537 |
NameTitleLanguageCode
538 |
539 |
540 |
541 | 542 |
543 |
544 | 545 |
546 |
547 |
548 | 549 | 550 | -------------------------------------------------------------------------------- /js/bootstrap-history-tabs.js: -------------------------------------------------------------------------------- 1 | +function ($) { 2 | 'use strict'; 3 | $.fn.historyTabs = function() { 4 | var that = this; 5 | window.addEventListener('popstate', function(event) { 6 | if (event.state) { 7 | $(that).filter('[href="' + event.state.url + '"]').tab('show'); 8 | } else { 9 | $(that).filter('[href="#authentication"]').tab('show'); 10 | } 11 | }); 12 | return this.each(function(index, element) { 13 | $(element).on('show.bs.tab', function() { 14 | var stateObject = {'url' : $(this).attr('href')}; 15 | 16 | if (stateObject.url !== window.location.hash) { 17 | window.history.pushState(stateObject, document.title, window.location.pathname + $(this).attr('href')); 18 | } 19 | }); 20 | if (!window.location.hash && $(element).is('.active')) { 21 | // Shows the first element if there are no query parameters. 22 | $(element).tab('show'); 23 | } else if ($(this).attr('href') === window.location.hash) { 24 | $(element).tab('show'); 25 | } 26 | }); 27 | }; 28 | }(jQuery); -------------------------------------------------------------------------------- /js/detect-browser.js: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | 'use strict'; 3 | // detect-browser.js v1.0.0 4 | // Get Browser Data 5 | 6 | // MIT License 7 | 8 | // Copyright (c) 2018 Ahmad Raza 9 | 10 | // Permission is hereby granted, free of charge, to any person obtaining a copy 11 | // of this software and associated documentation files (the "Software"), to deal 12 | // in the Software without restriction, including without limitation the rights 13 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | // copies of the Software, and to permit persons to whom the Software is 15 | // furnished to do so, subject to the following conditions: 16 | 17 | // The above copyright notice and this permission notice shall be included in all 18 | // copies or substantial portions of the Software. 19 | 20 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | // SOFTWARE. 27 | 28 | 29 | function isMobile() { 30 | return /Mobi/.test(navigator.userAgent); 31 | } 32 | 33 | function getBrowserName() { 34 | // Opera 8.0+ 35 | if ((window.opr && window.opr.addons) 36 | || window.opera 37 | || navigator.userAgent.indexOf(' OPR/') >= 0) { 38 | return 'Opera'; 39 | } 40 | 41 | // Firefox 1.0+ 42 | if (typeof InstallTrigger !== 'undefined') { 43 | return 'Firefox'; 44 | } 45 | 46 | // Safari 3.0+ "[object HTMLElementConstructor]" 47 | if (/constructor/i.test(window.HTMLElement) || (function (p) { 48 | return p.toString() === '[object SafariRemoteNotification]'; 49 | })(!window['safari'])) { 50 | return 'Safari'; 51 | } 52 | 53 | // Internet Explorer 6-11 54 | if (/* @cc_on!@*/false || document.documentMode) { 55 | return 'Internet Explorer'; 56 | } 57 | 58 | // Edge 20+ 59 | if (!(document.documentMode) && window.StyleMedia) { 60 | return 'Microsoft Edge'; 61 | } 62 | 63 | // Chrome 64 | if (window.chrome) { 65 | return 'Chrome'; 66 | } 67 | } 68 | 69 | function getOSName() { 70 | var os; 71 | if (isMobile()) { 72 | if (/Windows/.test(navigator.userAgent)) { 73 | os = 'Windows'; 74 | if (/Phone 8.0/.test(navigator.userAgent)) { 75 | os += ' Phone 8.0'; 76 | } else if (/Phone 10.0/.test(navigator.userAgent)) { 77 | os += ' Phone 10.0'; 78 | } 79 | } else if (/Android/.test(navigator.userAgent)) { 80 | function androidVersion() { 81 | if (/Android/.test(navigator.appVersion)) { 82 | var v = (navigator.appVersion).match(/Android (\d+).(\d+)/); 83 | return v; 84 | } 85 | } 86 | 87 | var ver = androidVersion(); 88 | os = ver[0]; 89 | } else if (/iPhone;/.test(navigator.userAgent)) { 90 | function iOSversion() { 91 | if (/iP(hone|od|ad)/.test(navigator.appVersion)) { 92 | var v = (navigator.appVersion).match(/OS (\d+)_(\d+)_?(\d+)?/); 93 | return [parseInt(v[1], 10), parseInt(v[2], 10), parseInt(v[3] || 0, 10)]; 94 | } 95 | } 96 | 97 | var ver = iOSversion(); 98 | os = 'iOS ' + ver[0] + '.' + ver[1] + '.' + ver[2]; 99 | } else if (/iPad;/.test(navigator.userAgent)) { 100 | function iOSversion() { 101 | if (/iP(hone|od|ad)/.test(navigator.appVersion)) { 102 | var v = (navigator.appVersion).match(/OS (\d+)_(\d+)_?(\d+)?/); 103 | return [parseInt(v[1], 10), parseInt(v[2], 10), parseInt(v[3] || 0, 10)]; 104 | } 105 | } 106 | 107 | var ver = iOSversion(); 108 | os = 'iOS ' + ver[0] + '.' + ver[1] + '.' + ver[2]; 109 | } else if (/BBd*/.test(navigator.userAgent)) { 110 | os = 'BlackBerry'; 111 | } 112 | } else { 113 | if (/Windows/.test(navigator.userAgent)) { 114 | os = 'Windows'; 115 | if (/5.1;/.test(navigator.userAgent)) { 116 | os += ' XP'; 117 | } else if (/6.0;/.test(navigator.userAgent)) { 118 | os += ' Vista'; 119 | } else if (/6.1;/.test(navigator.userAgent)) { 120 | os += ' 7'; 121 | } else if (/6.2/.test(navigator.userAgent)) { 122 | os += ' 8'; 123 | } else if (/10.0;/.test(navigator.userAgent)) { 124 | os += ' 10'; 125 | } 126 | 127 | if (/64/.test(navigator.userAgent)) { 128 | os += ' 64-bit'; 129 | } else { 130 | os += ' 32-bit'; 131 | } 132 | } else if (/Macintosh/.test(navigator.userAgent)) { 133 | os = 'Macintosh'; 134 | if (/OS X/.test(navigator.userAgent)) { 135 | os += ' OS X'; 136 | } 137 | } 138 | } 139 | 140 | return os; 141 | } 142 | 143 | function getBrowserVersion() { 144 | var ua = navigator.userAgent, tem, M = ua.match(/(opera|chrome|safari|firefox|msie|trident(?=\/))\/?\s*(\d+)/i) || []; 145 | if (/trident/i.test(M[1])) { 146 | tem = /\brv[ :]+(\d+)/g.exec(ua) || []; 147 | return (tem[1] || ''); 148 | } 149 | if (M[1] === 'Chrome') { 150 | tem = ua.match(/\bOPR|Edge\/(\d+)/) 151 | if (tem != null) { 152 | if (M.input && M.input.match(/Windows NT 10.0/)) { 153 | return tem[1]; 154 | } 155 | return tem[1]; 156 | } 157 | } 158 | M = M[2] ? [M[1], M[2]] : [navigator.appName, navigator.appVersion, '-?']; 159 | if ((tem = ua.match(/version\/(\d+)/i)) != null) { 160 | M.splice(1, 1, tem[1]); 161 | } 162 | return M[1]; 163 | } 164 | 165 | function getBrowser() { 166 | return { 167 | os: getOSName(), 168 | browser: getBrowserName(), 169 | browserVersion: getBrowserVersion(), 170 | language: navigator.language, 171 | languages: navigator.languages, 172 | user_agent: navigator.userAgent, 173 | device: isMobile() ? 'Mobile' : 'Desktop', 174 | referrer: document.referrer || 'N/A', 175 | online: navigator.onLine, 176 | timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, 177 | screen_resolution: screen.width + ' x ' + screen.height, 178 | cookie_enabled: navigator.cookieEnabled, 179 | }; 180 | } -------------------------------------------------------------------------------- /js/main.js: -------------------------------------------------------------------------------- 1 | // Variables for the Authorised Devices card 2 | var clientIdentifier; // UID for the device being used 3 | var plexProduct = "PASTA"; // X-Plex-Product - Application name 4 | var pastaVersion = "1.6.0"; // X-Plex-Version - Application version 5 | var pastaPlatform; // X-Plex-Platform - Web Browser 6 | var pastaPlatformVersion; // X-Plex-Platform-Version - Web Browser version 7 | var deviceInfo; // X-Plex-Device - Operation system? 8 | var deviceName; // X-Plex-Device-Name - Main name shown 9 | // End auth devices card variables 10 | var plexUrl; 11 | var plexToken; 12 | var backOffTimer = 0; 13 | var serverList = []; // save server information for pin login and multiple servers 14 | 15 | var libraryNumber = ""; // The Library ID that was clicked 16 | var showId = ""; // Stores the Id for the most recently clicked series 17 | var seasonsList = []; // Stores the Ids for all seasons of the most recently clicked series 18 | var seasonId = ""; // Store the Id of the most recently clicked season 19 | var episodeId = ""; // Stores the Id of the most recently clicked episode 20 | var libraryType = "shows"; // Sets whether the library is a show or a movie / other videos 21 | 22 | $(document).ready(() => { 23 | // Check if there is a page refresh, if so we want to push the history without the # 24 | let navigationType; 25 | if (window.performance && window.performance.getEntriesByType('navigation').length) { 26 | navigationType = window.performance.getEntriesByType("navigation")[0].type; 27 | } else if (performance.getEntriesByType('navigation').length) { 28 | navigationType = performance.getEntriesByType("navigation")[0].type; 29 | } 30 | else { 31 | console.log("Couldn't find the window.performance or performance to get reload information"); 32 | } 33 | if ((navigationType == 'reload') && (window.location.href.indexOf('#authentication') == -1)) { 34 | window.history.pushState('', document.title, window.location.pathname + '#authentication'); 35 | } 36 | 37 | // Enable Tooltips 38 | $('.helpButtons, #titleLogo').tooltip(); 39 | // Enable history tracking for tabs 40 | $('a[data-toggle="tab"]').historyTabs(); 41 | // Enable Toasts 42 | $('.toast').toast({'delay': 1750}); 43 | 44 | // Check if the page was loaded locally or over http and warn them about the value of https 45 | if ((location.protocol == "http:") || (location.protocol == "file:")) { 46 | if (localStorage.showHttpAlert == 'false') {} 47 | else { 48 | $("#insecureWarning").show(); 49 | } 50 | } 51 | 52 | // Check if they have permanently dismissed the Login Info alert 53 | if (localStorage.showLoginInfoAlert == 'false') {} 54 | else { 55 | $("#loginInfoAlert").show(); 56 | } 57 | 58 | // Override the close mechanism to not show the loginInfoAlert 59 | $("#loginInfoAlertClose").on("click", () => { 60 | hideLoginInfoAlertForever(); 61 | }); 62 | 63 | // SET THE VARIABLES FOR PLEX PIN AUTH REQUESTS 64 | try { 65 | let browserInfo = getBrowser(); 66 | // Set the clientID, this might get overridden if one is saved to localstorage 67 | clientIdentifier = localStorage.clientIdentifier || `PASTA-cglatot-${Date.now()}-${Math.round(Math.random() * 1000)}`; 68 | // Set the OS 69 | deviceInfo = browserInfo.os || ""; 70 | // Set the web browser and version 71 | pastaPlatform = browserInfo.browser || ""; 72 | pastaPlatformVersion = browserInfo.browserVersion || ""; 73 | // Set the main display name 74 | deviceName = `PASTA (${pastaPlatform})` || "PASTA"; 75 | } catch (e) { 76 | console.log(e); 77 | // Fallback values 78 | // Set the clientID, this might get overridden if one is saved to localstorage 79 | clientIdentifier = localStorage.clientIdentifier || `PASTA-cglatot-${Date.now()}-${Math.round(Math.random() * 1000)}`; 80 | // Set the OS 81 | deviceInfo = ""; 82 | // Set the web browser and version 83 | pastaPlatform = ""; 84 | pastaPlatformVersion = ""; 85 | // Set the main display name 86 | deviceName = "PASTA"; 87 | } 88 | 89 | // Validation listeners on the Plex URL Input 90 | $('#plexUrl').on("input", () => { 91 | validateEnableConnectBtn('plexUrl'); 92 | }); 93 | // Validation listeners on the Plex Token Input 94 | $('#plexToken').on("input", () => { 95 | validateEnableConnectBtn('plexToken'); 96 | }); 97 | 98 | // Setup on change listener for toggle buttons 99 | $('input[type=radio][name=pinOrAuth]').change(function() { 100 | toggleAuthPages(this.value); 101 | }); 102 | 103 | // Check whether they want to connect using a local IP or not 104 | if (localStorage.useLocalAddress == "true") { 105 | $('#connectViaLocalAddress').prop('checked', true); 106 | } else { 107 | $('#connectViaLocalAddress').prop('checked', false); 108 | } 109 | 110 | // Check if there is a stored Auth Token 111 | if (localStorage.pinAuthToken) { 112 | checkIfAuthTokenIsValid(); 113 | } else { 114 | $('#new-pin-container').show(); 115 | } 116 | }); 117 | 118 | function validateEnableConnectBtn(context) { 119 | // Apply validation highlighting to URL field 120 | if (context == 'plexUrl') { 121 | if ($('#plexUrl').val() != "") { 122 | $('#plexUrl').removeClass("is-invalid").addClass("is-valid"); 123 | } 124 | else { 125 | $('#plexUrl').removeClass("is-valid").addClass("is-invalid"); 126 | } 127 | } 128 | else { 129 | // Apply validation highlighting to Plex Token field 130 | if ($('#plexToken').val() != "") { 131 | $('#plexToken').removeClass("is-invalid").addClass("is-valid"); 132 | } 133 | else { 134 | $('#plexToken').removeClass("is-valid").addClass("is-invalid"); 135 | } 136 | } 137 | 138 | // Enable or disable the button, depending on field status 139 | if (($('#plexUrl').val() != "") && ($('#plexToken').val() != "")) { 140 | $("#btnConnectToPlex").prop("disabled", false); 141 | } 142 | else { 143 | $("#btnConnectToPlex").prop("disabled", true); 144 | } 145 | } 146 | 147 | function forgetDetails() { 148 | localStorage.removeItem('plexUrl'); 149 | localStorage.removeItem('plexToken'); 150 | $('#plexUrl, #plexToken').val('').removeClass('is-valid is-invalid'); 151 | $('#confirmForget').fadeIn(250).delay(750).fadeOut(1250, () => { 152 | $('#forgetDivider, #forgetDetailsSection').hide(); 153 | }); 154 | } 155 | 156 | function forgetPinDetails() { 157 | localStorage.removeItem('isPinAuth'); 158 | localStorage.removeItem('pinAuthToken'); 159 | localStorage.removeItem('useLocalAddress'); 160 | window.location.reload(); 161 | } 162 | 163 | function hideAlertForever() { 164 | $("#insecureWarning").hide(); 165 | localStorage.showHttpAlert = 'false'; 166 | } 167 | 168 | function hideLoginInfoAlertForever() { 169 | $("#loginInfoAlert").hide(); 170 | localStorage.showLoginInfoAlert = 'false'; 171 | } 172 | 173 | // Checks if the generated token is valid 174 | function checkIfAuthTokenIsValid() { 175 | $.ajax({ 176 | "url": `https://plex.tv/api/v2/user`, 177 | "headers": { 178 | "accept": "application/json", 179 | "X-Plex-Client-Identifier": clientIdentifier, 180 | "X-Plex-Token": localStorage.pinAuthToken, 181 | "X-Plex-Product": plexProduct, 182 | "X-Plex-Version": pastaVersion, 183 | "X-Plex-Platform": pastaPlatform, 184 | "X-Plex-Platform-Version": pastaPlatformVersion, 185 | "X-Plex-Device": deviceInfo, 186 | "X-Plex-Device-Name": deviceName 187 | }, 188 | "method": "GET", 189 | "success": (data) => { 190 | plexToken = localStorage.pinAuthToken; 191 | $('#new-pin-container').hide(); 192 | $('#authed-pin-container').show(); 193 | $('#loggedInAs').text(data.username); 194 | getServers(); 195 | }, 196 | "error": (data) => { 197 | if (data.status == 401) { 198 | // Auth Token has expired 199 | localStorage.removeItem('isPinAuth'); 200 | localStorage.removeItem('pinAuthToken'); 201 | localStorage.removeItem('useLocalAddress'); 202 | $('#new-pin-container').show(); 203 | } else { 204 | console.log("ERROR L121"); 205 | } 206 | } 207 | }); 208 | } 209 | 210 | function authenticateWithPlex() { 211 | // Generate a PIN code to get the URL 212 | $.ajax({ 213 | "url": `https://plex.tv/api/v2/pins`, 214 | "headers": { 215 | "accept": "application/json", 216 | "strong": "true", 217 | "X-Plex-Client-Identifier": clientIdentifier, 218 | "X-Plex-Product": plexProduct, 219 | "X-Plex-Version": pastaVersion, 220 | "X-Plex-Platform": pastaPlatform, 221 | "X-Plex-Platform-Version": pastaPlatformVersion, 222 | "X-Plex-Device": deviceInfo, 223 | "X-Plex-Device-Name": deviceName 224 | }, 225 | "method": "POST", 226 | "success": (data) => { 227 | // For some reason auth doesn't work unless you choose Plex Web as the product id 228 | let plexProductTemp = encodeURIComponent("Plex Web"); 229 | let authAppUrl = `https://app.plex.tv/auth#?clientID=${clientIdentifier}&code=${data.code}&context%5Bdevice%5D%5Bproduct%5D=${plexProductTemp}`; 230 | 231 | $('#waitOnPinAuth').show(); 232 | $('#loginWithPlexBtn').hide(); 233 | let popupWindow = window.open(authAppUrl, 'PlexSignIn', 'width=800,height=730'); 234 | backOffTimer = Date.now(); 235 | listenForValidPincode(data.id, clientIdentifier, data.code, popupWindow); 236 | }, 237 | "error": (data) => { 238 | console.log("ERROR L121"); 239 | console.log(data); 240 | } 241 | }); 242 | } 243 | 244 | function listenForValidPincode(pinId, clientId, pinCode, popWindow) { 245 | let currentTime = Date.now(); 246 | if ((currentTime - backOffTimer)/1000 < 180) { 247 | $.ajax({ 248 | "url": `https://plex.tv/api/v2/pins/${pinId}`, 249 | "headers": { 250 | "accept": "application/json", 251 | "code": pinCode, 252 | "X-Plex-Client-Identifier": clientId, 253 | }, 254 | "method": "GET", 255 | "success": (data) => { 256 | if (data.authToken != null) { 257 | $('#waitOnPinAuth').hide(); 258 | localStorage.clientIdentifier = clientIdentifier; 259 | localStorage.isPinAuth = true; 260 | localStorage.pinAuthToken = data.authToken; 261 | plexToken = data.authToken; 262 | checkIfAuthTokenIsValid(); 263 | popWindow.close(); 264 | } else { 265 | setTimeout(() => { 266 | listenForValidPincode(pinId, clientId, pinCode, popWindow); 267 | }, 3000); // Check every 3 seconds 268 | } 269 | }, 270 | "error": (data) => { 271 | console.log("ERROR L121"); 272 | console.log(data); 273 | } 274 | }); 275 | } else { 276 | $('#new-pin-container').html('

Login timed out. \ 277 | Please refresh the page and ensure your popup blocker is disabled.

'); 278 | } 279 | } 280 | 281 | // Toggle between the authentication methods 282 | function toggleAuthPages(value) { 283 | if (value == 'showPinControls') { 284 | $('#pin-auth-over-container').show(); 285 | $('#url-auth-over-container').hide(); 286 | } else { 287 | $('#pin-auth-over-container').hide(); 288 | $('#url-auth-over-container').show(); 289 | 290 | if (localStorage.isPinAuth) { 291 | $("#authWarningText").html(``); 297 | } 298 | } 299 | } 300 | 301 | // Called when the "connect using local IP" checkbox is toggled 302 | // Refreshes the page and updates the variable for whether it should use the local address or not 303 | function useLocalAddress (checkbox) { 304 | if (checkbox.checked) { 305 | localStorage.useLocalAddress = "true"; 306 | } else { 307 | localStorage.removeItem('useLocalAddress'); 308 | } 309 | window.location.reload(); 310 | } 311 | 312 | function getServers () { 313 | // Choose whether or not to include https connections 314 | let includeHttps = 1; 315 | if (location.protocol == 'http:') { 316 | includeHttps = 0; 317 | } 318 | // Get the servers for this user 319 | $.ajax({ 320 | "url": `https://plex.tv/api/v2/resources?includeHttps=${includeHttps}&includeRelay=0`, 321 | "method": "GET", 322 | "headers": { 323 | "X-Plex-Client-Identifier": clientIdentifier, 324 | "X-Plex-Token": plexToken, 325 | "accept": "application/json" 326 | }, 327 | "success": (data) => { 328 | let servers = data.filter(entry => entry.product == "Plex Media Server"); 329 | if (servers.length > 0) { 330 | // Add server info to the list 331 | for (let i = 0; i < servers.length; i++) { 332 | let serverConnections = servers[i].connections; 333 | 334 | // Filter servers based off the local address checkbox 335 | if (localStorage.useLocalAddress) { 336 | serverConnections = serverConnections.filter(conn => conn.local == true); 337 | } else { 338 | serverConnections = serverConnections.filter(conn => conn.local == false); 339 | } 340 | 341 | // Filter servers based on http / https site loaded 342 | if (location.protocol == 'http:') { 343 | serverConnections = serverConnections.filter(conn => conn.protocol == "http"); 344 | } else { 345 | serverConnections = serverConnections.filter(conn => conn.protocol == "https"); 346 | } 347 | 348 | serverList.push({ 349 | name: servers[i].name, 350 | accessToken: servers[i].accessToken, 351 | connections: serverConnections, 352 | }); 353 | } 354 | // Populate the servers in the list of servers 355 | displayServers(servers); 356 | } else { 357 | console.log("ERROR L301: There are no results in the list of servers!"); 358 | } 359 | }, 360 | "error": (data) => { 361 | console.log("ERROR L224"); 362 | console.log(data); 363 | if (data.status == 401) { 364 | console.log("Unauthorized"); 365 | $("#pinAuthWarning").html(``); 371 | } 372 | } 373 | }); 374 | } 375 | 376 | function displayServers(servers) { 377 | $("#serverTable tbody").empty(); 378 | $("#libraryTable tbody").empty(); 379 | $("#tvShowsTable tbody").empty(); 380 | $("#seasonsTable tbody").empty(); 381 | $("#episodesTable tbody").empty(); 382 | $("#audioTable tbody").empty(); 383 | $("#subtitleTable tbody").empty(); 384 | 385 | for (let i = 0; i < servers.length; i++) { 386 | let rowHTML = ` 387 | ${servers[i].name} 388 | `; 389 | $("#serverTable tbody").append(rowHTML); 390 | } 391 | $("#serverTableContainer").show(); 392 | } 393 | 394 | async function chooseServer(number, row) { 395 | $("#libraryTable tbody").empty(); 396 | $("#tvShowsTable tbody").empty(); 397 | $("#seasonsTable tbody").empty(); 398 | $("#episodesTable tbody").empty(); 399 | $("#audioTable tbody").empty(); 400 | $("#subtitleTable tbody").empty(); 401 | $('#alphabetGroup').children().removeClass("btn-dark").addClass("btn-outline-dark").prop("disabled", true); 402 | 403 | $(row).siblings().removeClass("table-active"); 404 | $(row).addClass("table-active"); 405 | 406 | plexToken = serverList[number].accessToken; 407 | let connections = serverList[number].connections; 408 | 409 | // Loop through the connections to see if we can find one that works 410 | for (let i = 0; i < connections.length; i++) { 411 | try { 412 | let testResult = await $.ajax({ 413 | "url": `${connections[i].uri}/identity`, 414 | "method": "GET", 415 | "headers": { 416 | "X-Plex-Token": plexToken, 417 | "Accept": "application/json" 418 | } 419 | }); 420 | 421 | // Check if it is a valid server 422 | if (testResult.MediaContainer.machineIdentifier != undefined) { 423 | plexUrl = connections[i].uri; 424 | connectToPlex(); 425 | break; 426 | } 427 | } catch (e) {} 428 | } 429 | } 430 | 431 | function connectToPlex() { 432 | plexUrl = plexUrl || $("#plexUrl").val().trim().replace(/\/+$/, ''); 433 | plexToken = plexToken || $("#plexToken").val().trim(); 434 | 435 | if (plexUrl.toLowerCase().indexOf("http") < 0) { 436 | plexUrl = `http://${plexUrl}` 437 | } 438 | 439 | $.ajax({ 440 | "url": `${plexUrl}/library/sections/`, 441 | "method": "GET", 442 | "headers": { 443 | "X-Plex-Token": plexToken, 444 | "Accept": "application/json" 445 | }, 446 | "success": (data) => { 447 | $("#authWarningText").empty(); 448 | if ($('#rememberDetails').prop('checked')) { 449 | localStorage.plexUrl = plexUrl; 450 | localStorage.plexToken = plexToken; 451 | $('#forgetDivider, #forgetDetailsSection').show(); 452 | } 453 | displayLibraries(data); 454 | }, 455 | "error": (data) => { 456 | if (data.status == 401) { 457 | console.log("Unauthorized"); 458 | $("#authWarningText").html(``); 464 | } 465 | else if ((location.protocol == 'https:') && (localStorage.isPinAuth) && (plexUrl.indexOf('http:') > -1)) { 466 | console.log("Trying to use http over a https site with PIN authentication"); 467 | $("#pinAuthWarning").html(``); 474 | } 475 | else if ((location.protocol == 'https:') && (plexUrl.indexOf('http:') > -1)) { 476 | console.log("Trying to use http over a https site"); 477 | $("#authWarningText").html(``); 484 | } 485 | else { 486 | console.log("Unknown error, most likely bad URL / IP"); 487 | $("#authWarningText").html(``); 493 | } 494 | $("#libraryTable tbody").empty(); 495 | $("#tvShowsTable tbody").empty(); 496 | $("#seasonsTable tbody").empty(); 497 | $("#episodesTable tbody").empty(); 498 | $("#audioTable tbody").empty(); 499 | $("#subtitleTable tbody").empty(); 500 | } 501 | }); 502 | } 503 | 504 | function displayLibraries(data) { 505 | const libraries = data.MediaContainer.Directory; 506 | 507 | $("#libraryTable tbody").empty(); 508 | $("#tvShowsTable tbody").empty(); 509 | $("#seasonsTable tbody").empty(); 510 | $("#episodesTable tbody").empty(); 511 | $("#audioTable tbody").empty(); 512 | $("#subtitleTable tbody").empty(); 513 | 514 | for (let i = 0; i < libraries.length; i++) { 515 | let rowHTML = ` 516 | ${libraries[i].title} 517 | `; 518 | $("#libraryTable tbody").append(rowHTML); 519 | } 520 | // Scroll to the table 521 | document.querySelector('#libraryTable').scrollIntoView({ 522 | behavior: 'smooth' 523 | }); 524 | } 525 | 526 | function getAlphabet(uid, libType, row) { 527 | $.ajax({ 528 | "url": `${plexUrl}/library/sections/${uid}/firstCharacter`, 529 | "method": "GET", 530 | "headers": { 531 | "X-Plex-Token": plexToken, 532 | "Accept": "application/json" 533 | }, 534 | "success": (data) => { 535 | libraryNumber = uid; 536 | displayAlphabet(data, libType, row); 537 | $('#series-tab').tab('show'); 538 | }, 539 | "error": (data) => { 540 | console.log("ERROR L428"); 541 | console.log(data); 542 | } 543 | }); 544 | } 545 | 546 | function displayAlphabet(data, libType, row) { 547 | const availableAlphabet = data.MediaContainer.Directory; 548 | 549 | if ( libType == 'show') { libraryType = "shows"; } 550 | else { libraryType = "movie"; } 551 | 552 | if (data.MediaContainer.thumb.indexOf('video') > -1) { 553 | // Update the tab names to "Videos" and "Tracks" 554 | $('#series-tab').html("Videos"); 555 | $('#episodes-tab').html("Tracks"); 556 | $('#libraryTypeTitle').html("Other Videos"); 557 | } else if (libraryType == "shows") { 558 | // Update the tab names to "Series" and "Episodes" 559 | $('#series-tab').html("Series"); 560 | $('#episodes-tab').html("Episodes"); 561 | $('#libraryTypeTitle').html("TV Series"); 562 | } else { 563 | // Update the tab names to "Movies" and "Tracks" 564 | $('#series-tab').html("Movies"); 565 | $('#episodes-tab').html("Tracks"); 566 | $('#libraryTypeTitle').html("Movies"); 567 | } 568 | 569 | $("#tvShowsTable tbody").empty(); 570 | $("#seasonsTable tbody").empty(); 571 | $("#episodesTable tbody").empty(); 572 | $("#audioTable tbody").empty(); 573 | $("#subtitleTable tbody").empty(); 574 | 575 | $(row).siblings().removeClass("table-active"); 576 | $(row).addClass("table-active"); 577 | $('#alphabetGroup').children().removeClass("btn-dark").addClass("btn-outline-dark").prop("disabled", true); 578 | 579 | for (let i = 0; i < availableAlphabet.length; i++) { 580 | if (availableAlphabet[i].title == "#") { 581 | $(`#btnHash`).prop("disabled", false); 582 | } 583 | else { 584 | $(`#btn${availableAlphabet[i].title}`).prop("disabled", false); 585 | } 586 | } 587 | 588 | // Get the non-English characters 589 | const nonEngChars = availableAlphabet.filter(entry => !entry.title.match(/[a-z#]/i)); 590 | 591 | // Remove all custom buttons after the Z 592 | $('#btnZ').nextAll().remove(); 593 | 594 | if (nonEngChars.length > 0) { 595 | // Add buttons for the non English characters 596 | for (let j = 0; j < nonEngChars.length; j++) { 597 | $('#alphabetGroup').append(``); 599 | } 600 | } 601 | } 602 | 603 | function getLibraryByLetter(element) { 604 | let letter = $(element).text(); 605 | if (letter == "#") letter = "%23"; 606 | 607 | $(element).siblings().removeClass("btn-dark").addClass("btn-outline-dark"); 608 | $(element).removeClass("btn-outline-dark").addClass("btn-dark"); 609 | 610 | $.ajax({ 611 | "url": `${plexUrl}/library/sections/${libraryNumber}/firstCharacter/${letter}`, 612 | "method": "GET", 613 | "headers": { 614 | "X-Plex-Token": plexToken, 615 | "Accept": "application/json" 616 | }, 617 | "success": (data) => displayTitles(data), 618 | "error": (data) => { 619 | console.log("ERROR L473"); 620 | console.log(data); 621 | } 622 | }); 623 | } 624 | 625 | function displayTitles(titles) { 626 | const tvShows = titles.MediaContainer.Metadata; 627 | $("#tvShowsTable tbody").empty(); 628 | $("#seasonsTable tbody").empty(); 629 | $("#episodesTable tbody").empty(); 630 | $("#audioTable tbody").empty(); 631 | $("#subtitleTable tbody").empty(); 632 | 633 | for (let i = 0; i < tvShows.length; i++) { 634 | let rowHTML = ` 635 | ${tvShows[i].title} 636 | ${tvShows[i].year} 637 | `; 638 | $("#tvShowsTable tbody").append(rowHTML); 639 | } 640 | // Scroll to the table 641 | document.querySelector('#tvShowsTable').scrollIntoView({ 642 | behavior: 'smooth' 643 | }); 644 | } 645 | 646 | function getTitleInfo(uid, row) { 647 | showId = uid; 648 | if (libraryType == "movie") { 649 | getEpisodeInfo(uid, row); 650 | // Hide TV shows tables and switches 651 | $('#seasonsTableContainer').hide(); 652 | $('#episodesTableContainer').hide(); 653 | $('#switchToggleContainer').hide(); 654 | // Update the name of the Movie in the placeholder 655 | $('#movieNamePlaceholder').show(); 656 | $('#movieNamePlaceholder h2').html(`${$(row).children().first().html()} (${$(row).children().last().html()})`); 657 | // Swap to the tab 658 | $('#episodes-tab').tab('show'); 659 | } else { 660 | $('#seasonsTableContainer').show(); 661 | $('#episodesTableContainer').show(); 662 | $('#switchToggleContainer').show(); 663 | $('#movieNamePlaceholder').hide(); 664 | $.ajax({ 665 | "url": `${plexUrl}/library/metadata/${uid}/children`, 666 | "method": "GET", 667 | "headers": { 668 | "X-Plex-Token": plexToken, 669 | "Accept": "application/json" 670 | }, 671 | "success": (data) => { 672 | showTitleInfo(data, row); 673 | $('#episodes-tab').tab('show'); 674 | }, 675 | "error": (data) => { 676 | console.log("ERROR L510"); 677 | console.log(data); 678 | if (data.status == 400) { 679 | // This is a "bad request" - this usually means a Movie was selected 680 | $('#progressModal #progressModalTitle').empty(); 681 | $('#progressModal #progressModalTitle').text(`Invalid TV Show`); 682 | $('#progressModal #modalBodyText').empty(); 683 | $('#progressModal #modalBodyText').append(``); 689 | $('#progressModal').modal(); 690 | } 691 | } 692 | }); 693 | } 694 | } 695 | 696 | function showTitleInfo(data, row) { 697 | const seasons = data.MediaContainer.Metadata; 698 | seasonsList.length = 0; 699 | 700 | $(row).siblings().removeClass("table-active"); 701 | $(row).addClass("table-active"); 702 | 703 | $("#seasonsTable tbody").empty(); 704 | $("#episodesTable tbody").empty(); 705 | $("#audioTable tbody").empty(); 706 | $("#subtitleTable tbody").empty(); 707 | 708 | for (let i = 0; i < seasons.length; i++) { 709 | seasonsList.push(seasons[i].ratingKey); 710 | let rowHTML = ` 711 | ${seasons[i].title} 712 | `; 713 | $("#seasonsTable tbody").append(rowHTML); 714 | } 715 | } 716 | 717 | function getSeasonInfo(uid, row) { 718 | seasonId = uid; 719 | $.ajax({ 720 | "url": `${plexUrl}/library/metadata/${uid}/children`, 721 | "method": "GET", 722 | "headers": { 723 | "X-Plex-Token": plexToken, 724 | "Accept": "application/json" 725 | }, 726 | "success": (data) => showSeasonInfo(data, row), 727 | "error": (data) => { 728 | console.log("ERROR L561"); 729 | console.log(data); 730 | } 731 | }); 732 | } 733 | 734 | function showSeasonInfo(data, row) { 735 | const episodes = data.MediaContainer.Metadata; 736 | 737 | $(row).siblings().removeClass("table-active"); 738 | $(row).addClass("table-active"); 739 | 740 | $("#episodesTable tbody").empty(); 741 | $("#audioTable tbody").empty(); 742 | $("#subtitleTable tbody").empty(); 743 | 744 | for (let i = 0; i < episodes.length; i++) { 745 | let rowHTML = ` 746 | S${episodes[i].parentIndex}E${episodes[i].index} - ${episodes[i].title} 747 | `; 748 | $("#episodesTable tbody").append(rowHTML); 749 | } 750 | // Scroll to the table 751 | document.querySelector('#episodesTable').scrollIntoView({ 752 | behavior: 'smooth' 753 | }); 754 | } 755 | 756 | function getEpisodeInfo(uid, row) { 757 | episodeId = uid; 758 | $.ajax({ 759 | "url": `${plexUrl}/library/metadata/${uid}`, 760 | "method": "GET", 761 | "headers": { 762 | "X-Plex-Token": plexToken, 763 | "Accept": "application/json" 764 | }, 765 | "success": (data) => showEpisodeInfo(data, row), 766 | "error": (data) => { 767 | console.log("ERROR L596"); 768 | console.log(data); 769 | } 770 | }); 771 | } 772 | 773 | function showEpisodeInfo(data, row) { 774 | const streams = data.MediaContainer.Metadata[0].Media[0].Part[0].Stream; 775 | const partId = data.MediaContainer.Metadata[0].Media[0].Part[0].id; 776 | 777 | $(row).siblings().removeClass("table-active"); 778 | $(row).addClass("table-active"); 779 | 780 | $("#audioTable tbody").empty(); 781 | $("#subtitleTable tbody").empty(); 782 | 783 | // We need to keep track if any subtitles are selected - if not, then we need to make the subtitle row table-active 784 | let subtitlesChosen = false; 785 | 786 | for (let i = 0; i < streams.length; i++) { 787 | if (streams[i].streamType == 2) { 788 | let rowHTML = ` 789 | ${streams[i].displayTitle} 790 | ${streams[i].title} 791 | ${streams[i].language} 792 | ${streams[i].languageCode} 793 | `; 794 | $("#audioTable tbody").append(rowHTML); 795 | } 796 | else if (streams[i].streamType == 3) { 797 | if (streams[i].selected) subtitlesChosen = true; 798 | let rowHTML = ` 799 | ${streams[i].displayTitle} 800 | ${streams[i].title} 801 | ${streams[i].language} 802 | ${streams[i].languageCode} 803 | `; 804 | $("#subtitleTable tbody").append(rowHTML); 805 | } 806 | } 807 | 808 | // Append the "No Subtitles" row to the top of the tracks table 809 | let noSubsRow = ` 810 | No Subtitles 811 | -- 812 | -- 813 | -- 814 | `; 815 | $("#subtitleTable tbody").prepend(noSubsRow); 816 | 817 | // Scroll to the table 818 | document.querySelector('#audioTable').scrollIntoView({ 819 | behavior: 'smooth' 820 | }); 821 | } 822 | 823 | async function setAudioStream(partsId, streamId, row) { 824 | let singleEpisode = $("#singleEpisode").prop("checked"); 825 | let singleSeason = $("#singleSeason").prop("checked"); 826 | // Need these 2 variables and function for progress bar 827 | let currentProgress = 0; 828 | let maxProgress = 0; 829 | 830 | if (singleEpisode) { 831 | $.ajax({ 832 | "url": `${plexUrl}/library/parts/${partsId}?audioStreamID=${streamId}&allParts=1`, 833 | "method": "POST", 834 | "headers": { 835 | "X-Plex-Token": plexToken, 836 | "Accept": "application/json" 837 | }, 838 | "success": (data) => { 839 | $(row).siblings().removeClass("table-active"); 840 | $(row).addClass("table-active").addClass("success-transition"); 841 | setTimeout(() => { 842 | $(row).removeClass('success-transition'); 843 | }, 1750); 844 | // Show the toast 845 | let audioTrackName = $(row).find('td.name')[0].innerText; 846 | $('#successToast .toast-body').html( `Audio track successfully updated to ${audioTrackName}`); 847 | $('#successToast').toast('show'); 848 | }, 849 | "error": (data) => { 850 | console.log("ERROR L670"); 851 | console.log(data); 852 | } 853 | }); 854 | } 855 | else { 856 | // Show the modal to set progress 857 | $('#progressModal #progressModalTitle').empty(); 858 | $('#progressModal #progressModalTitle').text(`Processing Audio Changes`); 859 | $('#progressModal #modalBodyText').empty(); 860 | $('#progressModal #modalBodyText').append(``); 868 | $('#progressModal').modal(); 869 | 870 | let promiseConstructors = []; // This will hold the details that will then be added to the full promises in matchPromises 871 | let matchPromises = []; // This will store the promises to change the audio for given files. It means we can run in parallel and await them all 872 | let searchTitle = ($(".title", row).text() == "undefined") ? undefined : $(".title", row).text(); 873 | let searchName = ($(".name", row).text() == "undefined") ? undefined : $(".name", row).text(); 874 | let searchLanguage = ($(".language", row).text() == "undefined") ? undefined : $(".language", row).text(); 875 | let searchCode = ($(".code", row).text() == "undefined") ? undefined : $(".code", row).text(); 876 | 877 | // We have the Seasons Ids stored in seasonsList, so iterate over them to get all the episodes 878 | let episodeList = []; 879 | if (singleSeason) { 880 | // If the "Single Season" button is selected, we only want to change the current season's episodes 881 | let seasonEpisodes = await $.ajax({ 882 | "url": `${plexUrl}/library/metadata/${seasonId}/children`, 883 | "method": "GET", 884 | "headers": { 885 | "X-Plex-Token": plexToken, 886 | "Accept": "application/json" 887 | } 888 | }); 889 | for (let k = 0; k < seasonEpisodes.MediaContainer.Metadata.length; k++) { 890 | episodeList.push(seasonEpisodes.MediaContainer.Metadata[k].ratingKey); 891 | } 892 | } else { 893 | // Else we want to get all the episodes from every season 894 | for (let i = 0; i < seasonsList.length; i++) { 895 | let seasonEpisodes = await $.ajax({ 896 | "url": `${plexUrl}/library/metadata/${seasonsList[i]}/children`, 897 | "method": "GET", 898 | "headers": { 899 | "X-Plex-Token": plexToken, 900 | "Accept": "application/json" 901 | } 902 | }); 903 | for (let j = 0; j < seasonEpisodes.MediaContainer.Metadata.length; j++) { 904 | episodeList.push(seasonEpisodes.MediaContainer.Metadata[j].ratingKey); 905 | } 906 | } 907 | } 908 | 909 | // Set the progress bar to have a certain length 910 | maxProgress = episodeList.length; 911 | $('#progressBar').attr('aria-valuemax', maxProgress); 912 | // We have the episodes in episodeList, now we need to go through each one and see what streams are available 913 | for (let i = 0; i < episodeList.length; i++) { 914 | // Update the progressbar 915 | currentProgress++; 916 | const calculatedWidth = (currentProgress / maxProgress) * 100; 917 | $('#progressBar').width(`${calculatedWidth}%`); 918 | $('#progressBar').attr('aria-valuenow', currentProgress); 919 | 920 | let episodeData = await $.ajax({ 921 | "url": `${plexUrl}/library/metadata/${episodeList[i]}`, 922 | "method": "GET", 923 | "headers": { 924 | "X-Plex-Token": plexToken, 925 | "Accept": "application/json" 926 | } 927 | }); 928 | const seasonNumber = episodeData.MediaContainer.Metadata[0].parentIndex; 929 | const episodeNumber = episodeData.MediaContainer.Metadata[0].index; 930 | const episodePartId = episodeData.MediaContainer.Metadata[0].Media[0].Part[0].id; 931 | const episodeStreams = episodeData.MediaContainer.Metadata[0].Media[0].Part[0].Stream; 932 | 933 | // Loop through each audio stream and check for any matches using the searchTitle, searchName, searchLanguage, searchCode 934 | let hasMatch = false; 935 | let matchType = ""; 936 | let potentialMatches = []; 937 | let selectedTrack = { 938 | "matchId": "", 939 | "matchLevel": 0, 940 | "matchName": "" 941 | }; 942 | let bestMatch; 943 | 944 | for (let j = 0; j < episodeStreams.length; j++) { 945 | // Audio streams are streamType 2, so we only care about that 946 | if (episodeStreams[j].streamType == "2") { 947 | // If EVERYTHING is a match, even if they are "undefined" then select it 948 | if ((episodeStreams[j].title == searchTitle) && (episodeStreams[j].displayTitle == searchName) && (episodeStreams[j].language == searchLanguage) && (episodeStreams[j].languageCode == searchCode)) { 949 | if (episodeStreams[j].selected == true) { 950 | selectedTrack.matchId = episodeStreams[j].id; 951 | selectedTrack.matchLevel = 6; 952 | selectedTrack.matchName = episodeStreams[j].displayTitle; 953 | } 954 | else { 955 | potentialMatches.push({ 956 | "matchId": episodeStreams[j].id, 957 | "matchLevel": 6, 958 | "matchName": episodeStreams[j].displayTitle 959 | }); 960 | } 961 | } 962 | // If the displayTitle and title are the same, we have an instant match (also rule out any undefined matches) 963 | else if ((episodeStreams[j].title == searchTitle) && (episodeStreams[j].displayTitle == searchName) && (episodeStreams[j].title != "undefined") && (episodeStreams[j].displayTitle != "undefined")) { 964 | if (episodeStreams[j].selected == true) { 965 | selectedTrack.matchId = episodeStreams[j].id; 966 | selectedTrack.matchLevel = 5; 967 | selectedTrack.matchName = episodeStreams[j].displayTitle; 968 | } 969 | else { 970 | potentialMatches.push({ 971 | "matchId": episodeStreams[j].id, 972 | "matchLevel": 5, 973 | "matchName": episodeStreams[j].displayTitle 974 | }); 975 | } 976 | } 977 | // If the titles are the same (rule out undefined match) 978 | else if ((episodeStreams[j].title == searchTitle) && (episodeStreams[j].title != "undefined")) { 979 | if (episodeStreams[j].selected == true) { 980 | selectedTrack.matchId = episodeStreams[j].id; 981 | selectedTrack.matchLevel = 4; 982 | selectedTrack.matchName = episodeStreams[j].displayTitle; 983 | } 984 | else { 985 | potentialMatches.push({ 986 | "matchId": episodeStreams[j].id, 987 | "matchLevel": 4, 988 | "matchName": episodeStreams[j].displayTitle 989 | }); 990 | } 991 | } 992 | // If the names are the same (rule out undefined match) 993 | else if ((episodeStreams[j].displayTitle == searchName) && (episodeStreams[j].displayTitle != "undefined")) { 994 | if (episodeStreams[j].selected == true) { 995 | selectedTrack.matchId = episodeStreams[j].id; 996 | selectedTrack.matchLevel = 3; 997 | selectedTrack.matchName = episodeStreams[j].displayTitle; 998 | } 999 | else { 1000 | potentialMatches.push({ 1001 | "matchId": episodeStreams[j].id, 1002 | "matchLevel": 3, 1003 | "matchName": episodeStreams[j].displayTitle 1004 | }); 1005 | } 1006 | } 1007 | // If the languages are the same (rule out undefined match) 1008 | else if ((episodeStreams[j].language == searchLanguage) && (episodeStreams[j].language != "undefined")) { 1009 | if (episodeStreams[j].selected == true) { 1010 | selectedTrack.matchId = episodeStreams[j].id; 1011 | selectedTrack.matchLevel = 2; 1012 | selectedTrack.matchName = episodeStreams[j].displayTitle; 1013 | } 1014 | else { 1015 | potentialMatches.push({ 1016 | "matchId": episodeStreams[j].id, 1017 | "matchLevel": 2, 1018 | "matchName": episodeStreams[j].displayTitle 1019 | }); 1020 | } 1021 | } 1022 | // If the language codes are the same (rule out undefined match) 1023 | else if ((episodeStreams[j].languageCode == searchCode) && (episodeStreams[j].languageCode != "undefined")) { 1024 | if (episodeStreams[j].selected == true) { 1025 | selectedTrack.matchId = episodeStreams[j].id; 1026 | selectedTrack.matchLevel = 1; 1027 | selectedTrack.matchName = episodeStreams[j].displayTitle; 1028 | } 1029 | else { 1030 | potentialMatches.push({ 1031 | "matchId": episodeStreams[j].id, 1032 | "matchLevel": 1, 1033 | "matchName": episodeStreams[j].displayTitle 1034 | }); 1035 | } 1036 | } 1037 | } 1038 | } 1039 | 1040 | // If there are no potential matches, then return hasMatch = false so we can skip sending unnecessary commands to plex 1041 | if (potentialMatches.length == 0) { 1042 | hasMatch = false; 1043 | } 1044 | else { 1045 | // If there are potential matches - get the highest matchLevel (most accurate) and compare it to the currently selected track 1046 | bestMatch = potentialMatches.reduce((p, c) => p.matchLevel > c.matchLevel ? p : c); 1047 | if (bestMatch.matchLevel > selectedTrack.matchLevel) { 1048 | // By default selectedTrack.matchLevel = 0, so even if there is no selected track, this comparison will work 1049 | hasMatch = true; 1050 | if (bestMatch.matchLevel == 6) matchType = "Everything"; 1051 | else if (bestMatch.matchLevel == 5) matchType = "Name and Title"; 1052 | else if (bestMatch.matchLevel == 4) matchType = "Title"; 1053 | else if (bestMatch.matchLevel == 3) matchType = "Name"; 1054 | else if (bestMatch.matchLevel == 2) matchType = "Language"; 1055 | else if (bestMatch.matchLevel == 1) matchType = "Language Code"; 1056 | } 1057 | else { 1058 | hasMatch = false; 1059 | } 1060 | } 1061 | 1062 | if (hasMatch) { 1063 | // There is a match, so update the audio track using the newStreamId and episodePartId 1064 | promiseConstructors.push({ 1065 | "url": `${plexUrl}/library/parts/${episodePartId}?audioStreamID=${bestMatch.matchId}&allParts=1`, 1066 | "messageAppend": `S${seasonNumber}E${episodeNumber} - ${episodeData.MediaContainer.Metadata[0].title} updated with Audio Track: ${bestMatch.matchName} because of a match on ${matchType}
` 1067 | }); 1068 | } 1069 | else { 1070 | //console.log(`Episode: ${episodeData.MediaContainer.Metadata[0].title} has no match, or there is only 1 audio track`); 1071 | } 1072 | } 1073 | 1074 | // Reset the progress bar and modal text 1075 | $("#modalBodyText #modalTitleText").text("Updating matches... Please do not close this tab or refresh until the process is complete."); 1076 | maxProgress = promiseConstructors.length; 1077 | $('#progressBar').attr('aria-valuemax', maxProgress); 1078 | $('#progressBar').attr('aria-valuenow', 0); 1079 | 1080 | function futurePromise(data) { 1081 | return axios({ 1082 | "url": data.url, 1083 | "method": "POST", 1084 | "headers": { 1085 | "X-Plex-Token": plexToken, 1086 | "Accept": "application/json" 1087 | } 1088 | }).then((_result) => { 1089 | handleProgress(); 1090 | return data; 1091 | }); 1092 | } 1093 | 1094 | for (let k = 0; k < promiseConstructors.length; k++) { 1095 | let axiosPromise = futurePromise(promiseConstructors[k]); 1096 | matchPromises.push(axiosPromise); 1097 | } 1098 | 1099 | function handleProgress() { 1100 | currentProgress++; 1101 | const calculatedWidth = (currentProgress / maxProgress) * 100; 1102 | $('#progressBar').width(`${calculatedWidth}%`); 1103 | $('#progressBar').attr('aria-valuenow', currentProgress); 1104 | }; 1105 | 1106 | try { 1107 | Promise.allSettled(matchPromises).then(() => { 1108 | matchPromises.forEach(async (matchPromise) => { 1109 | await matchPromise.then((data) => { 1110 | $('#progressModal #modalBodyText').append(data.messageAppend); 1111 | $(row).siblings().removeClass("table-active"); 1112 | $(row).addClass("table-active"); 1113 | }).catch((e) => console.log(e)); 1114 | }) 1115 | }) 1116 | .then(() => { 1117 | $('#modalBodyText .alert').removeClass("alert-warning").addClass("alert-success"); 1118 | $("#modalBodyText #modalTitleText").text("Processing Complete! You can now close this popup."); 1119 | $('#modalBodyText #progressBarContainer').hide(); 1120 | }); 1121 | } 1122 | catch (e) { 1123 | console.log("ERROR L936"); 1124 | console.log(e); 1125 | } 1126 | } 1127 | } 1128 | 1129 | async function setSubtitleStream(partsId, streamId, row) { 1130 | let singleEpisode = $("#singleEpisode").prop("checked"); 1131 | let singleSeason = $("#singleSeason").prop("checked"); 1132 | // Need these 2 variables and function for progress bar 1133 | let currentProgress = 0; 1134 | let maxProgress = 0; 1135 | 1136 | if (singleEpisode) { 1137 | $.ajax({ 1138 | "url": `${plexUrl}/library/parts/${partsId}?subtitleStreamID=${streamId}&allParts=1`, 1139 | "method": "POST", 1140 | "headers": { 1141 | "X-Plex-Token": plexToken, 1142 | "Accept": "application/json" 1143 | }, 1144 | "success": (data) => { 1145 | $(row).siblings().removeClass("table-active"); 1146 | $(row).addClass("table-active").addClass("success-transition"); 1147 | setTimeout(() => { 1148 | $(row).removeClass('success-transition'); 1149 | }, 1750); 1150 | // Show the toast 1151 | let subtitleTrackName = $(row).find('td.name')[0].innerText; 1152 | $('#successToast .toast-body').html( `Subtitle track successfully updated to ${subtitleTrackName}`); 1153 | $('#successToast').toast('show'); 1154 | }, 1155 | "error": (data) => { 1156 | console.log("ERROR L965"); 1157 | console.log(data); 1158 | } 1159 | }); 1160 | } 1161 | else { 1162 | // Show the modal to set progress 1163 | $('#progressModal #progressModalTitle').empty(); 1164 | $('#progressModal #progressModalTitle').text(`Processing Subtitle Changes`); 1165 | $('#progressModal #modalBodyText').empty(); 1166 | $('#progressModal #modalBodyText').append(``); 1174 | $('#progressModal').modal(); 1175 | 1176 | let promiseConstructors = []; // This will hold the details that will then be added to the full promises in matchPromises 1177 | let matchPromises = []; // This will store the promises to change the audio for given files. It means we can run in parallel and await them all 1178 | let searchTitle = ($(".title", row).text() == "undefined") ? undefined : $(".title", row).text(); 1179 | let searchName = ($(".name", row).text() == "undefined") ? undefined : $(".name", row).text(); 1180 | let searchLanguage = ($(".language", row).text() == "undefined") ? undefined : $(".language", row).text(); 1181 | let searchCode = ($(".code", row).text() == "undefined") ? undefined : $(".code", row).text(); 1182 | 1183 | // We have the Seasons Ids stored in seasonsList, so iterate over them to get all the episodes 1184 | let episodeList = []; 1185 | if (singleSeason) { 1186 | // If the "Single Season" button is selected, we only want to change the current season's episodes 1187 | let seasonEpisodes = await $.ajax({ 1188 | "url": `${plexUrl}/library/metadata/${seasonId}/children`, 1189 | "method": "GET", 1190 | "headers": { 1191 | "X-Plex-Token": plexToken, 1192 | "Accept": "application/json" 1193 | } 1194 | }); 1195 | for (let k = 0; k < seasonEpisodes.MediaContainer.Metadata.length; k++) { 1196 | episodeList.push(seasonEpisodes.MediaContainer.Metadata[k].ratingKey); 1197 | } 1198 | } else { 1199 | // Else we want to get all the episodes from every season 1200 | for (let i = 0; i < seasonsList.length; i++) { 1201 | let seasonEpisodes = await $.ajax({ 1202 | "url": `${plexUrl}/library/metadata/${seasonsList[i]}/children`, 1203 | "method": "GET", 1204 | "headers": { 1205 | "X-Plex-Token": plexToken, 1206 | "Accept": "application/json" 1207 | } 1208 | }); 1209 | for (let j = 0; j < seasonEpisodes.MediaContainer.Metadata.length; j++) { 1210 | episodeList.push(seasonEpisodes.MediaContainer.Metadata[j].ratingKey); 1211 | } 1212 | } 1213 | } 1214 | 1215 | // Set the progress bar to have a certain length 1216 | maxProgress = episodeList.length; 1217 | $('#progressBar').attr('aria-valuemax', maxProgress); 1218 | // We have the episodes in episodeList, now we need to go through each one and see what streams are available 1219 | for (let i = 0; i < episodeList.length; i++) { 1220 | // Update the progressbar 1221 | currentProgress++; 1222 | const calculatedWidth = (currentProgress / maxProgress) * 100; 1223 | $('#progressBar').width(`${calculatedWidth}%`); 1224 | $('#progressBar').attr('aria-valuenow', currentProgress); 1225 | 1226 | let episodeData = await $.ajax({ 1227 | "url": `${plexUrl}/library/metadata/${episodeList[i]}`, 1228 | "method": "GET", 1229 | "headers": { 1230 | "X-Plex-Token": plexToken, 1231 | "Accept": "application/json" 1232 | } 1233 | }); 1234 | const seasonNumber = episodeData.MediaContainer.Metadata[0].parentIndex; 1235 | const episodeNumber = episodeData.MediaContainer.Metadata[0].index; 1236 | const episodePartId = episodeData.MediaContainer.Metadata[0].Media[0].Part[0].id; 1237 | const episodeStreams = episodeData.MediaContainer.Metadata[0].Media[0].Part[0].Stream; 1238 | 1239 | // If streamId = 0 then we are unsetting the subtitles. Otherwise we need to find the best matches for each episode 1240 | if (streamId != 0) { 1241 | // Loop through each subtitle stream and check for any matches using the searchTitle, searchName, searchLanguage, searchCode 1242 | let hasMatch = false; 1243 | let matchType = ""; 1244 | let potentialMatches = []; 1245 | let selectedTrack = { 1246 | "matchId": "", 1247 | "matchLevel": 0, 1248 | "matchName": "" 1249 | }; 1250 | let bestMatch; 1251 | 1252 | for (let j = 0; j < episodeStreams.length; j++) { 1253 | // Subtitle streams are streamType 3, so we only care about that 1254 | if (episodeStreams[j].streamType == "3") { 1255 | // If EVERYTHING is a match, even if they are "undefined" then select it 1256 | if ((episodeStreams[j].title == searchTitle) && (episodeStreams[j].displayTitle == searchName) && (episodeStreams[j].language == searchLanguage) && (episodeStreams[j].languageCode == searchCode)) { 1257 | if (episodeStreams[j].selected == true) { 1258 | selectedTrack.matchId = episodeStreams[j].id; 1259 | selectedTrack.matchLevel = 6; 1260 | selectedTrack.matchName = episodeStreams[j].displayTitle; 1261 | } 1262 | else { 1263 | potentialMatches.push({ 1264 | "matchId": episodeStreams[j].id, 1265 | "matchLevel": 6, 1266 | "matchName": episodeStreams[j].displayTitle 1267 | }); 1268 | } 1269 | } 1270 | // If the displayTitle and title are the same, we have an instant match (also rule out any undefined matches) 1271 | else if ((episodeStreams[j].title == searchTitle) && (episodeStreams[j].displayTitle == searchName) && (episodeStreams[j].title != "undefined") && (episodeStreams[j].displayTitle != "undefined")) { 1272 | if (episodeStreams[j].selected == true) { 1273 | selectedTrack.matchId = episodeStreams[j].id; 1274 | selectedTrack.matchLevel = 5; 1275 | selectedTrack.matchName = episodeStreams[j].displayTitle; 1276 | } 1277 | else { 1278 | potentialMatches.push({ 1279 | "matchId": episodeStreams[j].id, 1280 | "matchLevel": 5, 1281 | "matchName": episodeStreams[j].displayTitle 1282 | }); 1283 | } 1284 | } 1285 | // If the titles are the same (rule out undefined match) 1286 | else if ((episodeStreams[j].title == searchTitle) && (episodeStreams[j].title != "undefined")) { 1287 | if (episodeStreams[j].selected == true) { 1288 | selectedTrack.matchId = episodeStreams[j].id; 1289 | selectedTrack.matchLevel = 4; 1290 | selectedTrack.matchName = episodeStreams[j].displayTitle; 1291 | } 1292 | else { 1293 | potentialMatches.push({ 1294 | "matchId": episodeStreams[j].id, 1295 | "matchLevel": 4, 1296 | "matchName": episodeStreams[j].displayTitle 1297 | }); 1298 | } 1299 | } 1300 | // If the names are the same (rule out undefined match) 1301 | else if ((episodeStreams[j].displayTitle == searchName) && (episodeStreams[j].displayTitle != "undefined")) { 1302 | if (episodeStreams[j].selected == true) { 1303 | selectedTrack.matchId = episodeStreams[j].id; 1304 | selectedTrack.matchLevel = 3; 1305 | selectedTrack.matchName = episodeStreams[j].displayTitle; 1306 | } 1307 | else { 1308 | potentialMatches.push({ 1309 | "matchId": episodeStreams[j].id, 1310 | "matchLevel": 3, 1311 | "matchName": episodeStreams[j].displayTitle 1312 | }); 1313 | } 1314 | } 1315 | // If the languages are the same (rule out undefined match) 1316 | else if ((episodeStreams[j].language == searchLanguage) && (episodeStreams[j].language != "undefined")) { 1317 | if (episodeStreams[j].selected == true) { 1318 | selectedTrack.matchId = episodeStreams[j].id; 1319 | selectedTrack.matchLevel = 2; 1320 | selectedTrack.matchName = episodeStreams[j].displayTitle; 1321 | } 1322 | else { 1323 | potentialMatches.push({ 1324 | "matchId": episodeStreams[j].id, 1325 | "matchLevel": 2, 1326 | "matchName": episodeStreams[j].displayTitle 1327 | }); 1328 | } 1329 | } 1330 | // If the language codes are the same (rule out undefined match) 1331 | else if ((episodeStreams[j].languageCode == searchCode) && (episodeStreams[j].languageCode != "undefined")) { 1332 | if (episodeStreams[j].selected == true) { 1333 | selectedTrack.matchId = episodeStreams[j].id; 1334 | selectedTrack.matchLevel = 1; 1335 | selectedTrack.matchName = episodeStreams[j].displayTitle; 1336 | } 1337 | else { 1338 | potentialMatches.push({ 1339 | "matchId": episodeStreams[j].id, 1340 | "matchLevel": 1, 1341 | "matchName": episodeStreams[j].displayTitle 1342 | }); 1343 | } 1344 | } 1345 | } 1346 | } 1347 | 1348 | // If there are no potential matches, then return hasMatch = false so we can skip sending unnecessary commands to plex 1349 | if (potentialMatches.length == 0) { 1350 | hasMatch = false; 1351 | } 1352 | else { 1353 | // If there are potential matches - get the highest matchLevel (most accurate) and compare it to the currently selected track 1354 | bestMatch = potentialMatches.reduce((p, c) => p.matchLevel > c.matchLevel ? p : c); 1355 | if (bestMatch.matchLevel > selectedTrack.matchLevel) { 1356 | // By default selectedTrack.matchLevel = 0, so even if there is no selected track, this comparison will work 1357 | hasMatch = true; 1358 | if (bestMatch.matchLevel == 6) matchType = "Everything"; 1359 | else if (bestMatch.matchLevel == 5) matchType = "Name and Title"; 1360 | else if (bestMatch.matchLevel == 4) matchType = "Title"; 1361 | else if (bestMatch.matchLevel == 3) matchType = "Name"; 1362 | else if (bestMatch.matchLevel == 2) matchType = "Language"; 1363 | else if (bestMatch.matchLevel == 1) matchType = "Language Code"; 1364 | } 1365 | else { 1366 | hasMatch = false; 1367 | } 1368 | } 1369 | 1370 | if (hasMatch) { 1371 | // There is a match, so update the subtitle track using the currentMatch.matchId and episodePartId 1372 | promiseConstructors.push({ 1373 | "url": `${plexUrl}/library/parts/${episodePartId}?subtitleStreamID=${bestMatch.matchId}&allParts=1`, 1374 | "messageAppend": `S${seasonNumber}E${episodeNumber} - ${episodeData.MediaContainer.Metadata[0].title} updated with Subtitle Track: ${bestMatch.matchName} because of a match on ${matchType}
` 1375 | }); 1376 | } 1377 | else { 1378 | //console.log(`Episode: ${episodeData.MediaContainer.Metadata[0].title} has no match, or there is only 1 subtitle track`); 1379 | } 1380 | } 1381 | else { 1382 | // streamId = 0, which means we just want to set the subtitleStreamID = 0 for every episode 1383 | promiseConstructors.push({ 1384 | "url": `${plexUrl}/library/parts/${episodePartId}?subtitleStreamID=0&allParts=1`, 1385 | "messageAppend": `S${seasonNumber}E${episodeNumber} - ${episodeData.MediaContainer.Metadata[0].title} has had the subtitles deselected
` 1386 | }); 1387 | } 1388 | } 1389 | 1390 | // Reset the progress bar and modal text 1391 | $("#modalBodyText #modalTitleText").text("Updating matches... Please do not close this tab or refresh until the process is complete."); 1392 | maxProgress = promiseConstructors.length; 1393 | $('#progressBar').attr('aria-valuemax', maxProgress); 1394 | $('#progressBar').attr('aria-valuenow', 0); 1395 | 1396 | function futurePromise(data) { 1397 | return axios({ 1398 | "url": data.url, 1399 | "method": "POST", 1400 | "headers": { 1401 | "X-Plex-Token": plexToken, 1402 | "Accept": "application/json" 1403 | } 1404 | }).then((_result) => { 1405 | handleProgress(); 1406 | return data; 1407 | }).catch((e) => console.log(e)); 1408 | } 1409 | 1410 | for (let k = 0; k < promiseConstructors.length; k++) { 1411 | let axiosPromise = futurePromise(promiseConstructors[k]); 1412 | matchPromises.push(axiosPromise); 1413 | } 1414 | 1415 | function handleProgress() { 1416 | currentProgress++; 1417 | const calculatedWidth = (currentProgress / maxProgress) * 100; 1418 | $('#progressBar').width(`${calculatedWidth}%`); 1419 | $('#progressBar').attr('aria-valuenow', currentProgress); 1420 | }; 1421 | 1422 | try { 1423 | Promise.allSettled(matchPromises).then(() => { 1424 | matchPromises.forEach(async (matchPromise) => { 1425 | await matchPromise.then((data) => { 1426 | $('#progressModal #modalBodyText').append(data.messageAppend); 1427 | $(row).siblings().removeClass("table-active"); 1428 | $(row).addClass("table-active"); 1429 | }).catch((e) => console.log(e)); 1430 | }) 1431 | }) 1432 | .then(() => { 1433 | $('#modalBodyText .alert').removeClass("alert-warning").addClass("alert-success"); 1434 | $("#modalBodyText #modalTitleText").text("Processing Complete! You can now close this popup."); 1435 | $('#modalBodyText #progressBarContainer').hide(); 1436 | }); 1437 | } 1438 | catch (e) { 1439 | console.log("ERROR L1241"); 1440 | console.log(e); 1441 | } 1442 | } 1443 | } 1444 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "PASTA | Audio & Subtitle Track Changer for Plex", 3 | "short_name": "PASTA", 4 | "icons": [ 5 | { 6 | "src": "images/android-chrome-192-maskable.png", 7 | "sizes": "192x192", 8 | "type": "image/png", 9 | "purpose": "any maskable" 10 | }, 11 | { 12 | "src": "images/android-chrome-512.png", 13 | "sizes": "512x512", 14 | "type": "image/png" 15 | } 16 | ], 17 | "theme_color": "#000000", 18 | "background_color": "#000000", 19 | "display": "fullscreen", 20 | "start_url": "index.html" 21 | } --------------------------------------------------------------------------------