├── .editorconfig ├── .eslintignore ├── .eslintrc.yml ├── .gitattributes ├── .github ├── renovate.json └── workflows │ ├── pull_request.yml │ └── release.yml ├── .gitignore ├── .npmignore ├── .npmrc ├── .prettierrc ├── CONTRIBUTORS.md ├── LICENSE ├── README.md ├── babel.config.js ├── jest.config.js ├── jest.setup.js ├── package-lock.json ├── package.json ├── src ├── apiClient.js ├── apiClientCore.js ├── appStorage.js ├── connectionManager.js ├── credentials.js ├── events.js ├── index.js └── promiseDelay.js ├── tests ├── apiClient.test.js ├── events.test.js └── index.test.js └── webpack.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | end_of_line = lf 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage 4 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | es6: true 3 | browser: true 4 | es2017: true 5 | es2020: true 6 | 7 | extends: 8 | - "eslint:recommended" 9 | - "plugin:promise/recommended" 10 | - "plugin:import/errors" 11 | - "plugin:import/warnings" 12 | 13 | plugins: 14 | - "promise" 15 | - "import" 16 | 17 | rules: 18 | promise/always-return: ["warn"] 19 | promise/catch-or-return: ["warn"] 20 | promise/no-return-wrap: ["warn"] 21 | 22 | parserOptions: 23 | ecmaVersion: 2020 24 | sourceType: module 25 | 26 | overrides: 27 | - files: 28 | - "src/**/*.js" 29 | rules: 30 | no-var: ["warn"] 31 | no-undef: ["warn"] 32 | prefer-rest-params: ["warn"] 33 | prefer-const: ["warn"] 34 | no-unused-vars: ["warn"] 35 | - files: 36 | "tests/**/*.js" 37 | env: 38 | jest: true 39 | extends: 40 | - "plugin:jest/recommended" 41 | - "plugin:jest/style" 42 | plugins: 43 | - jest 44 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | CONTRIBUTORS.md merge=union 2 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "packageRules": [ 3 | { 4 | "matchDepTypes": ["devDependencies"], 5 | "groupName": "development dependencies", 6 | "groupSlug": "dev-deps" 7 | }, 8 | { 9 | "matchDepTypes": ["dependencies"], 10 | "groupName": "dependencies", 11 | "groupSlug": "deps" 12 | }, 13 | { 14 | "matchDepTypes": ["action"], 15 | "groupName": "CI dependencies", 16 | "groupSlug": "ci-deps" 17 | } 18 | ], 19 | "dependencyDashboard": false, 20 | "ignoreDeps": ["npm", "node"], 21 | "lockFileMaintenance": { 22 | "enabled": false 23 | }, 24 | "enabledManagers": ["npm", "github-actions"], 25 | "labels": ["dependencies"], 26 | "rebaseWhen": "behind-base-branch", 27 | "rangeStrategy": "pin" 28 | } 29 | -------------------------------------------------------------------------------- /.github/workflows/pull_request.yml: -------------------------------------------------------------------------------- 1 | name: Pull Request workflow 🔀 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | workflow_dispatch: 8 | 9 | jobs: 10 | lint: 11 | name: Lint 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v3.0.2 17 | 18 | - name: Setup node environment 19 | uses: actions/setup-node@v3.2.0 20 | with: 21 | node-version: 14 22 | cache: 'npm' 23 | check-latest: true 24 | 25 | - name: Install dependencies 26 | run: npm ci --no-audit 27 | 28 | - name: Lint 29 | run: npm run lint 30 | 31 | test: 32 | name: Test 33 | runs-on: ubuntu-latest 34 | 35 | steps: 36 | - name: Checkout 37 | uses: actions/checkout@v3.0.2 38 | 39 | - name: Setup node environment 40 | uses: actions/setup-node@v3.2.0 41 | with: 42 | node-version: 14 43 | cache: 'npm' 44 | check-latest: true 45 | 46 | - name: Install dependencies 47 | run: npm ci --no-audit 48 | 49 | - name: Test 50 | run: npm run test --ci --reporters=default --reporters=jest-junit --coverage 51 | 52 | build: 53 | name: Build 54 | runs-on: ubuntu-latest 55 | 56 | steps: 57 | - name: Checkout 58 | uses: actions/checkout@v3.0.2 59 | 60 | - name: Setup node environment 61 | uses: actions/setup-node@v3.2.0 62 | with: 63 | node-version: 14 64 | cache: 'npm' 65 | check-latest: true 66 | 67 | - name: Install dependencies 68 | run: npm ci --no-audit 69 | 70 | - name: Build 71 | run: npm run build 72 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 📦 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | build: 9 | name: Publish to npmjs 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v3.0.2 15 | with: 16 | ref: 'master' 17 | 18 | - name: Setup node environment for npm 19 | uses: actions/setup-node@v3.2.0 20 | with: 21 | node-version: 14 22 | registry-url: 'https://registry.npmjs.org' 23 | cache: 'npm' 24 | check-latest: true 25 | 26 | - name: Install dependencies 27 | run: npm ci --no-audit 28 | 29 | - name: Publish to npm 30 | run: npm publish --access public 31 | env: 32 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/node,macos,linux,windows,webstorm,jetbrains,visualstudio,visualstudiocode 3 | # Edit at https://www.gitignore.io/?templates=node,macos,linux,windows,webstorm,jetbrains,visualstudio,visualstudiocode 4 | 5 | ### JetBrains ### 6 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 7 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 8 | 9 | # User-specific stuff 10 | .idea/**/workspace.xml 11 | .idea/**/tasks.xml 12 | .idea/**/usage.statistics.xml 13 | .idea/**/dictionaries 14 | .idea/**/shelf 15 | 16 | # Generated files 17 | .idea/**/contentModel.xml 18 | 19 | # Sensitive or high-churn files 20 | .idea/**/dataSources/ 21 | .idea/**/dataSources.ids 22 | .idea/**/dataSources.local.xml 23 | .idea/**/sqlDataSources.xml 24 | .idea/**/dynamic.xml 25 | .idea/**/uiDesigner.xml 26 | .idea/**/dbnavigator.xml 27 | 28 | # Gradle 29 | .idea/**/gradle.xml 30 | .idea/**/libraries 31 | 32 | # Gradle and Maven with auto-import 33 | # When using Gradle or Maven with auto-import, you should exclude module files, 34 | # since they will be recreated, and may cause churn. Uncomment if using 35 | # auto-import. 36 | # .idea/modules.xml 37 | # .idea/*.iml 38 | # .idea/modules 39 | 40 | # CMake 41 | cmake-build-*/ 42 | 43 | # Mongo Explorer plugin 44 | .idea/**/mongoSettings.xml 45 | 46 | # File-based project format 47 | *.iws 48 | 49 | # IntelliJ 50 | out/ 51 | 52 | # mpeltonen/sbt-idea plugin 53 | .idea_modules/ 54 | 55 | # JIRA plugin 56 | atlassian-ide-plugin.xml 57 | 58 | # Cursive Clojure plugin 59 | .idea/replstate.xml 60 | 61 | # Crashlytics plugin (for Android Studio and IntelliJ) 62 | com_crashlytics_export_strings.xml 63 | crashlytics.properties 64 | crashlytics-build.properties 65 | fabric.properties 66 | 67 | # Editor-based Rest Client 68 | .idea/httpRequests 69 | 70 | # Android studio 3.1+ serialized cache file 71 | .idea/caches/build_file_checksums.ser 72 | 73 | ### JetBrains Patch ### 74 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 75 | 76 | # *.iml 77 | # modules.xml 78 | # .idea/misc.xml 79 | # *.ipr 80 | 81 | # Sonarlint plugin 82 | .idea/sonarlint 83 | 84 | ### Linux ### 85 | *~ 86 | 87 | # temporary files which can be created if a process still has a handle open of a deleted file 88 | .fuse_hidden* 89 | 90 | # KDE directory preferences 91 | .directory 92 | 93 | # Linux trash folder which might appear on any partition or disk 94 | .Trash-* 95 | 96 | # .nfs files are created when an open file is removed but is still being accessed 97 | .nfs* 98 | 99 | ### macOS ### 100 | # General 101 | .DS_Store 102 | .AppleDouble 103 | .LSOverride 104 | 105 | # Icon must end with two \r 106 | Icon 107 | 108 | # Thumbnails 109 | ._* 110 | 111 | # Files that might appear in the root of a volume 112 | .DocumentRevisions-V100 113 | .fseventsd 114 | .Spotlight-V100 115 | .TemporaryItems 116 | .Trashes 117 | .VolumeIcon.icns 118 | .com.apple.timemachine.donotpresent 119 | 120 | # Directories potentially created on remote AFP share 121 | .AppleDB 122 | .AppleDesktop 123 | Network Trash Folder 124 | Temporary Items 125 | .apdisk 126 | 127 | ### Node ### 128 | # Logs 129 | logs 130 | *.log 131 | npm-debug.log* 132 | yarn-debug.log* 133 | yarn-error.log* 134 | 135 | # Runtime data 136 | pids 137 | *.pid 138 | *.seed 139 | *.pid.lock 140 | 141 | # Directory for instrumented libs generated by jscoverage/JSCover 142 | lib-cov 143 | 144 | # Coverage directory used by tools like istanbul 145 | coverage 146 | 147 | # nyc test coverage 148 | .nyc_output 149 | 150 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 151 | .grunt 152 | 153 | # Bower dependency directory (https://bower.io/) 154 | bower_components 155 | 156 | # node-waf configuration 157 | .lock-wscript 158 | 159 | # Compiled binary addons (https://nodejs.org/api/addons.html) 160 | build/Release 161 | 162 | # Dependency directories 163 | node_modules/ 164 | jspm_packages/ 165 | 166 | # TypeScript v1 declaration files 167 | typings/ 168 | 169 | # Optional npm cache directory 170 | .npm 171 | 172 | # Optional eslint cache 173 | .eslintcache 174 | 175 | # Optional REPL history 176 | .node_repl_history 177 | 178 | # Output of 'npm pack' 179 | *.tgz 180 | 181 | # Yarn Integrity file 182 | .yarn-integrity 183 | 184 | # dotenv environment variables file 185 | .env 186 | .env.test 187 | 188 | # parcel-bundler cache (https://parceljs.org/) 189 | .cache 190 | 191 | # next.js build output 192 | .next 193 | 194 | # nuxt.js build output 195 | .nuxt 196 | 197 | # vuepress build output 198 | .vuepress/dist 199 | 200 | # Serverless directories 201 | .serverless/ 202 | 203 | # FuseBox cache 204 | .fusebox/ 205 | 206 | # DynamoDB Local files 207 | .dynamodb/ 208 | 209 | ### VisualStudioCode ### 210 | .vscode/* 211 | !.vscode/settings.json 212 | !.vscode/tasks.json 213 | !.vscode/launch.json 214 | !.vscode/extensions.json 215 | 216 | ### VisualStudioCode Patch ### 217 | # Ignore all local history of files 218 | .history 219 | 220 | ### WebStorm ### 221 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 222 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 223 | 224 | # User-specific stuff 225 | 226 | # Generated files 227 | 228 | # Sensitive or high-churn files 229 | 230 | # Gradle 231 | 232 | # Gradle and Maven with auto-import 233 | # When using Gradle or Maven with auto-import, you should exclude module files, 234 | # since they will be recreated, and may cause churn. Uncomment if using 235 | # auto-import. 236 | # .idea/modules.xml 237 | # .idea/*.iml 238 | # .idea/modules 239 | 240 | # CMake 241 | 242 | # Mongo Explorer plugin 243 | 244 | # File-based project format 245 | 246 | # IntelliJ 247 | 248 | # mpeltonen/sbt-idea plugin 249 | 250 | # JIRA plugin 251 | 252 | # Cursive Clojure plugin 253 | 254 | # Crashlytics plugin (for Android Studio and IntelliJ) 255 | 256 | # Editor-based Rest Client 257 | 258 | # Android studio 3.1+ serialized cache file 259 | 260 | ### WebStorm Patch ### 261 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 262 | 263 | # *.iml 264 | # modules.xml 265 | # .idea/misc.xml 266 | # *.ipr 267 | 268 | # Sonarlint plugin 269 | 270 | ### Windows ### 271 | # Windows thumbnail cache files 272 | Thumbs.db 273 | ehthumbs.db 274 | ehthumbs_vista.db 275 | 276 | # Dump file 277 | *.stackdump 278 | 279 | # Folder config file 280 | [Dd]esktop.ini 281 | 282 | # Recycle Bin used on file shares 283 | $RECYCLE.BIN/ 284 | 285 | # Windows Installer files 286 | *.cab 287 | *.msi 288 | *.msix 289 | *.msm 290 | *.msp 291 | 292 | # Windows shortcuts 293 | *.lnk 294 | 295 | ### VisualStudio ### 296 | ## Ignore Visual Studio temporary files, build results, and 297 | ## files generated by popular Visual Studio add-ons. 298 | ## 299 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 300 | 301 | # User-specific files 302 | *.rsuser 303 | *.suo 304 | *.user 305 | *.userosscache 306 | *.sln.docstates 307 | 308 | # User-specific files (MonoDevelop/Xamarin Studio) 309 | *.userprefs 310 | 311 | # Build results 312 | [Dd]ebug/ 313 | [Dd]ebugPublic/ 314 | [Rr]elease/ 315 | [Rr]eleases/ 316 | x64/ 317 | x86/ 318 | [Aa][Rr][Mm]/ 319 | [Aa][Rr][Mm]64/ 320 | bld/ 321 | [Bb]in/ 322 | [Oo]bj/ 323 | [Ll]og/ 324 | 325 | # Visual Studio 2015/2017 cache/options directory 326 | .vs/ 327 | # Uncomment if you have tasks that create the project's static files in wwwroot 328 | #wwwroot/ 329 | 330 | # Visual Studio 2017 auto generated files 331 | Generated\ Files/ 332 | 333 | # MSTest test Results 334 | [Tt]est[Rr]esult*/ 335 | [Bb]uild[Ll]og.* 336 | 337 | # NUNIT 338 | *.VisualState.xml 339 | TestResult.xml 340 | 341 | # JUnit 342 | junit.xml 343 | 344 | # Build Results of an ATL Project 345 | [Dd]ebugPS/ 346 | [Rr]eleasePS/ 347 | dlldata.c 348 | 349 | # Benchmark Results 350 | BenchmarkDotNet.Artifacts/ 351 | 352 | # .NET Core 353 | project.lock.json 354 | project.fragment.lock.json 355 | artifacts/ 356 | 357 | # StyleCop 358 | StyleCopReport.xml 359 | 360 | # Files built by Visual Studio 361 | *_i.c 362 | *_p.c 363 | *_h.h 364 | *.ilk 365 | *.meta 366 | *.obj 367 | *.iobj 368 | *.pch 369 | *.pdb 370 | *.ipdb 371 | *.pgc 372 | *.pgd 373 | *.rsp 374 | *.sbr 375 | *.tlb 376 | *.tli 377 | *.tlh 378 | *.tmp 379 | *.tmp_proj 380 | *_wpftmp.csproj 381 | *.vspscc 382 | *.vssscc 383 | .builds 384 | *.pidb 385 | *.svclog 386 | *.scc 387 | 388 | # Chutzpah Test files 389 | _Chutzpah* 390 | 391 | # Visual C++ cache files 392 | ipch/ 393 | *.aps 394 | *.ncb 395 | *.opendb 396 | *.opensdf 397 | *.sdf 398 | *.cachefile 399 | *.VC.db 400 | *.VC.VC.opendb 401 | 402 | # Visual Studio profiler 403 | *.psess 404 | *.vsp 405 | *.vspx 406 | *.sap 407 | 408 | # Visual Studio Trace Files 409 | *.e2e 410 | 411 | # TFS 2012 Local Workspace 412 | $tf/ 413 | 414 | # Guidance Automation Toolkit 415 | *.gpState 416 | 417 | # ReSharper is a .NET coding add-in 418 | _ReSharper*/ 419 | *.[Rr]e[Ss]harper 420 | *.DotSettings.user 421 | 422 | # JustCode is a .NET coding add-in 423 | .JustCode 424 | 425 | # TeamCity is a build add-in 426 | _TeamCity* 427 | 428 | # DotCover is a Code Coverage Tool 429 | *.dotCover 430 | 431 | # AxoCover is a Code Coverage Tool 432 | .axoCover/* 433 | !.axoCover/settings.json 434 | 435 | # Visual Studio code coverage results 436 | *.coverage 437 | *.coveragexml 438 | 439 | # NCrunch 440 | _NCrunch_* 441 | .*crunch*.local.xml 442 | nCrunchTemp_* 443 | 444 | # MightyMoose 445 | *.mm.* 446 | AutoTest.Net/ 447 | 448 | # Web workbench (sass) 449 | .sass-cache/ 450 | 451 | # Installshield output folder 452 | [Ee]xpress/ 453 | 454 | # DocProject is a documentation generator add-in 455 | DocProject/buildhelp/ 456 | DocProject/Help/*.HxT 457 | DocProject/Help/*.HxC 458 | DocProject/Help/*.hhc 459 | DocProject/Help/*.hhk 460 | DocProject/Help/*.hhp 461 | DocProject/Help/Html2 462 | DocProject/Help/html 463 | 464 | # Click-Once directory 465 | publish/ 466 | 467 | # Publish Web Output 468 | *.[Pp]ublish.xml 469 | *.azurePubxml 470 | # Note: Comment the next line if you want to checkin your web deploy settings, 471 | # but database connection strings (with potential passwords) will be unencrypted 472 | *.pubxml 473 | *.publishproj 474 | 475 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 476 | # checkin your Azure Web App publish settings, but sensitive information contained 477 | # in these scripts will be unencrypted 478 | PublishScripts/ 479 | 480 | # NuGet Packages 481 | *.nupkg 482 | # The packages folder can be ignored because of Package Restore 483 | **/[Pp]ackages/* 484 | # except build/, which is used as an MSBuild target. 485 | !**/[Pp]ackages/build/ 486 | # Uncomment if necessary however generally it will be regenerated when needed 487 | #!**/[Pp]ackages/repositories.config 488 | # NuGet v3's project.json files produces more ignorable files 489 | *.nuget.props 490 | *.nuget.targets 491 | 492 | # Microsoft Azure Build Output 493 | csx/ 494 | *.build.csdef 495 | 496 | # Microsoft Azure Emulator 497 | ecf/ 498 | rcf/ 499 | 500 | # Windows Store app package directories and files 501 | AppPackages/ 502 | BundleArtifacts/ 503 | Package.StoreAssociation.xml 504 | _pkginfo.txt 505 | *.appx 506 | 507 | # Visual Studio cache files 508 | # files ending in .cache can be ignored 509 | *.[Cc]ache 510 | # but keep track of directories ending in .cache 511 | !?*.[Cc]ache/ 512 | 513 | # Others 514 | ClientBin/ 515 | ~$* 516 | *.dbmdl 517 | *.dbproj.schemaview 518 | *.jfm 519 | *.pfx 520 | *.publishsettings 521 | orleans.codegen.cs 522 | 523 | # Including strong name files can present a security risk 524 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 525 | #*.snk 526 | 527 | # Since there are multiple workflows, uncomment next line to ignore bower_components 528 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 529 | #bower_components/ 530 | # ASP.NET Core default setup: bower directory is configured as wwwroot/lib/ and bower restore is true 531 | **/wwwroot/lib/ 532 | 533 | # RIA/Silverlight projects 534 | Generated_Code/ 535 | 536 | # Backup & report files from converting an old project file 537 | # to a newer Visual Studio version. Backup files are not needed, 538 | # because we have git ;-) 539 | _UpgradeReport_Files/ 540 | Backup*/ 541 | UpgradeLog*.XML 542 | UpgradeLog*.htm 543 | ServiceFabricBackup/ 544 | *.rptproj.bak 545 | 546 | # SQL Server files 547 | *.mdf 548 | *.ldf 549 | *.ndf 550 | 551 | # Business Intelligence projects 552 | *.rdl.data 553 | *.bim.layout 554 | *.bim_*.settings 555 | *.rptproj.rsuser 556 | *- Backup*.rdl 557 | 558 | # Microsoft Fakes 559 | FakesAssemblies/ 560 | 561 | # GhostDoc plugin setting file 562 | *.GhostDoc.xml 563 | 564 | # Node.js Tools for Visual Studio 565 | .ntvs_analysis.dat 566 | 567 | # Visual Studio 6 build log 568 | *.plg 569 | 570 | # Visual Studio 6 workspace options file 571 | *.opt 572 | 573 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 574 | *.vbw 575 | 576 | # Visual Studio LightSwitch build output 577 | **/*.HTMLClient/GeneratedArtifacts 578 | **/*.DesktopClient/GeneratedArtifacts 579 | **/*.DesktopClient/ModelManifest.xml 580 | **/*.Server/GeneratedArtifacts 581 | **/*.Server/ModelManifest.xml 582 | _Pvt_Extensions 583 | 584 | # Paket dependency manager 585 | .paket/paket.exe 586 | paket-files/ 587 | 588 | # FAKE - F# Make 589 | .fake/ 590 | 591 | # JetBrains Rider 592 | .idea/ 593 | *.sln.iml 594 | 595 | # CodeRush personal settings 596 | .cr/personal 597 | 598 | # Python Tools for Visual Studio (PTVS) 599 | __pycache__/ 600 | *.pyc 601 | 602 | # Cake - Uncomment if you are using it 603 | # tools/** 604 | # !tools/packages.config 605 | 606 | # Tabs Studio 607 | *.tss 608 | 609 | # Telerik's JustMock configuration file 610 | *.jmconfig 611 | 612 | # BizTalk build output 613 | *.btp.cs 614 | *.btm.cs 615 | *.odx.cs 616 | *.xsd.cs 617 | 618 | # OpenCover UI analysis results 619 | OpenCover/ 620 | 621 | # Azure Stream Analytics local run output 622 | ASALocalRun/ 623 | 624 | # MSBuild Binary and Structured Log 625 | *.binlog 626 | 627 | # NVidia Nsight GPU debugger configuration file 628 | *.nvuser 629 | 630 | # MFractors (Xamarin productivity tool) working folder 631 | .mfractor/ 632 | 633 | # Local History for Visual Studio 634 | .localhistory/ 635 | 636 | # BeatPulse healthcheck temp database 637 | healthchecksdb 638 | 639 | # End of https://www.gitignore.io/api/node,macos,linux,windows,webstorm,jetbrains,visualstudio,visualstudiocode 640 | 641 | # Custom 642 | dist/ 643 | docs/ 644 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .gitattributes 2 | webpack.config.js 3 | yarn.lock 4 | src 5 | tests 6 | .ci 7 | .idea 8 | coverage 9 | junit.xml 10 | tsconfig.json 11 | .editorconfig 12 | .eslintignore 13 | .eslintrc.yml 14 | .prettierrc 15 | babel.config.js 16 | jest.config.js 17 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none", 3 | "tabWidth": 4, 4 | "printWidth": 120, 5 | "semi": true, 6 | "singleQuote": true 7 | } 8 | -------------------------------------------------------------------------------- /CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | # Jellyfin Contributors 2 | 3 | - [thornbill](https://github.com/thornbill) 4 | - [cvium](https://github.com/cvium) 5 | - [Oddstr13](https://github.com/oddstr13) 6 | - [Andrei Oanca](https://github.com/OancaAndrei) 7 | 8 | # Emby Contributors 9 | 10 | - [LukePulverenti](https://github.com/LukePulverenti) 11 | - [ebr11](https://github.com/ebr11) 12 | - [softworkz](https://github.com/softworkz) 13 | - [HazCod](https://github.com/HazCod) 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2019 Jellyfin Contributors 4 | 5 | Copyright (c) 2014-2018 Emby https://emby.media 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in 15 | all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | THE SOFTWARE. 24 | 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Jellyfin API Client for JavaScript

2 |

Part of the Jellyfin Project

3 | 4 | --- 5 | 6 |

7 | Logo Banner 8 |
9 |
10 | 11 | MIT License 12 | 13 | 14 | Donate 15 | 16 | 17 | Feature Requests 18 | 19 | 20 | Chat on Matrix 21 | 22 | 23 | Join our Subreddit 24 | 25 |

26 | 27 | > [!WARNING] 28 | > This library is **deprecated**. 29 | > It is recommended to use the [TypeScript SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) instead. 30 | > No future releases are planned and no new features will be supported. 31 | 32 | This library is meant to help clients written in JavaScript interact with Jellyfin's REST API. 33 | 34 | ## Compatibility 35 | 36 | This library depends on the Fetch and Promise APIs. These will be expected to be polyfilled if used in a browser that doesn't support them. 37 | 38 | ## Build Process 39 | 40 | ### Dependencies 41 | 42 | - npm 6 43 | 44 | ### Getting Started 45 | 46 | 1. Clone or download this repository 47 | 48 | ```sh 49 | git clone https://github.com/jellyfin/jellyfin-apiclient-javascript.git 50 | cd jellyfin-apiclient-javascript 51 | ``` 52 | 53 | 2. Install build dependencies in the project directory 54 | 55 | ```sh 56 | npm install 57 | ``` 58 | 59 | 3. Build the library for production 60 | 61 | ```sh 62 | npm run build 63 | ``` 64 | 65 | 4. Build the library for development 66 | 67 | ```sh 68 | npm run dev 69 | ``` 70 | 71 | ## Building Documentation 72 | 73 | This library is documented using [JSDoc](https://jsdoc.app/) style comments. Documentation can be generated in HTML format by running `npm run docs` and viewing the files in any modern browser. The resulting documentation will be saved in the `docs` directory. 74 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = api => { 2 | const isTest = api.env('test'); 3 | 4 | return { 5 | presets: [ 6 | isTest ? [ 7 | '@babel/preset-env', 8 | // Jest needs to target node 9 | { targets: { node: 'current' } } 10 | ] : [ 11 | '@babel/preset-env' 12 | ] 13 | ] 14 | }; 15 | }; 16 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | coverageDirectory: "coverage", 4 | coverageReporters: [ 5 | "cobertura", 6 | ], 7 | setupFiles: ['./jest.setup.js'] 8 | }; 9 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | // Add fetch polyfill for jest 2 | import 'isomorphic-fetch'; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jellyfin-apiclient", 3 | "version": "1.11.0", 4 | "description": "API client for Jellyfin", 5 | "main": "dist/jellyfin-apiclient.js", 6 | "dependencies": {}, 7 | "devDependencies": { 8 | "@babel/core": "7.18.2", 9 | "@babel/preset-env": "7.18.2", 10 | "babel-jest": "28.1.0", 11 | "babel-loader": "8.2.5", 12 | "eslint": "8.16.0", 13 | "eslint-plugin-import": "2.26.0", 14 | "eslint-plugin-jest": "26.4.6", 15 | "eslint-plugin-promise": "6.0.0", 16 | "isomorphic-fetch": "3.0.0", 17 | "jest": "28.1.0", 18 | "jest-junit": "13.2.0", 19 | "jsdoc": "3.6.10", 20 | "prettier": "2.6.2", 21 | "source-map-loader": "3.0.1", 22 | "webpack": "5.73.0", 23 | "webpack-cli": "4.9.2" 24 | }, 25 | "browserslist": [ 26 | "last 2 Firefox versions", 27 | "last 2 Chrome versions", 28 | "last 2 ChromeAndroid versions", 29 | "last 2 Safari versions", 30 | "last 2 iOS versions", 31 | "last 2 Edge versions", 32 | "Chrome 27", 33 | "Chrome 38", 34 | "Chrome 47", 35 | "Chrome 53", 36 | "Chrome 56", 37 | "Chrome 63", 38 | "Firefox ESR" 39 | ], 40 | "scripts": { 41 | "prepare": "webpack --mode production", 42 | "dev": "webpack --mode development", 43 | "build": "webpack --mode production", 44 | "lint": "eslint \"src\"", 45 | "test": "jest", 46 | "docs": "jsdoc src -r -R README.md -d docs" 47 | }, 48 | "repository": { 49 | "type": "git", 50 | "url": "git+https://github.com/jellyfin/jellyfin-apiclient-javascript.git" 51 | }, 52 | "author": "Jellyfin Contributors (https://github.com/jellyfin/jellyfin-apiclient-javascript/graphs/contributors)", 53 | "license": "MIT", 54 | "bugs": { 55 | "url": "https://github.com/jellyfin/jellyfin-apiclient-javascript/issues" 56 | }, 57 | "homepage": "https://github.com/jellyfin/jellyfin-apiclient-javascript#readme", 58 | "engines": { 59 | "yarn": "YARN NO LONGER USED - use npm instead." 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/apiClient.js: -------------------------------------------------------------------------------- 1 | import events from './events'; 2 | import appStorage from './appStorage'; 3 | import PromiseDelay from './promiseDelay'; 4 | 5 | /** Report rate limits in ms for different events */ 6 | const reportRateLimits = { 7 | timeupdate: 10000, 8 | volumechange: 3000 9 | }; 10 | 11 | /** Maximum bitrate (Int32) */ 12 | const MAX_BITRATE = 2147483647; 13 | /** Approximate LAN bitrate */ 14 | const LAN_BITRATE = 140000000; 15 | /** Bitrate test timeout in milliseconds */ 16 | const BITRATETEST_TIMEOUT = 5000; 17 | 18 | function redetectBitrate(instance) { 19 | stopBitrateDetection(instance); 20 | 21 | if (instance.accessToken() && instance.enableAutomaticBitrateDetection !== false) { 22 | instance.detectTimeout = setTimeout(redetectBitrateInternal.bind(instance), 6000); 23 | } 24 | } 25 | 26 | function redetectBitrateInternal() { 27 | this.detectTimeout = null; 28 | 29 | if (this.accessToken()) { 30 | this.detectBitrate(); 31 | } 32 | } 33 | 34 | function stopBitrateDetection(instance) { 35 | if (instance.detectTimeout) { 36 | clearTimeout(instance.detectTimeout); 37 | instance.detectTimeout = null; 38 | } 39 | } 40 | 41 | function replaceAll(originalString, strReplace, strWith) { 42 | const reg = new RegExp(strReplace, 'ig'); 43 | return originalString.replace(reg, strWith); 44 | } 45 | 46 | function onFetchFail(instance, url, response) { 47 | events.trigger(instance, 'requestfail', [ 48 | { 49 | url, 50 | status: response.status, 51 | errorCode: response.headers ? response.headers.get('X-Application-Error-Code') : null 52 | } 53 | ]); 54 | } 55 | 56 | function paramsToString(params) { 57 | const values = []; 58 | 59 | for (const key in params) { 60 | const value = params[key]; 61 | 62 | if (value !== null && value !== undefined && value !== '') { 63 | values.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`); 64 | } 65 | } 66 | return values.join('&'); 67 | } 68 | 69 | function fetchWithTimeout(url, options, timeoutMs) { 70 | return new Promise((resolve, reject) => { 71 | const timeout = setTimeout(reject, timeoutMs); 72 | 73 | options = options || {}; 74 | options.credentials = 'same-origin'; 75 | 76 | fetch(url, options) 77 | .then((response) => { 78 | clearTimeout(timeout); 79 | resolve(response); 80 | }) 81 | .catch((error) => { 82 | clearTimeout(timeout); 83 | reject(error); 84 | }); 85 | }); 86 | } 87 | 88 | function getFetchPromise(request) { 89 | const headers = request.headers || {}; 90 | 91 | if (request.dataType === 'json') { 92 | headers.accept = 'application/json'; 93 | } 94 | 95 | const fetchRequest = { 96 | headers, 97 | method: request.type, 98 | credentials: 'same-origin' 99 | }; 100 | 101 | let contentType = request.contentType; 102 | 103 | if (request.data) { 104 | if (typeof request.data === 'string') { 105 | fetchRequest.body = request.data; 106 | } else { 107 | fetchRequest.body = paramsToString(request.data); 108 | 109 | contentType = contentType || 'application/x-www-form-urlencoded; charset=UTF-8'; 110 | } 111 | } 112 | 113 | if (contentType) { 114 | headers['Content-Type'] = contentType; 115 | } 116 | 117 | if (!request.timeout) { 118 | return fetch(request.url, fetchRequest); 119 | } 120 | 121 | return fetchWithTimeout(request.url, fetchRequest, request.timeout); 122 | } 123 | 124 | async function resetReportPlaybackProgress(instance, resolve) { 125 | if (typeof instance.reportPlaybackProgressReset === 'function') { 126 | await instance.reportPlaybackProgressReset(resolve); 127 | } 128 | 129 | instance.lastPlaybackProgressReport = 0; 130 | instance.lastPlaybackProgressReportTicks = null; 131 | 132 | return Promise.resolve(); 133 | } 134 | 135 | /** 136 | * Creates a new api client instance 137 | * @param {String} serverAddress 138 | * @param {String} appName 139 | * @param {String} appVersion 140 | */ 141 | class ApiClient { 142 | constructor(serverAddress, appName, appVersion, deviceName, deviceId) { 143 | if (!serverAddress) { 144 | throw new Error('Must supply a serverAddress'); 145 | } 146 | 147 | console.debug(`ApiClient serverAddress: ${serverAddress}`); 148 | console.debug(`ApiClient appName: ${appName}`); 149 | console.debug(`ApiClient appVersion: ${appVersion}`); 150 | console.debug(`ApiClient deviceName: ${deviceName}`); 151 | console.debug(`ApiClient deviceId: ${deviceId}`); 152 | 153 | this._serverInfo = {}; 154 | this._serverAddress = serverAddress; 155 | this._deviceId = deviceId; 156 | this._deviceName = deviceName; 157 | this._appName = appName; 158 | this._appVersion = appVersion; 159 | this._loggedIn = false; 160 | } 161 | 162 | appName() { 163 | return this._appName; 164 | } 165 | 166 | setRequestHeaders(headers) { 167 | const currentServerInfo = this.serverInfo(); 168 | const appName = this._appName; 169 | const accessToken = currentServerInfo.AccessToken; 170 | 171 | const values = []; 172 | 173 | if (appName) { 174 | values.push(`Client="${appName}"`); 175 | } 176 | 177 | if (this._deviceName) { 178 | values.push(`Device="${this._deviceName}"`); 179 | } 180 | 181 | if (this._deviceId) { 182 | values.push(`DeviceId="${this._deviceId}"`); 183 | } 184 | 185 | if (this._appVersion) { 186 | values.push(`Version="${this._appVersion}"`); 187 | } 188 | 189 | if (accessToken) { 190 | values.push(`Token="${accessToken}"`); 191 | } 192 | 193 | if (values.length) { 194 | headers['Authorization'] = `MediaBrowser ${values.join(', ')}`; 195 | } 196 | } 197 | 198 | appVersion() { 199 | return this._appVersion; 200 | } 201 | 202 | deviceName() { 203 | return this._deviceName; 204 | } 205 | 206 | deviceId() { 207 | return this._deviceId; 208 | } 209 | 210 | /** 211 | * Gets the server address. 212 | */ 213 | serverAddress(val) { 214 | if (val != null) { 215 | if (val.toLowerCase().indexOf('http') !== 0) { 216 | throw new Error(`Invalid url: ${val}`); 217 | } 218 | 219 | const changed = val !== this._serverAddress; 220 | 221 | this._serverAddress = val; 222 | 223 | this.onNetworkChange(); 224 | 225 | if (changed) { 226 | events.trigger(this, 'serveraddresschanged'); 227 | } 228 | } 229 | 230 | return this._serverAddress; 231 | } 232 | 233 | onNetworkChange() { 234 | this.lastDetectedBitrate = 0; 235 | this.lastDetectedBitrateTime = 0; 236 | setSavedEndpointInfo(this, null); 237 | 238 | redetectBitrate(this); 239 | } 240 | 241 | /** 242 | * Creates an api url based on a handler name and query string parameters 243 | * @param {String} name 244 | * @param {Object} params 245 | */ 246 | getUrl(name, params, serverAddress) { 247 | if (!name) { 248 | throw new Error('Url name cannot be empty'); 249 | } 250 | 251 | let url = serverAddress || this._serverAddress; 252 | 253 | if (!url) { 254 | throw new Error('serverAddress is yet not set'); 255 | } 256 | 257 | if (name.charAt(0) !== '/') { 258 | url += '/'; 259 | } 260 | 261 | url += name; 262 | 263 | if (params) { 264 | params = paramsToString(params); 265 | if (params) { 266 | url += `?${params}`; 267 | } 268 | } 269 | 270 | return url; 271 | } 272 | 273 | fetchWithFailover(request, enableReconnection) { 274 | console.log(`Requesting ${request.url}`); 275 | 276 | request.timeout = 30000; 277 | const instance = this; 278 | 279 | return getFetchPromise(request) 280 | .then((response) => { 281 | instance.lastFetch = new Date().getTime(); 282 | 283 | if (response.status < 400) { 284 | if (request.dataType === 'json' || request.headers.accept === 'application/json') { 285 | return response.json(); 286 | } else if ( 287 | request.dataType === 'text' || 288 | (response.headers.get('Content-Type') || '').toLowerCase().indexOf('text/') === 0 289 | ) { 290 | return response.text(); 291 | } else { 292 | return response; 293 | } 294 | } else { 295 | onFetchFail(instance, request.url, response); 296 | return Promise.reject(response); 297 | } 298 | }) 299 | .catch((error) => { 300 | if (error) { 301 | console.log(`Request failed to ${request.url} ${error.toString()}`); 302 | } else { 303 | console.log(`Request timed out to ${request.url}`); 304 | } 305 | 306 | // http://api.jquery.com/jQuery.ajax/ 307 | if ((!error || !error.status) && enableReconnection) { 308 | console.log('Attempting reconnection'); 309 | 310 | const previousServerAddress = instance.serverAddress(); 311 | 312 | return tryReconnect(instance) 313 | .then(() => { 314 | console.log('Reconnect succeeded'); 315 | request.url = request.url.replace(previousServerAddress, instance.serverAddress()); 316 | 317 | return instance.fetchWithFailover(request, false); 318 | }) 319 | .catch((innerError) => { 320 | console.log('Reconnect failed'); 321 | onFetchFail(instance, request.url, {}); 322 | throw innerError; 323 | }); 324 | } else { 325 | console.log('Reporting request failure'); 326 | 327 | onFetchFail(instance, request.url, {}); 328 | throw error; 329 | } 330 | }); 331 | } 332 | 333 | /** 334 | * Wraps around jQuery ajax methods to add additional info to the request. 335 | */ 336 | fetch(request, includeAuthorization) { 337 | if (!request) { 338 | return Promise.reject('Request cannot be null'); 339 | } 340 | 341 | request.headers = request.headers || {}; 342 | 343 | if (includeAuthorization !== false) { 344 | this.setRequestHeaders(request.headers); 345 | } 346 | 347 | if (this.enableAutomaticNetworking === false || request.type !== 'GET') { 348 | console.log(`Requesting url without automatic networking: ${request.url}`); 349 | 350 | const instance = this; 351 | return getFetchPromise(request) 352 | .then((response) => { 353 | instance.lastFetch = new Date().getTime(); 354 | 355 | if (response.status < 400) { 356 | if (request.dataType === 'json' || request.headers.accept === 'application/json') { 357 | return response.json(); 358 | } else if ( 359 | request.dataType === 'text' || 360 | (response.headers.get('Content-Type') || '').toLowerCase().indexOf('text/') === 0 361 | ) { 362 | return response.text(); 363 | } else { 364 | return response; 365 | } 366 | } else { 367 | onFetchFail(instance, request.url, response); 368 | return Promise.reject(response); 369 | } 370 | }) 371 | .catch((error) => { 372 | onFetchFail(instance, request.url, {}); 373 | return Promise.reject(error); 374 | }); 375 | } 376 | 377 | return this.fetchWithFailover(request, true); 378 | } 379 | 380 | setAuthenticationInfo(accessKey, userId) { 381 | this._currentUser = null; 382 | 383 | this._loggedIn = !!userId && !!accessKey; 384 | 385 | this._serverInfo.AccessToken = accessKey; 386 | this._serverInfo.UserId = userId; 387 | redetectBitrate(this); 388 | } 389 | 390 | serverInfo(info) { 391 | if (info) { 392 | this._serverInfo = info; 393 | } 394 | 395 | return this._serverInfo; 396 | } 397 | 398 | /** 399 | * Gets or sets the current user id. 400 | */ 401 | getCurrentUserId() { 402 | if (!this._loggedIn) return null; 403 | return this._serverInfo.UserId; 404 | } 405 | 406 | accessToken() { 407 | if (!this._loggedIn) return null; 408 | return this._serverInfo.AccessToken; 409 | } 410 | 411 | serverId() { 412 | return this.serverInfo().Id; 413 | } 414 | 415 | serverName() { 416 | return this.serverInfo().Name; 417 | } 418 | 419 | /** 420 | * Wraps around jQuery ajax methods to add additional info to the request. 421 | */ 422 | ajax(request, includeAuthorization) { 423 | if (!request) { 424 | return Promise.reject('Request cannot be null'); 425 | } 426 | 427 | return this.fetch(request, includeAuthorization); 428 | } 429 | 430 | /** 431 | * Gets or sets the current user id. 432 | */ 433 | getCurrentUser(enableCache) { 434 | if (this._currentUser) { 435 | return Promise.resolve(this._currentUser); 436 | } 437 | 438 | const userId = this.getCurrentUserId(); 439 | 440 | if (!userId) { 441 | return Promise.reject(); 442 | } 443 | 444 | const instance = this; 445 | let user; 446 | 447 | const serverPromise = this.getUser(userId) 448 | .then((userObject) => { 449 | appStorage.setItem(`user-${userObject.Id}-${userObject.ServerId}`, JSON.stringify(userObject)); 450 | 451 | instance._currentUser = userObject; 452 | return userObject; 453 | }) 454 | .catch((response) => { 455 | // if timed out, look for cached value 456 | if (!response.status) { 457 | if (userId && instance.accessToken()) { 458 | user = getCachedUser(instance, userId); 459 | if (user) { 460 | return Promise.resolve(user); 461 | } 462 | } 463 | } 464 | 465 | throw response; 466 | }); 467 | 468 | if (!this.lastFetch && enableCache !== false) { 469 | user = getCachedUser(instance, userId); 470 | if (user) { 471 | return Promise.resolve(user); 472 | } 473 | } 474 | 475 | return serverPromise; 476 | } 477 | 478 | isLoggedIn() { 479 | return this._loggedIn; 480 | } 481 | 482 | /** 483 | * Logout current user 484 | */ 485 | logout() { 486 | stopBitrateDetection(this); 487 | this.closeWebSocket(); 488 | 489 | const done = () => { 490 | const info = this.serverInfo(); 491 | if (info && info.UserId && info.Id) { 492 | appStorage.removeItem(`user-${info.UserId}-${info.Id}`); 493 | } 494 | this.setAuthenticationInfo(null, null); 495 | }; 496 | 497 | if (this.accessToken()) { 498 | const url = this.getUrl('Sessions/Logout'); 499 | 500 | return this.ajax({ 501 | type: 'POST', 502 | url 503 | }).then(done, done); 504 | } 505 | 506 | done(); 507 | return Promise.resolve(); 508 | } 509 | 510 | /** 511 | * Authenticates a user 512 | * @param {String} name 513 | * @param {String} password 514 | */ 515 | authenticateUserByName(name, password) { 516 | if (!name) { 517 | return Promise.reject(); 518 | } 519 | 520 | const url = this.getUrl('Users/authenticatebyname'); 521 | 522 | return new Promise((resolve, reject) => { 523 | const postData = { 524 | Username: name, 525 | Pw: password || '' 526 | }; 527 | 528 | this.ajax({ 529 | type: 'POST', 530 | url: url, 531 | data: JSON.stringify(postData), 532 | dataType: 'json', 533 | contentType: 'application/json' 534 | }) 535 | .then((result) => { 536 | const afterOnAuthenticated = () => { 537 | redetectBitrate(this); 538 | resolve(result); 539 | }; 540 | 541 | if (this.onAuthenticated) { 542 | this.onAuthenticated(this, result).then(afterOnAuthenticated); 543 | } else { 544 | afterOnAuthenticated(); 545 | } 546 | }) 547 | .catch(reject); 548 | }); 549 | } 550 | 551 | /** 552 | * Authenticates a user using quick connect 553 | * @param {String} secret The secret from the request. 554 | */ 555 | quickConnect(secret) { 556 | if (!secret) { 557 | return Promise.reject(); 558 | } 559 | 560 | const url = this.getUrl('Users/AuthenticateWithQuickConnect'); 561 | 562 | return new Promise((resolve, reject) => { 563 | const postData = { 564 | Secret: secret 565 | }; 566 | 567 | this.ajax({ 568 | type: 'POST', 569 | url: url, 570 | data: JSON.stringify(postData), 571 | dataType: 'json', 572 | contentType: 'application/json' 573 | }) 574 | .then((result) => { 575 | const afterOnAuthenticated = () => { 576 | redetectBitrate(this); 577 | resolve(result); 578 | }; 579 | 580 | if (this.onAuthenticated) { 581 | this.onAuthenticated(this, result).then(afterOnAuthenticated); 582 | } else { 583 | afterOnAuthenticated(); 584 | } 585 | }) 586 | .catch(() => { 587 | throw new Error('quickConnect: error authenticating with the server'); 588 | }); 589 | }); 590 | } 591 | 592 | /** 593 | * Retrieves quick connect information for the provided verb 594 | * @param {String} verb 595 | */ 596 | getQuickConnect(verb) { 597 | var url = this.getUrl("/QuickConnect/" + verb); 598 | return this.getJSON(url); 599 | } 600 | 601 | ensureWebSocket() { 602 | if (this.isWebSocketOpenOrConnecting() || !this.isWebSocketSupported()) { 603 | return; 604 | } 605 | 606 | try { 607 | this.openWebSocket(); 608 | } catch (err) { 609 | console.log(`Error opening web socket: ${err}`); 610 | } 611 | } 612 | 613 | openWebSocket() { 614 | const accessToken = this.accessToken(); 615 | 616 | if (!accessToken) { 617 | throw new Error('Cannot open web socket without access token.'); 618 | } 619 | 620 | let url = this.getUrl('socket'); 621 | 622 | url = replaceAll(url, 'emby/socket', 'embywebsocket'); 623 | url = replaceAll(url, 'https:', 'wss:'); 624 | url = replaceAll(url, 'http:', 'ws:'); 625 | 626 | url += `?api_key=${accessToken}`; 627 | url += `&deviceId=${this.deviceId()}`; 628 | 629 | console.log(`opening web socket with url: ${url}`); 630 | 631 | const webSocket = new WebSocket(url); 632 | 633 | webSocket.onmessage = onWebSocketMessage.bind(this); 634 | webSocket.onopen = onWebSocketOpen.bind(this); 635 | webSocket.onerror = onWebSocketError.bind(this); 636 | setSocketOnClose(this, webSocket); 637 | 638 | this._webSocket = webSocket; 639 | } 640 | 641 | closeWebSocket() { 642 | const socket = this._webSocket; 643 | 644 | if (socket && socket.readyState === WebSocket.OPEN) { 645 | socket.close(); 646 | } 647 | } 648 | 649 | sendWebSocketMessage(name, data) { 650 | console.log(`Sending web socket message: ${name}`); 651 | 652 | let msg = { MessageType: name }; 653 | 654 | if (data) { 655 | msg.Data = data; 656 | } 657 | 658 | msg = JSON.stringify(msg); 659 | 660 | this._webSocket.send(msg); 661 | } 662 | 663 | sendMessage(name, data) { 664 | if (this.isWebSocketOpen()) { 665 | this.sendWebSocketMessage(name, data); 666 | } 667 | } 668 | 669 | isMessageChannelOpen() { 670 | return this.isWebSocketOpen(); 671 | } 672 | 673 | isWebSocketOpen() { 674 | const socket = this._webSocket; 675 | 676 | if (socket) { 677 | return socket.readyState === WebSocket.OPEN; 678 | } 679 | return false; 680 | } 681 | 682 | isWebSocketOpenOrConnecting() { 683 | const socket = this._webSocket; 684 | 685 | if (socket) { 686 | return socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING; 687 | } 688 | return false; 689 | } 690 | 691 | get(url) { 692 | return this.ajax({ 693 | type: 'GET', 694 | url 695 | }); 696 | } 697 | 698 | getJSON(url, includeAuthorization) { 699 | return this.fetch( 700 | { 701 | url, 702 | type: 'GET', 703 | dataType: 'json', 704 | headers: { 705 | accept: 'application/json' 706 | } 707 | }, 708 | includeAuthorization 709 | ); 710 | } 711 | 712 | updateServerInfo(server, serverUrl) { 713 | if (server == null) { 714 | throw new Error('server cannot be null'); 715 | } 716 | 717 | this.serverInfo(server); 718 | 719 | if (!serverUrl) { 720 | throw new Error(`serverUrl cannot be null. serverInfo: ${JSON.stringify(server)}`); 721 | } 722 | console.log(`Setting server address to ${serverUrl}`); 723 | this.serverAddress(serverUrl); 724 | } 725 | 726 | isWebSocketSupported() { 727 | try { 728 | return WebSocket != null; 729 | } catch (err) { 730 | return false; 731 | } 732 | } 733 | 734 | clearAuthenticationInfo() { 735 | this.setAuthenticationInfo(null, null); 736 | } 737 | 738 | encodeName(name) { 739 | name = name.split('/').join('-'); 740 | name = name.split('&').join('-'); 741 | name = name.split('?').join('-'); 742 | 743 | const val = paramsToString({ name }); 744 | return val.substring(val.indexOf('=') + 1).replace("'", '%27'); 745 | } 746 | 747 | /** 748 | * Gets the server time as a UTC formatted string. 749 | * @returns {Promise} Promise that it's fulfilled on request completion. 750 | * @since 10.6.0 751 | */ 752 | getServerTime() { 753 | const url = this.getUrl('GetUTCTime'); 754 | 755 | return this.ajax({ 756 | type: 'GET', 757 | url: url 758 | }); 759 | } 760 | 761 | getDownloadSpeed(byteSize) { 762 | return new Promise((resolve, reject) => { 763 | const url = this.getUrl('Playback/BitrateTest', { 764 | Size: byteSize 765 | }); 766 | 767 | console.log(`Requesting ${url}`); 768 | 769 | const xhr = new XMLHttpRequest; 770 | 771 | xhr.open('GET', url, true); 772 | 773 | xhr.responseType = 'blob'; 774 | xhr.timeout = BITRATETEST_TIMEOUT; 775 | 776 | const headers = { 777 | 'Cache-Control': 'no-cache, no-store' 778 | }; 779 | 780 | this.setRequestHeaders(headers); 781 | 782 | for (const key in headers) { 783 | xhr.setRequestHeader(key, headers[key]); 784 | } 785 | 786 | let startTime; 787 | 788 | xhr.onreadystatechange = () => { 789 | if (xhr.readyState == XMLHttpRequest.HEADERS_RECEIVED) { 790 | startTime = performance.now(); 791 | } 792 | }; 793 | 794 | xhr.onload = () => { 795 | if (xhr.status < 400) { 796 | const responseTimeSeconds = (performance.now() - startTime) * 1e-3; 797 | const bytesLoaded = xhr.response.size; 798 | const bytesPerSecond = bytesLoaded / responseTimeSeconds; 799 | const bitrate = Math.round(bytesPerSecond * 8); 800 | 801 | console.debug(`BitrateTest ${bytesLoaded} bytes loaded (${byteSize} requested) in ${responseTimeSeconds} seconds -> ${bitrate} bps`); 802 | 803 | resolve(bitrate); 804 | } else { 805 | reject(`BitrateTest failed with ${xhr.status} status`); 806 | } 807 | }; 808 | 809 | xhr.onabort = () => { 810 | reject('BitrateTest abort'); 811 | }; 812 | 813 | xhr.onerror = () => { 814 | reject('BitrateTest error'); 815 | }; 816 | 817 | xhr.ontimeout = () => { 818 | reject('BitrateTest timeout'); 819 | }; 820 | 821 | xhr.send(null); 822 | }); 823 | } 824 | 825 | detectBitrate(force) { 826 | if ( 827 | !force && 828 | this.lastDetectedBitrate && 829 | new Date().getTime() - (this.lastDetectedBitrateTime || 0) <= 3600000 830 | ) { 831 | return Promise.resolve(this.lastDetectedBitrate); 832 | } 833 | 834 | const instance = this; 835 | 836 | return this.getEndpointInfo().then( 837 | (info) => detectBitrateWithEndpointInfo(instance, info), 838 | (info) => detectBitrateWithEndpointInfo(instance, {}) 839 | ); 840 | } 841 | 842 | /** 843 | * Gets an item from the server 844 | * Omit itemId to get the root folder. 845 | */ 846 | getItem(userId, itemId) { 847 | if (!itemId) { 848 | throw new Error('null itemId'); 849 | } 850 | 851 | const url = userId ? this.getUrl(`Users/${userId}/Items/${itemId}`) : this.getUrl(`Items/${itemId}`); 852 | 853 | return this.getJSON(url); 854 | } 855 | 856 | /** 857 | * Gets the root folder from the server 858 | */ 859 | getRootFolder(userId) { 860 | if (!userId) { 861 | throw new Error('null userId'); 862 | } 863 | 864 | const url = this.getUrl(`Users/${userId}/Items/Root`); 865 | 866 | return this.getJSON(url); 867 | } 868 | 869 | getNotificationSummary(userId) { 870 | if (!userId) { 871 | throw new Error('null userId'); 872 | } 873 | 874 | const url = this.getUrl(`Notifications/${userId}/Summary`); 875 | 876 | return this.getJSON(url); 877 | } 878 | 879 | getNotifications(userId, options) { 880 | if (!userId) { 881 | throw new Error('null userId'); 882 | } 883 | 884 | const url = this.getUrl(`Notifications/${userId}`, options || {}); 885 | 886 | return this.getJSON(url); 887 | } 888 | 889 | markNotificationsRead(userId, idList, isRead) { 890 | if (!userId) { 891 | throw new Error('null userId'); 892 | } 893 | 894 | if (!idList) { 895 | throw new Error('null idList'); 896 | } 897 | 898 | const suffix = isRead ? 'Read' : 'Unread'; 899 | 900 | const params = { 901 | UserId: userId, 902 | Ids: idList.join(',') 903 | }; 904 | 905 | const url = this.getUrl(`Notifications/${userId}/${suffix}`, params); 906 | 907 | return this.ajax({ 908 | type: 'POST', 909 | url 910 | }); 911 | } 912 | 913 | getRemoteImageProviders(options) { 914 | if (!options) { 915 | throw new Error('null options'); 916 | } 917 | 918 | const urlPrefix = getRemoteImagePrefix(this, options); 919 | 920 | const url = this.getUrl(`${urlPrefix}/RemoteImages/Providers`, options); 921 | 922 | return this.getJSON(url); 923 | } 924 | 925 | getAvailableRemoteImages(options) { 926 | if (!options) { 927 | throw new Error('null options'); 928 | } 929 | 930 | const urlPrefix = getRemoteImagePrefix(this, options); 931 | 932 | const url = this.getUrl(`${urlPrefix}/RemoteImages`, options); 933 | 934 | return this.getJSON(url); 935 | } 936 | 937 | downloadRemoteImage(options) { 938 | if (!options) { 939 | throw new Error('null options'); 940 | } 941 | 942 | const urlPrefix = getRemoteImagePrefix(this, options); 943 | 944 | const url = this.getUrl(`${urlPrefix}/RemoteImages/Download`, options); 945 | 946 | return this.ajax({ 947 | type: 'POST', 948 | url 949 | }); 950 | } 951 | 952 | getRecordingFolders(userId) { 953 | const url = this.getUrl('LiveTv/Recordings/Folders', { userId: userId }); 954 | 955 | return this.getJSON(url); 956 | } 957 | 958 | getLiveTvInfo(options) { 959 | const url = this.getUrl('LiveTv/Info', options || {}); 960 | 961 | return this.getJSON(url); 962 | } 963 | 964 | getLiveTvGuideInfo(options) { 965 | const url = this.getUrl('LiveTv/GuideInfo', options || {}); 966 | 967 | return this.getJSON(url); 968 | } 969 | 970 | getLiveTvChannel(id, userId) { 971 | if (!id) { 972 | throw new Error('null id'); 973 | } 974 | 975 | const options = {}; 976 | 977 | if (userId) { 978 | options.userId = userId; 979 | } 980 | 981 | const url = this.getUrl(`LiveTv/Channels/${id}`, options); 982 | 983 | return this.getJSON(url); 984 | } 985 | 986 | getLiveTvChannels(options) { 987 | const url = this.getUrl('LiveTv/Channels', options || {}); 988 | 989 | return this.getJSON(url); 990 | } 991 | 992 | getLiveTvPrograms(options = {}) { 993 | if (options.channelIds && options.channelIds.length > 1800) { 994 | return this.ajax({ 995 | type: 'POST', 996 | url: this.getUrl('LiveTv/Programs'), 997 | data: JSON.stringify(options), 998 | contentType: 'application/json', 999 | dataType: 'json' 1000 | }); 1001 | } else { 1002 | return this.ajax({ 1003 | type: 'GET', 1004 | url: this.getUrl('LiveTv/Programs', options), 1005 | dataType: 'json' 1006 | }); 1007 | } 1008 | } 1009 | 1010 | getLiveTvRecommendedPrograms(options = {}) { 1011 | return this.ajax({ 1012 | type: 'GET', 1013 | url: this.getUrl('LiveTv/Programs/Recommended', options), 1014 | dataType: 'json' 1015 | }); 1016 | } 1017 | 1018 | getLiveTvRecordings(options) { 1019 | const url = this.getUrl('LiveTv/Recordings', options || {}); 1020 | 1021 | return this.getJSON(url); 1022 | } 1023 | 1024 | getLiveTvRecordingSeries(options) { 1025 | const url = this.getUrl('LiveTv/Recordings/Series', options || {}); 1026 | 1027 | return this.getJSON(url); 1028 | } 1029 | 1030 | getLiveTvRecordingGroups(options) { 1031 | const url = this.getUrl('LiveTv/Recordings/Groups', options || {}); 1032 | 1033 | return this.getJSON(url); 1034 | } 1035 | 1036 | getLiveTvRecordingGroup(id) { 1037 | if (!id) { 1038 | throw new Error('null id'); 1039 | } 1040 | 1041 | const url = this.getUrl(`LiveTv/Recordings/Groups/${id}`); 1042 | 1043 | return this.getJSON(url); 1044 | } 1045 | 1046 | getLiveTvRecording(id, userId) { 1047 | if (!id) { 1048 | throw new Error('null id'); 1049 | } 1050 | 1051 | const options = {}; 1052 | 1053 | if (userId) { 1054 | options.userId = userId; 1055 | } 1056 | 1057 | const url = this.getUrl(`LiveTv/Recordings/${id}`, options); 1058 | 1059 | return this.getJSON(url); 1060 | } 1061 | 1062 | getLiveTvProgram(id, userId) { 1063 | if (!id) { 1064 | throw new Error('null id'); 1065 | } 1066 | 1067 | const options = {}; 1068 | 1069 | if (userId) { 1070 | options.userId = userId; 1071 | } 1072 | 1073 | const url = this.getUrl(`LiveTv/Programs/${id}`, options); 1074 | 1075 | return this.getJSON(url); 1076 | } 1077 | 1078 | deleteLiveTvRecording(id) { 1079 | if (!id) { 1080 | throw new Error('null id'); 1081 | } 1082 | 1083 | const url = this.getUrl(`LiveTv/Recordings/${id}`); 1084 | 1085 | return this.ajax({ 1086 | type: 'DELETE', 1087 | url 1088 | }); 1089 | } 1090 | 1091 | cancelLiveTvTimer(id) { 1092 | if (!id) { 1093 | throw new Error('null id'); 1094 | } 1095 | 1096 | const url = this.getUrl(`LiveTv/Timers/${id}`); 1097 | 1098 | return this.ajax({ 1099 | type: 'DELETE', 1100 | url 1101 | }); 1102 | } 1103 | 1104 | getLiveTvTimers(options) { 1105 | const url = this.getUrl('LiveTv/Timers', options || {}); 1106 | 1107 | return this.getJSON(url); 1108 | } 1109 | 1110 | getLiveTvTimer(id) { 1111 | if (!id) { 1112 | throw new Error('null id'); 1113 | } 1114 | 1115 | const url = this.getUrl(`LiveTv/Timers/${id}`); 1116 | 1117 | return this.getJSON(url); 1118 | } 1119 | 1120 | getNewLiveTvTimerDefaults(options = {}) { 1121 | const url = this.getUrl('LiveTv/Timers/Defaults', options); 1122 | 1123 | return this.getJSON(url); 1124 | } 1125 | 1126 | createLiveTvTimer(item) { 1127 | if (!item) { 1128 | throw new Error('null item'); 1129 | } 1130 | 1131 | const url = this.getUrl('LiveTv/Timers'); 1132 | 1133 | return this.ajax({ 1134 | type: 'POST', 1135 | url, 1136 | data: JSON.stringify(item), 1137 | contentType: 'application/json' 1138 | }); 1139 | } 1140 | 1141 | updateLiveTvTimer(item) { 1142 | if (!item) { 1143 | throw new Error('null item'); 1144 | } 1145 | 1146 | const url = this.getUrl(`LiveTv/Timers/${item.Id}`); 1147 | 1148 | return this.ajax({ 1149 | type: 'POST', 1150 | url, 1151 | data: JSON.stringify(item), 1152 | contentType: 'application/json' 1153 | }); 1154 | } 1155 | 1156 | resetLiveTvTuner(id) { 1157 | if (!id) { 1158 | throw new Error('null id'); 1159 | } 1160 | 1161 | const url = this.getUrl(`LiveTv/Tuners/${id}/Reset`); 1162 | 1163 | return this.ajax({ 1164 | type: 'POST', 1165 | url 1166 | }); 1167 | } 1168 | 1169 | getLiveTvSeriesTimers(options) { 1170 | const url = this.getUrl('LiveTv/SeriesTimers', options || {}); 1171 | 1172 | return this.getJSON(url); 1173 | } 1174 | 1175 | getLiveTvSeriesTimer(id) { 1176 | if (!id) { 1177 | throw new Error('null id'); 1178 | } 1179 | 1180 | const url = this.getUrl(`LiveTv/SeriesTimers/${id}`); 1181 | 1182 | return this.getJSON(url); 1183 | } 1184 | 1185 | cancelLiveTvSeriesTimer(id) { 1186 | if (!id) { 1187 | throw new Error('null id'); 1188 | } 1189 | 1190 | const url = this.getUrl(`LiveTv/SeriesTimers/${id}`); 1191 | 1192 | return this.ajax({ 1193 | type: 'DELETE', 1194 | url 1195 | }); 1196 | } 1197 | 1198 | createLiveTvSeriesTimer(item) { 1199 | if (!item) { 1200 | throw new Error('null item'); 1201 | } 1202 | 1203 | const url = this.getUrl('LiveTv/SeriesTimers'); 1204 | 1205 | return this.ajax({ 1206 | type: 'POST', 1207 | url, 1208 | data: JSON.stringify(item), 1209 | contentType: 'application/json' 1210 | }); 1211 | } 1212 | 1213 | updateLiveTvSeriesTimer(item) { 1214 | if (!item) { 1215 | throw new Error('null item'); 1216 | } 1217 | 1218 | const url = this.getUrl(`LiveTv/SeriesTimers/${item.Id}`); 1219 | 1220 | return this.ajax({ 1221 | type: 'POST', 1222 | url, 1223 | data: JSON.stringify(item), 1224 | contentType: 'application/json' 1225 | }); 1226 | } 1227 | 1228 | getRegistrationInfo(feature) { 1229 | const url = this.getUrl(`Registrations/${feature}`); 1230 | 1231 | return this.getJSON(url); 1232 | } 1233 | 1234 | /** 1235 | * Gets the current server status 1236 | */ 1237 | getSystemInfo(itemId) { 1238 | const url = this.getUrl('System/Info'); 1239 | 1240 | const instance = this; 1241 | 1242 | return this.getJSON(url).then((info) => { 1243 | instance.setSystemInfo(info); 1244 | return Promise.resolve(info); 1245 | }); 1246 | } 1247 | 1248 | getSyncStatus() { 1249 | const url = this.getUrl('Sync/' + itemId + '/Status'); 1250 | 1251 | return this.ajax({ 1252 | url: url, 1253 | type: 'POST', 1254 | dataType: 'json', 1255 | contentType: 'application/json', 1256 | data: JSON.stringify({ 1257 | TargetId: this.deviceId() 1258 | }) 1259 | }); 1260 | } 1261 | 1262 | /** 1263 | * Gets the current server status 1264 | */ 1265 | getPublicSystemInfo() { 1266 | const url = this.getUrl('System/Info/Public'); 1267 | 1268 | const instance = this; 1269 | 1270 | return this.getJSON(url).then((info) => { 1271 | instance.setSystemInfo(info); 1272 | return Promise.resolve(info); 1273 | }); 1274 | } 1275 | 1276 | getInstantMixFromItem(itemId, options) { 1277 | const url = this.getUrl(`Items/${itemId}/InstantMix`, options); 1278 | 1279 | return this.getJSON(url); 1280 | } 1281 | 1282 | getEpisodes(itemId, options) { 1283 | const url = this.getUrl(`Shows/${itemId}/Episodes`, options); 1284 | 1285 | return this.getJSON(url); 1286 | } 1287 | 1288 | getDisplayPreferences(id, userId, app) { 1289 | const url = this.getUrl(`DisplayPreferences/${id}`, { 1290 | userId, 1291 | client: app 1292 | }); 1293 | 1294 | return this.getJSON(url); 1295 | } 1296 | 1297 | updateDisplayPreferences(id, obj, userId, app) { 1298 | const url = this.getUrl(`DisplayPreferences/${id}`, { 1299 | userId, 1300 | client: app 1301 | }); 1302 | 1303 | return this.ajax({ 1304 | type: 'POST', 1305 | url, 1306 | data: JSON.stringify(obj), 1307 | contentType: 'application/json' 1308 | }); 1309 | } 1310 | 1311 | getSeasons(itemId, options) { 1312 | const url = this.getUrl(`Shows/${itemId}/Seasons`, options); 1313 | 1314 | return this.getJSON(url); 1315 | } 1316 | 1317 | getSimilarItems(itemId, options) { 1318 | const url = this.getUrl(`Items/${itemId}/Similar`, options); 1319 | 1320 | return this.getJSON(url); 1321 | } 1322 | 1323 | /** 1324 | * Gets all cultures known to the server 1325 | */ 1326 | getCultures() { 1327 | const url = this.getUrl('Localization/cultures'); 1328 | 1329 | return this.getJSON(url); 1330 | } 1331 | 1332 | /** 1333 | * Gets all countries known to the server 1334 | */ 1335 | getCountries() { 1336 | const url = this.getUrl('Localization/countries'); 1337 | 1338 | return this.getJSON(url); 1339 | } 1340 | 1341 | getPlaybackInfo(itemId, options, deviceProfile) { 1342 | const postData = { 1343 | DeviceProfile: deviceProfile 1344 | }; 1345 | 1346 | return this.ajax({ 1347 | url: this.getUrl(`Items/${itemId}/PlaybackInfo`, options), 1348 | type: 'POST', 1349 | data: JSON.stringify(postData), 1350 | contentType: 'application/json', 1351 | dataType: 'json' 1352 | }); 1353 | } 1354 | 1355 | getLiveStreamMediaInfo(liveStreamId) { 1356 | const postData = { 1357 | LiveStreamId: liveStreamId 1358 | }; 1359 | 1360 | return this.ajax({ 1361 | url: this.getUrl('LiveStreams/MediaInfo'), 1362 | type: 'POST', 1363 | data: JSON.stringify(postData), 1364 | contentType: 'application/json', 1365 | dataType: 'json' 1366 | }); 1367 | } 1368 | 1369 | getIntros(itemId) { 1370 | return this.getJSON(this.getUrl(`Users/${this.getCurrentUserId()}/Items/${itemId}/Intros`)); 1371 | } 1372 | 1373 | /** 1374 | * Gets the directory contents of a path on the server 1375 | */ 1376 | getDirectoryContents(path, options) { 1377 | if (!path) { 1378 | throw new Error('null path'); 1379 | } 1380 | if (typeof path !== 'string') { 1381 | throw new Error('invalid path'); 1382 | } 1383 | 1384 | options = options || {}; 1385 | 1386 | options.path = path; 1387 | 1388 | const url = this.getUrl('Environment/DirectoryContents', options); 1389 | 1390 | return this.getJSON(url); 1391 | } 1392 | 1393 | /** 1394 | * Gets shares from a network device 1395 | */ 1396 | getNetworkShares(path) { 1397 | if (!path) { 1398 | throw new Error('null path'); 1399 | } 1400 | 1401 | const options = {}; 1402 | options.path = path; 1403 | 1404 | const url = this.getUrl('Environment/NetworkShares', options); 1405 | 1406 | return this.getJSON(url); 1407 | } 1408 | 1409 | /** 1410 | * Gets the parent of a given path 1411 | */ 1412 | getParentPath(path) { 1413 | if (!path) { 1414 | throw new Error('null path'); 1415 | } 1416 | 1417 | const options = {}; 1418 | options.path = path; 1419 | 1420 | const url = this.getUrl('Environment/ParentPath', options); 1421 | 1422 | return this.ajax({ 1423 | type: 'GET', 1424 | url, 1425 | dataType: 'text' 1426 | }); 1427 | } 1428 | 1429 | /** 1430 | * Gets a list of physical drives from the server 1431 | */ 1432 | getDrives() { 1433 | const url = this.getUrl('Environment/Drives'); 1434 | 1435 | return this.getJSON(url); 1436 | } 1437 | 1438 | /** 1439 | * Gets a list of network devices from the server 1440 | */ 1441 | getNetworkDevices() { 1442 | const url = this.getUrl('Environment/NetworkDevices'); 1443 | 1444 | return this.getJSON(url); 1445 | } 1446 | 1447 | /** 1448 | * Cancels a package installation 1449 | */ 1450 | cancelPackageInstallation(installationId) { 1451 | if (!installationId) { 1452 | throw new Error('null installationId'); 1453 | } 1454 | 1455 | const url = this.getUrl(`Packages/Installing/${installationId}`); 1456 | 1457 | return this.ajax({ 1458 | type: 'DELETE', 1459 | url 1460 | }); 1461 | } 1462 | 1463 | /** 1464 | * Refreshes metadata for an item 1465 | */ 1466 | refreshItem(itemId, options) { 1467 | if (!itemId) { 1468 | throw new Error('null itemId'); 1469 | } 1470 | 1471 | const url = this.getUrl(`Items/${itemId}/Refresh`, options || {}); 1472 | 1473 | return this.ajax({ 1474 | type: 'POST', 1475 | url 1476 | }); 1477 | } 1478 | 1479 | /** 1480 | * Installs or updates a new plugin 1481 | */ 1482 | installPlugin(name, guid, version) { 1483 | if (!name) { 1484 | throw new Error('null name'); 1485 | } 1486 | 1487 | const options = { 1488 | AssemblyGuid: guid 1489 | }; 1490 | 1491 | if (version) { 1492 | options.version = version; 1493 | } 1494 | 1495 | const url = this.getUrl(`Packages/Installed/${name}`, options); 1496 | 1497 | return this.ajax({ 1498 | type: 'POST', 1499 | url 1500 | }); 1501 | } 1502 | 1503 | /** 1504 | * Instructs the server to perform a restart. 1505 | */ 1506 | restartServer() { 1507 | const url = this.getUrl('System/Restart'); 1508 | 1509 | return this.ajax({ 1510 | type: 'POST', 1511 | url 1512 | }); 1513 | } 1514 | 1515 | /** 1516 | * Instructs the server to perform a shutdown. 1517 | */ 1518 | shutdownServer() { 1519 | const url = this.getUrl('System/Shutdown'); 1520 | 1521 | return this.ajax({ 1522 | type: 'POST', 1523 | url 1524 | }); 1525 | } 1526 | 1527 | /** 1528 | * Gets information about an installable package 1529 | */ 1530 | getPackageInfo(name, guid) { 1531 | if (!name) { 1532 | throw new Error('null name'); 1533 | } 1534 | 1535 | const options = { 1536 | AssemblyGuid: guid 1537 | }; 1538 | 1539 | const url = this.getUrl(`Packages/${name}`, options); 1540 | 1541 | return this.getJSON(url); 1542 | } 1543 | 1544 | /** 1545 | * Gets the virtual folder list 1546 | */ 1547 | getVirtualFolders() { 1548 | let url = 'Library/VirtualFolders'; 1549 | 1550 | url = this.getUrl(url); 1551 | 1552 | return this.getJSON(url); 1553 | } 1554 | 1555 | /** 1556 | * Gets all the paths of the locations in the physical root. 1557 | */ 1558 | getPhysicalPaths() { 1559 | const url = this.getUrl('Library/PhysicalPaths'); 1560 | 1561 | return this.getJSON(url); 1562 | } 1563 | 1564 | /** 1565 | * Gets the current server configuration 1566 | */ 1567 | getServerConfiguration() { 1568 | const url = this.getUrl('System/Configuration'); 1569 | 1570 | return this.getJSON(url); 1571 | } 1572 | 1573 | /** 1574 | * Gets the current server configuration 1575 | */ 1576 | getDevicesOptions() { 1577 | const url = this.getUrl('System/Configuration/devices'); 1578 | 1579 | return this.getJSON(url); 1580 | } 1581 | 1582 | /** 1583 | * Deletes the device from the devices list, forcing any active sessions 1584 | * to re-authenticate. 1585 | * @param {String} deviceId 1586 | */ 1587 | deleteDevice(deviceId) { 1588 | const url = this.getUrl('Devices', { 1589 | Id: deviceId 1590 | }); 1591 | 1592 | return this.ajax({ 1593 | type: 'DELETE', 1594 | url 1595 | }); 1596 | } 1597 | 1598 | /** 1599 | * Gets the current server configuration 1600 | */ 1601 | getContentUploadHistory() { 1602 | const url = this.getUrl('Devices/CameraUploads', { 1603 | DeviceId: this.deviceId() 1604 | }); 1605 | 1606 | return this.getJSON(url); 1607 | } 1608 | 1609 | getNamedConfiguration(name) { 1610 | const url = this.getUrl(`System/Configuration/${name}`); 1611 | 1612 | return this.getJSON(url); 1613 | } 1614 | 1615 | /** 1616 | * Gets the server's scheduled tasks 1617 | */ 1618 | getScheduledTasks(options = {}) { 1619 | const url = this.getUrl('ScheduledTasks', options); 1620 | 1621 | return this.getJSON(url); 1622 | } 1623 | 1624 | /** 1625 | * Starts a scheduled task 1626 | */ 1627 | startScheduledTask(id) { 1628 | if (!id) { 1629 | throw new Error('null id'); 1630 | } 1631 | 1632 | const url = this.getUrl(`ScheduledTasks/Running/${id}`); 1633 | 1634 | return this.ajax({ 1635 | type: 'POST', 1636 | url 1637 | }); 1638 | } 1639 | 1640 | /** 1641 | * Gets a scheduled task 1642 | */ 1643 | getScheduledTask(id) { 1644 | if (!id) { 1645 | throw new Error('null id'); 1646 | } 1647 | 1648 | const url = this.getUrl(`ScheduledTasks/${id}`); 1649 | 1650 | return this.getJSON(url); 1651 | } 1652 | 1653 | getNextUpEpisodes(options) { 1654 | const url = this.getUrl('Shows/NextUp', options); 1655 | 1656 | return this.getJSON(url); 1657 | } 1658 | 1659 | /** 1660 | * Stops a scheduled task 1661 | */ 1662 | stopScheduledTask(id) { 1663 | if (!id) { 1664 | throw new Error('null id'); 1665 | } 1666 | 1667 | const url = this.getUrl(`ScheduledTasks/Running/${id}`); 1668 | 1669 | return this.ajax({ 1670 | type: 'DELETE', 1671 | url 1672 | }); 1673 | } 1674 | 1675 | /** 1676 | * Gets the configuration of a plugin 1677 | * @param {String} Id 1678 | */ 1679 | getPluginConfiguration(id) { 1680 | if (!id) { 1681 | throw new Error('null Id'); 1682 | } 1683 | 1684 | const url = this.getUrl(`Plugins/${id}/Configuration`); 1685 | 1686 | return this.getJSON(url); 1687 | } 1688 | 1689 | /** 1690 | * Gets a list of plugins that are available to be installed 1691 | */ 1692 | getAvailablePlugins(options = {}) { 1693 | options.PackageType = 'UserInstalled'; 1694 | 1695 | const url = this.getUrl('Packages', options); 1696 | 1697 | return this.getJSON(url); 1698 | } 1699 | 1700 | /** 1701 | * Uninstalls a plugin 1702 | * @param {String} Id 1703 | */ 1704 | uninstallPlugin(id) { 1705 | if (!id) { 1706 | throw new Error('null Id'); 1707 | } 1708 | 1709 | const url = this.getUrl(`Plugins/${id}`); 1710 | 1711 | return this.ajax({ 1712 | type: 'DELETE', 1713 | url 1714 | }); 1715 | } 1716 | 1717 | /** 1718 | * Uninstalls a plugin 1719 | * @param {String} Id 1720 | * @param {String} Version 1721 | */ 1722 | uninstallPluginByVersion(id, version) { 1723 | if (!id) { 1724 | throw new Error('null Id'); 1725 | } 1726 | 1727 | if (!version) { 1728 | throw new Error('null Version'); 1729 | } 1730 | 1731 | const url = this.getUrl(`Plugins/${id}/${version}`); 1732 | 1733 | return this.ajax({ 1734 | type: 'DELETE', 1735 | url 1736 | }); 1737 | } 1738 | 1739 | /** 1740 | * Enables a plugin 1741 | * @param {String} Id 1742 | * @param {String} Version 1743 | */ 1744 | enablePlugin(id, version) { 1745 | if (!id) { 1746 | throw new Error('null Id'); 1747 | } 1748 | 1749 | if (!version) { 1750 | throw new Error('null Id'); 1751 | } 1752 | 1753 | const url = this.getUrl(`Plugins/${id}/${version}/Enable`); 1754 | 1755 | return this.ajax({ 1756 | type: 'POST', 1757 | url 1758 | }); 1759 | } 1760 | 1761 | /** 1762 | * Disables a plugin 1763 | * @param {String} Id 1764 | * @param {String} Version 1765 | */ 1766 | disablePlugin(id, version) { 1767 | if (!id) { 1768 | throw new Error('null Id'); 1769 | } 1770 | 1771 | if (!version) { 1772 | throw new Error('null Version'); 1773 | } 1774 | 1775 | const url = this.getUrl(`Plugins/${id}/${version}/Disable`); 1776 | 1777 | return this.ajax({ 1778 | type: 'POST', 1779 | url 1780 | }); 1781 | } 1782 | 1783 | /** 1784 | * Removes a virtual folder 1785 | * @param {String} name 1786 | */ 1787 | removeVirtualFolder(name, refreshLibrary) { 1788 | if (!name) { 1789 | throw new Error('null name'); 1790 | } 1791 | 1792 | let url = 'Library/VirtualFolders'; 1793 | 1794 | url = this.getUrl(url, { 1795 | refreshLibrary: refreshLibrary ? true : false, 1796 | name 1797 | }); 1798 | 1799 | return this.ajax({ 1800 | type: 'DELETE', 1801 | url 1802 | }); 1803 | } 1804 | 1805 | /** 1806 | * Adds a virtual folder 1807 | * @param {String} name 1808 | */ 1809 | addVirtualFolder(name, type, refreshLibrary, libraryOptions) { 1810 | if (!name) { 1811 | throw new Error('null name'); 1812 | } 1813 | 1814 | const options = {}; 1815 | 1816 | if (type) { 1817 | options.collectionType = type; 1818 | } 1819 | 1820 | options.refreshLibrary = refreshLibrary ? true : false; 1821 | options.name = name; 1822 | 1823 | let url = 'Library/VirtualFolders'; 1824 | 1825 | url = this.getUrl(url, options); 1826 | 1827 | return this.ajax({ 1828 | type: 'POST', 1829 | url, 1830 | data: JSON.stringify({ 1831 | LibraryOptions: libraryOptions 1832 | }), 1833 | contentType: 'application/json' 1834 | }); 1835 | } 1836 | 1837 | updateVirtualFolderOptions(id, libraryOptions) { 1838 | if (!id) { 1839 | throw new Error('null name'); 1840 | } 1841 | 1842 | let url = 'Library/VirtualFolders/LibraryOptions'; 1843 | 1844 | url = this.getUrl(url); 1845 | 1846 | return this.ajax({ 1847 | type: 'POST', 1848 | url, 1849 | data: JSON.stringify({ 1850 | Id: id, 1851 | LibraryOptions: libraryOptions 1852 | }), 1853 | contentType: 'application/json' 1854 | }); 1855 | } 1856 | 1857 | /** 1858 | * Renames a virtual folder 1859 | * @param {String} name 1860 | */ 1861 | renameVirtualFolder(name, newName, refreshLibrary) { 1862 | if (!name) { 1863 | throw new Error('null name'); 1864 | } 1865 | 1866 | let url = 'Library/VirtualFolders/Name'; 1867 | 1868 | url = this.getUrl(url, { 1869 | refreshLibrary: refreshLibrary ? true : false, 1870 | newName, 1871 | name 1872 | }); 1873 | 1874 | return this.ajax({ 1875 | type: 'POST', 1876 | url 1877 | }); 1878 | } 1879 | 1880 | /** 1881 | * Adds an additional mediaPath to an existing virtual folder 1882 | * @param {String} name 1883 | */ 1884 | addMediaPath(virtualFolderName, mediaPath, networkSharePath, refreshLibrary) { 1885 | if (!virtualFolderName) { 1886 | throw new Error('null virtualFolderName'); 1887 | } 1888 | 1889 | if (!mediaPath) { 1890 | throw new Error('null mediaPath'); 1891 | } 1892 | 1893 | let url = 'Library/VirtualFolders/Paths'; 1894 | 1895 | const pathInfo = { 1896 | Path: mediaPath 1897 | }; 1898 | if (networkSharePath) { 1899 | pathInfo.NetworkPath = networkSharePath; 1900 | } 1901 | 1902 | url = this.getUrl(url, { 1903 | refreshLibrary: refreshLibrary ? true : false 1904 | }); 1905 | 1906 | return this.ajax({ 1907 | type: 'POST', 1908 | url, 1909 | data: JSON.stringify({ 1910 | Name: virtualFolderName, 1911 | PathInfo: pathInfo 1912 | }), 1913 | contentType: 'application/json' 1914 | }); 1915 | } 1916 | 1917 | updateMediaPath(virtualFolderName, pathInfo) { 1918 | if (!virtualFolderName) { 1919 | throw new Error('null virtualFolderName'); 1920 | } 1921 | 1922 | if (!pathInfo) { 1923 | throw new Error('null pathInfo'); 1924 | } 1925 | 1926 | let url = 'Library/VirtualFolders/Paths/Update'; 1927 | 1928 | url = this.getUrl(url); 1929 | 1930 | return this.ajax({ 1931 | type: 'POST', 1932 | url, 1933 | data: JSON.stringify({ 1934 | Name: virtualFolderName, 1935 | PathInfo: pathInfo 1936 | }), 1937 | contentType: 'application/json' 1938 | }); 1939 | } 1940 | 1941 | /** 1942 | * Removes a media path from a virtual folder 1943 | * @param {String} name 1944 | */ 1945 | removeMediaPath(virtualFolderName, mediaPath, refreshLibrary) { 1946 | if (!virtualFolderName) { 1947 | throw new Error('null virtualFolderName'); 1948 | } 1949 | 1950 | if (!mediaPath) { 1951 | throw new Error('null mediaPath'); 1952 | } 1953 | 1954 | let url = 'Library/VirtualFolders/Paths'; 1955 | 1956 | url = this.getUrl(url, { 1957 | refreshLibrary: refreshLibrary ? true : false, 1958 | path: mediaPath, 1959 | name: virtualFolderName 1960 | }); 1961 | 1962 | return this.ajax({ 1963 | type: 'DELETE', 1964 | url 1965 | }); 1966 | } 1967 | 1968 | /** 1969 | * Deletes a user 1970 | * @param {String} id 1971 | */ 1972 | deleteUser(id) { 1973 | if (!id) { 1974 | throw new Error('null id'); 1975 | } 1976 | 1977 | const url = this.getUrl(`Users/${id}`); 1978 | 1979 | return this.ajax({ 1980 | type: 'DELETE', 1981 | url 1982 | }); 1983 | } 1984 | 1985 | /** 1986 | * Deletes a user image 1987 | * @param {String} userId 1988 | * @param {String} imageType The type of image to delete, based on the server-side ImageType enum. 1989 | */ 1990 | deleteUserImage(userId, imageType, imageIndex) { 1991 | if (!userId) { 1992 | throw new Error('null userId'); 1993 | } 1994 | 1995 | if (!imageType) { 1996 | throw new Error('null imageType'); 1997 | } 1998 | 1999 | let url = this.getUrl(`Users/${userId}/Images/${imageType}`); 2000 | 2001 | if (imageIndex != null) { 2002 | url += `/${imageIndex}`; 2003 | } 2004 | 2005 | return this.ajax({ 2006 | type: 'DELETE', 2007 | url 2008 | }); 2009 | } 2010 | 2011 | deleteItemImage(itemId, imageType, imageIndex) { 2012 | if (!imageType) { 2013 | throw new Error('null imageType'); 2014 | } 2015 | 2016 | let url = this.getUrl(`Items/${itemId}/Images`); 2017 | 2018 | url += `/${imageType}`; 2019 | 2020 | if (imageIndex != null) { 2021 | url += `/${imageIndex}`; 2022 | } 2023 | 2024 | return this.ajax({ 2025 | type: 'DELETE', 2026 | url 2027 | }); 2028 | } 2029 | 2030 | deleteItem(itemId) { 2031 | if (!itemId) { 2032 | throw new Error('null itemId'); 2033 | } 2034 | 2035 | const url = this.getUrl(`Items/${itemId}`); 2036 | 2037 | return this.ajax({ 2038 | type: 'DELETE', 2039 | url 2040 | }); 2041 | } 2042 | 2043 | stopActiveEncodings(playSessionId) { 2044 | const options = { 2045 | deviceId: this.deviceId() 2046 | }; 2047 | 2048 | if (playSessionId) { 2049 | options.PlaySessionId = playSessionId; 2050 | } 2051 | 2052 | const url = this.getUrl('Videos/ActiveEncodings', options); 2053 | 2054 | return this.ajax({ 2055 | type: 'DELETE', 2056 | url 2057 | }); 2058 | } 2059 | 2060 | reportCapabilities(options) { 2061 | const url = this.getUrl('Sessions/Capabilities/Full'); 2062 | 2063 | return this.ajax({ 2064 | type: 'POST', 2065 | url, 2066 | data: JSON.stringify(options), 2067 | contentType: 'application/json' 2068 | }); 2069 | } 2070 | 2071 | updateItemImageIndex(itemId, imageType, imageIndex, newIndex) { 2072 | if (!imageType) { 2073 | throw new Error('null imageType'); 2074 | } 2075 | 2076 | const options = { newIndex }; 2077 | 2078 | const url = this.getUrl(`Items/${itemId}/Images/${imageType}/${imageIndex}/Index`, options); 2079 | 2080 | return this.ajax({ 2081 | type: 'POST', 2082 | url 2083 | }); 2084 | } 2085 | 2086 | getItemImageInfos(itemId) { 2087 | const url = this.getUrl(`Items/${itemId}/Images`); 2088 | 2089 | return this.getJSON(url); 2090 | } 2091 | 2092 | getCriticReviews(itemId, options) { 2093 | if (!itemId) { 2094 | throw new Error('null itemId'); 2095 | } 2096 | 2097 | const url = this.getUrl(`Items/${itemId}/CriticReviews`, options); 2098 | 2099 | return this.getJSON(url); 2100 | } 2101 | 2102 | getItemDownloadUrl(itemId) { 2103 | if (!itemId) { 2104 | throw new Error('itemId cannot be empty'); 2105 | } 2106 | 2107 | const url = `Items/${itemId}/Download`; 2108 | 2109 | return this.getUrl(url, { 2110 | api_key: this.accessToken() 2111 | }); 2112 | } 2113 | 2114 | getSessions(options) { 2115 | const url = this.getUrl('Sessions', options); 2116 | 2117 | return this.getJSON(url); 2118 | } 2119 | 2120 | /** 2121 | * Uploads a user image 2122 | * @param {String} userId 2123 | * @param {String} imageType The type of image to delete, based on the server-side ImageType enum. 2124 | * @param {Object} file The file from the input element 2125 | */ 2126 | uploadUserImage(userId, imageType, file) { 2127 | if (!userId) { 2128 | throw new Error('null userId'); 2129 | } 2130 | 2131 | if (!imageType) { 2132 | throw new Error('null imageType'); 2133 | } 2134 | 2135 | if (!file) { 2136 | throw new Error('File must be an image.'); 2137 | } 2138 | 2139 | if (!file.type.startsWith('image/')) { 2140 | throw new Error('File must be an image.'); 2141 | } 2142 | 2143 | const instance = this; 2144 | 2145 | return new Promise((resolve, reject) => { 2146 | const reader = new FileReader(); 2147 | 2148 | reader.onerror = () => { 2149 | reject(); 2150 | }; 2151 | 2152 | reader.onabort = () => { 2153 | reject(); 2154 | }; 2155 | 2156 | // Closure to capture the file information. 2157 | reader.onload = (e) => { 2158 | // Split by a comma to remove the url: prefix 2159 | const data = e.target.result.split(',')[1]; 2160 | 2161 | const url = instance.getUrl(`Users/${userId}/Images/${imageType}`); 2162 | 2163 | instance 2164 | .ajax({ 2165 | type: 'POST', 2166 | url, 2167 | data, 2168 | contentType: file.type 2169 | }) 2170 | .then(resolve, reject); 2171 | }; 2172 | 2173 | // Read in the image file as a data URL. 2174 | reader.readAsDataURL(file); 2175 | }); 2176 | } 2177 | 2178 | uploadItemImage(itemId, imageType, file) { 2179 | if (!itemId) { 2180 | throw new Error('null itemId'); 2181 | } 2182 | 2183 | if (!imageType) { 2184 | throw new Error('null imageType'); 2185 | } 2186 | 2187 | if (!file) { 2188 | throw new Error('File must be an image.'); 2189 | } 2190 | 2191 | if (!file.type.startsWith('image/')) { 2192 | throw new Error('File must be an image.'); 2193 | } 2194 | 2195 | let url = this.getUrl(`Items/${itemId}/Images`); 2196 | 2197 | url += `/${imageType}`; 2198 | const instance = this; 2199 | 2200 | return new Promise((resolve, reject) => { 2201 | const reader = new FileReader(); 2202 | 2203 | reader.onerror = () => { 2204 | reject(); 2205 | }; 2206 | 2207 | reader.onabort = () => { 2208 | reject(); 2209 | }; 2210 | 2211 | // Closure to capture the file information. 2212 | reader.onload = (e) => { 2213 | // Split by a comma to remove the url: prefix 2214 | const data = e.target.result.split(',')[1]; 2215 | 2216 | instance 2217 | .ajax({ 2218 | type: 'POST', 2219 | url, 2220 | data, 2221 | contentType: file.type 2222 | }) 2223 | .then(resolve, reject); 2224 | }; 2225 | 2226 | // Read in the image file as a data URL. 2227 | reader.readAsDataURL(file); 2228 | }); 2229 | } 2230 | 2231 | uploadItemSubtitle(itemId, language, isForced, file) { 2232 | if (!itemId) { 2233 | throw new SyntaxError('Missing itemId'); 2234 | } 2235 | 2236 | if (!language) { 2237 | throw new SyntaxError('Missing language'); 2238 | } 2239 | 2240 | if (typeof isForced !== 'boolean') { 2241 | throw new TypeError('Parameter isForced must be a boolean.'); 2242 | } 2243 | 2244 | if (!file) { 2245 | throw new SyntaxError('File must be a subtitle file.'); 2246 | } 2247 | 2248 | const format = file.name.substring(file.name.lastIndexOf('.') + 1).toLowerCase(); 2249 | 2250 | if (!['sub', 'srt', 'vtt', 'ass', 'ssa'].includes(format)) { 2251 | throw new Error('Invalid subtitle format.'); 2252 | } 2253 | 2254 | let url = this.getUrl(`Videos/${itemId}/Subtitles`); 2255 | 2256 | return new Promise((resolve, reject) => { 2257 | const reader = new FileReader(); 2258 | 2259 | reader.onerror = () => { 2260 | reject(); 2261 | }; 2262 | 2263 | reader.onabort = () => { 2264 | reject(); 2265 | }; 2266 | 2267 | // Closure to capture the file information. 2268 | reader.onload = (e) => { 2269 | // Split by a comma to remove the url: prefix 2270 | const data = e.target.result.split(',')[1]; 2271 | 2272 | this.ajax({ 2273 | type: 'POST', 2274 | url, 2275 | contentType: 'application/json', 2276 | data: JSON.stringify({ 2277 | language: language, 2278 | format: format, 2279 | isForced: isForced, 2280 | data: data 2281 | }) 2282 | }) 2283 | .then(resolve, reject); 2284 | }; 2285 | 2286 | // Read in the image file as a data URL. 2287 | reader.readAsDataURL(file); 2288 | }); 2289 | } 2290 | 2291 | /** 2292 | * Gets the list of installed plugins on the server 2293 | */ 2294 | getInstalledPlugins() { 2295 | const options = {}; 2296 | 2297 | const url = this.getUrl('Plugins', options); 2298 | 2299 | return this.getJSON(url); 2300 | } 2301 | 2302 | /** 2303 | * Gets a user by id 2304 | * @param {String} id 2305 | */ 2306 | getUser(id) { 2307 | if (!id) { 2308 | throw new Error('Must supply a userId'); 2309 | } 2310 | 2311 | const url = this.getUrl(`Users/${id}`); 2312 | 2313 | return this.getJSON(url); 2314 | } 2315 | 2316 | /** 2317 | * Gets a studio 2318 | */ 2319 | getStudio(name, userId) { 2320 | if (!name) { 2321 | throw new Error('null name'); 2322 | } 2323 | 2324 | const options = {}; 2325 | 2326 | if (userId) { 2327 | options.userId = userId; 2328 | } 2329 | 2330 | const url = this.getUrl(`Studios/${this.encodeName(name)}`, options); 2331 | 2332 | return this.getJSON(url); 2333 | } 2334 | 2335 | /** 2336 | * Gets a genre 2337 | */ 2338 | getGenre(name, userId) { 2339 | if (!name) { 2340 | throw new Error('null name'); 2341 | } 2342 | 2343 | const options = {}; 2344 | 2345 | if (userId) { 2346 | options.userId = userId; 2347 | } 2348 | 2349 | const url = this.getUrl(`Genres/${this.encodeName(name)}`, options); 2350 | 2351 | return this.getJSON(url); 2352 | } 2353 | 2354 | getMusicGenre(name, userId) { 2355 | if (!name) { 2356 | throw new Error('null name'); 2357 | } 2358 | 2359 | const options = {}; 2360 | 2361 | if (userId) { 2362 | options.userId = userId; 2363 | } 2364 | 2365 | const url = this.getUrl(`MusicGenres/${this.encodeName(name)}`, options); 2366 | 2367 | return this.getJSON(url); 2368 | } 2369 | 2370 | /** 2371 | * Gets an artist 2372 | */ 2373 | getArtist(name, userId) { 2374 | if (!name) { 2375 | throw new Error('null name'); 2376 | } 2377 | 2378 | const options = {}; 2379 | 2380 | if (userId) { 2381 | options.userId = userId; 2382 | } 2383 | 2384 | const url = this.getUrl(`Artists/${this.encodeName(name)}`, options); 2385 | 2386 | return this.getJSON(url); 2387 | } 2388 | 2389 | /** 2390 | * Gets a Person 2391 | */ 2392 | getPerson(name, userId) { 2393 | if (!name) { 2394 | throw new Error('null name'); 2395 | } 2396 | 2397 | const options = {}; 2398 | 2399 | if (userId) { 2400 | options.userId = userId; 2401 | } 2402 | 2403 | const url = this.getUrl(`Persons/${this.encodeName(name)}`, options); 2404 | 2405 | return this.getJSON(url); 2406 | } 2407 | 2408 | getPublicUsers() { 2409 | const url = this.getUrl('users/public'); 2410 | 2411 | return this.ajax( 2412 | { 2413 | type: 'GET', 2414 | url, 2415 | dataType: 'json' 2416 | }, 2417 | false 2418 | ); 2419 | } 2420 | 2421 | /** 2422 | * Gets all users from the server 2423 | */ 2424 | getUsers(options) { 2425 | const url = this.getUrl('users', options || {}); 2426 | 2427 | return this.getJSON(url); 2428 | } 2429 | 2430 | /** 2431 | * Gets all available parental ratings from the server 2432 | */ 2433 | getParentalRatings() { 2434 | const url = this.getUrl('Localization/ParentalRatings'); 2435 | 2436 | return this.getJSON(url); 2437 | } 2438 | 2439 | getDefaultImageQuality(imageType) { 2440 | return imageType.toLowerCase() === 'backdrop' ? 80 : 90; 2441 | } 2442 | 2443 | /** 2444 | * Constructs a url for a user image 2445 | * @param {String} userId 2446 | * @param {Object} options 2447 | * Options supports the following properties: 2448 | * width - download the image at a fixed width 2449 | * height - download the image at a fixed height 2450 | * maxWidth - download the image at a maxWidth (touch box on the inside) 2451 | * maxHeight - download the image at a maxHeight (touch box on the inside) 2452 | * fillWidth - scale the image down to fill a fillWidth wide box (touch box on the outside) 2453 | * fillHeight - scale the image down to fill a fillHeight high box (touch box on the outside) 2454 | * quality - A scale of 0-100. This should almost always be omitted as the default will suffice. 2455 | * For best results do not specify both width and height together, as aspect ratio might be altered. 2456 | */ 2457 | getUserImageUrl(userId, options) { 2458 | if (!userId) { 2459 | throw new Error('null userId'); 2460 | } 2461 | 2462 | options = options || {}; 2463 | 2464 | let url = `Users/${userId}/Images/${options.type}`; 2465 | 2466 | if (options.index != null) { 2467 | url += `/${options.index}`; 2468 | } 2469 | 2470 | normalizeImageOptions(this, options); 2471 | 2472 | // Don't put these on the query string 2473 | delete options.type; 2474 | delete options.index; 2475 | 2476 | return this.getUrl(url, options); 2477 | } 2478 | 2479 | /** 2480 | * Constructs a url for an item image 2481 | * @param {String} itemId 2482 | * @param {Object} options 2483 | * Options supports the following properties: 2484 | * type - Primary, logo, backdrop, etc. See the server-side enum ImageType 2485 | * index - When downloading a backdrop, use this to specify which one (omitting is equivalent to zero) 2486 | * width - download the image at a fixed width 2487 | * height - download the image at a fixed height 2488 | * maxWidth - download the image at a maxWidth (touch box on the inside) 2489 | * maxHeight - download the image at a maxHeight (touch box on the inside) 2490 | * fillWidth - scale the image down to fill a fillWidth wide box (touch box on the outside) 2491 | * fillHeight - scale the image down to fill a fillHeight high box (touch box on the outside) 2492 | * quality - A scale of 0-100. This should almost always be omitted as the default will suffice. 2493 | * For best results do not specify both width and height together, as aspect ratio might be altered. 2494 | */ 2495 | getImageUrl(itemId, options) { 2496 | if (!itemId) { 2497 | throw new Error('itemId cannot be empty'); 2498 | } 2499 | 2500 | options = options || {}; 2501 | 2502 | let url = `Items/${itemId}/Images/${options.type}`; 2503 | 2504 | if (options.index != null) { 2505 | url += `/${options.index}`; 2506 | } 2507 | 2508 | options.quality = options.quality || this.getDefaultImageQuality(options.type); 2509 | 2510 | if (this.normalizeImageOptions) { 2511 | this.normalizeImageOptions(options); 2512 | } 2513 | 2514 | // Don't put these on the query string 2515 | delete options.type; 2516 | delete options.index; 2517 | 2518 | return this.getUrl(url, options); 2519 | } 2520 | 2521 | getScaledImageUrl(itemId, options) { 2522 | if (!itemId) { 2523 | throw new Error('itemId cannot be empty'); 2524 | } 2525 | 2526 | options = options || {}; 2527 | 2528 | let url = `Items/${itemId}/Images/${options.type}`; 2529 | 2530 | if (options.index != null) { 2531 | url += `/${options.index}`; 2532 | } 2533 | 2534 | normalizeImageOptions(this, options); 2535 | 2536 | // Don't put these on the query string 2537 | delete options.type; 2538 | delete options.index; 2539 | delete options.minScale; 2540 | 2541 | return this.getUrl(url, options); 2542 | } 2543 | 2544 | getThumbImageUrl(item, options) { 2545 | if (!item) { 2546 | throw new Error('null item'); 2547 | } 2548 | 2549 | options = options || {}; 2550 | 2551 | options.imageType = 'thumb'; 2552 | 2553 | if (item.ImageTags && item.ImageTags.Thumb) { 2554 | options.tag = item.ImageTags.Thumb; 2555 | return this.getImageUrl(item.Id, options); 2556 | } else if (item.ParentThumbItemId) { 2557 | options.tag = item.ImageTags.ParentThumbImageTag; 2558 | return this.getImageUrl(item.ParentThumbItemId, options); 2559 | } else { 2560 | return null; 2561 | } 2562 | } 2563 | 2564 | /** 2565 | * Updates a user's password 2566 | * @param {String} userId 2567 | * @param {String} currentPassword 2568 | * @param {String} newPassword 2569 | */ 2570 | updateUserPassword(userId, currentPassword, newPassword) { 2571 | if (!userId) { 2572 | return Promise.reject(); 2573 | } 2574 | 2575 | const url = this.getUrl(`Users/${userId}/Password`); 2576 | 2577 | return this.ajax({ 2578 | type: 'POST', 2579 | url: url, 2580 | data: JSON.stringify({ 2581 | CurrentPw: currentPassword || '', 2582 | NewPw: newPassword 2583 | }), 2584 | contentType: 'application/json' 2585 | }); 2586 | } 2587 | 2588 | /** 2589 | * Updates a user's easy password 2590 | * @param {String} userId 2591 | * @param {String} newPassword 2592 | */ 2593 | updateEasyPassword(userId, newPassword) { 2594 | if (!userId) { 2595 | Promise.reject(); 2596 | return; 2597 | } 2598 | 2599 | const url = this.getUrl(`Users/${userId}/EasyPassword`); 2600 | 2601 | return this.ajax({ 2602 | type: 'POST', 2603 | url, 2604 | data: JSON.stringify({ 2605 | NewPw: newPassword 2606 | }), 2607 | contentType: 'application/json' 2608 | }); 2609 | } 2610 | 2611 | /** 2612 | * Resets a user's password 2613 | * @param {String} userId 2614 | */ 2615 | resetUserPassword(userId) { 2616 | if (!userId) { 2617 | throw new Error('null userId'); 2618 | } 2619 | 2620 | const url = this.getUrl(`Users/${userId}/Password`); 2621 | 2622 | return this.ajax({ 2623 | type: 'POST', 2624 | url, 2625 | data: JSON.stringify({ 2626 | resetPassword: true 2627 | }), 2628 | contentType: 'application/json' 2629 | }); 2630 | } 2631 | 2632 | resetEasyPassword(userId) { 2633 | if (!userId) { 2634 | throw new Error('null userId'); 2635 | } 2636 | 2637 | const url = this.getUrl(`Users/${userId}/EasyPassword`); 2638 | 2639 | return this.ajax({ 2640 | type: 'POST', 2641 | url, 2642 | data: JSON.stringify({ 2643 | resetPassword: true 2644 | }), 2645 | contentType: 'application/json' 2646 | }); 2647 | } 2648 | 2649 | /** 2650 | * Updates the server's configuration 2651 | * @param {Object} configuration 2652 | */ 2653 | updateServerConfiguration(configuration) { 2654 | if (!configuration) { 2655 | throw new Error('null configuration'); 2656 | } 2657 | 2658 | const url = this.getUrl('System/Configuration'); 2659 | 2660 | return this.ajax({ 2661 | type: 'POST', 2662 | url, 2663 | data: JSON.stringify(configuration), 2664 | contentType: 'application/json' 2665 | }); 2666 | } 2667 | 2668 | updateNamedConfiguration(name, configuration) { 2669 | if (!configuration) { 2670 | throw new Error('null configuration'); 2671 | } 2672 | 2673 | const url = this.getUrl(`System/Configuration/${name}`); 2674 | 2675 | return this.ajax({ 2676 | type: 'POST', 2677 | url, 2678 | data: JSON.stringify(configuration), 2679 | contentType: 'application/json' 2680 | }); 2681 | } 2682 | 2683 | updateItem(item) { 2684 | if (!item) { 2685 | throw new Error('null item'); 2686 | } 2687 | 2688 | const url = this.getUrl(`Items/${item.Id}`); 2689 | 2690 | return this.ajax({ 2691 | type: 'POST', 2692 | url, 2693 | data: JSON.stringify(item), 2694 | contentType: 'application/json' 2695 | }); 2696 | } 2697 | 2698 | /** 2699 | * Updates plugin security info 2700 | */ 2701 | updatePluginSecurityInfo(info) { 2702 | const url = this.getUrl('Plugins/SecurityInfo'); 2703 | 2704 | return this.ajax({ 2705 | type: 'POST', 2706 | url, 2707 | data: JSON.stringify(info), 2708 | contentType: 'application/json' 2709 | }); 2710 | } 2711 | 2712 | /** 2713 | * Creates a user 2714 | * @param {Object} user 2715 | */ 2716 | createUser(user) { 2717 | const url = this.getUrl('Users/New'); 2718 | return this.ajax({ 2719 | type: 'POST', 2720 | url, 2721 | data: JSON.stringify(user), 2722 | contentType: 'application/json', 2723 | headers: { 2724 | accept: 'application/json' 2725 | } 2726 | }); 2727 | } 2728 | 2729 | /** 2730 | * Updates a user 2731 | * @param {Object} user 2732 | */ 2733 | updateUser(user) { 2734 | if (!user) { 2735 | throw new Error('null user'); 2736 | } 2737 | 2738 | const url = this.getUrl(`Users/${user.Id}`); 2739 | 2740 | return this.ajax({ 2741 | type: 'POST', 2742 | url, 2743 | data: JSON.stringify(user), 2744 | contentType: 'application/json' 2745 | }); 2746 | } 2747 | 2748 | updateUserPolicy(userId, policy) { 2749 | if (!userId) { 2750 | throw new Error('null userId'); 2751 | } 2752 | if (!policy) { 2753 | throw new Error('null policy'); 2754 | } 2755 | 2756 | const url = this.getUrl(`Users/${userId}/Policy`); 2757 | 2758 | return this.ajax({ 2759 | type: 'POST', 2760 | url, 2761 | data: JSON.stringify(policy), 2762 | contentType: 'application/json' 2763 | }); 2764 | } 2765 | 2766 | updateUserConfiguration(userId, configuration) { 2767 | if (!userId) { 2768 | throw new Error('null userId'); 2769 | } 2770 | if (!configuration) { 2771 | throw new Error('null configuration'); 2772 | } 2773 | 2774 | const url = this.getUrl(`Users/${userId}/Configuration`); 2775 | 2776 | return this.ajax({ 2777 | type: 'POST', 2778 | url, 2779 | data: JSON.stringify(configuration), 2780 | contentType: 'application/json' 2781 | }); 2782 | } 2783 | 2784 | /** 2785 | * Updates the Triggers for a ScheduledTask 2786 | * @param {String} id 2787 | * @param {Object} triggers 2788 | */ 2789 | updateScheduledTaskTriggers(id, triggers) { 2790 | if (!id) { 2791 | throw new Error('null id'); 2792 | } 2793 | 2794 | if (!triggers) { 2795 | throw new Error('null triggers'); 2796 | } 2797 | 2798 | const url = this.getUrl(`ScheduledTasks/${id}/Triggers`); 2799 | 2800 | return this.ajax({ 2801 | type: 'POST', 2802 | url, 2803 | data: JSON.stringify(triggers), 2804 | contentType: 'application/json' 2805 | }); 2806 | } 2807 | 2808 | /** 2809 | * Updates a plugin's configuration 2810 | * @param {String} Id 2811 | * @param {Object} configuration 2812 | */ 2813 | updatePluginConfiguration(id, configuration) { 2814 | if (!id) { 2815 | throw new Error('null Id'); 2816 | } 2817 | 2818 | if (!configuration) { 2819 | throw new Error('null configuration'); 2820 | } 2821 | 2822 | const url = this.getUrl(`Plugins/${id}/Configuration`); 2823 | 2824 | return this.ajax({ 2825 | type: 'POST', 2826 | url, 2827 | data: JSON.stringify(configuration), 2828 | contentType: 'application/json' 2829 | }); 2830 | } 2831 | 2832 | getAncestorItems(itemId, userId) { 2833 | if (!itemId) { 2834 | throw new Error('null itemId'); 2835 | } 2836 | 2837 | const options = {}; 2838 | 2839 | if (userId) { 2840 | options.userId = userId; 2841 | } 2842 | 2843 | const url = this.getUrl(`Items/${itemId}/Ancestors`, options); 2844 | 2845 | return this.getJSON(url); 2846 | } 2847 | 2848 | /** 2849 | * Gets items based on a query, typically for children of a folder 2850 | * @param {String} userId 2851 | * @param {Object} options 2852 | * Options accepts the following properties: 2853 | * itemId - Localize the search to a specific folder (root if omitted) 2854 | * startIndex - Use for paging 2855 | * limit - Use to limit results to a certain number of items 2856 | * filter - Specify one or more ItemFilters, comma delimeted (see server-side enum) 2857 | * sortBy - Specify an ItemSortBy (comma-delimeted list see server-side enum) 2858 | * sortOrder - ascending/descending 2859 | * fields - additional fields to include aside from basic info. This is a comma delimited list. See server-side enum ItemFields. 2860 | * index - the name of the dynamic, localized index function 2861 | * dynamicSortBy - the name of the dynamic localized sort function 2862 | * recursive - Whether or not the query should be recursive 2863 | * searchTerm - search term to use as a filter 2864 | */ 2865 | getItems(userId, options) { 2866 | let url; 2867 | 2868 | if ((typeof userId).toString().toLowerCase() === 'string') { 2869 | url = this.getUrl(`Users/${userId}/Items`, options); 2870 | } else { 2871 | url = this.getUrl('Items', options); 2872 | } 2873 | 2874 | return this.getJSON(url); 2875 | } 2876 | 2877 | getResumableItems(userId, options) { 2878 | if (this.isMinServerVersion('3.2.33')) { 2879 | return this.getJSON(this.getUrl(`Users/${userId}/Items/Resume`, options)); 2880 | } 2881 | 2882 | return this.getItems( 2883 | userId, 2884 | Object.assign( 2885 | { 2886 | SortBy: 'DatePlayed', 2887 | SortOrder: 'Descending', 2888 | Filters: 'IsResumable', 2889 | Recursive: true, 2890 | CollapseBoxSetItems: false, 2891 | ExcludeLocationTypes: 'Virtual' 2892 | }, 2893 | options 2894 | ) 2895 | ); 2896 | } 2897 | 2898 | getMovieRecommendations(options) { 2899 | return this.getJSON(this.getUrl('Movies/Recommendations', options)); 2900 | } 2901 | 2902 | getUpcomingEpisodes(options) { 2903 | return this.getJSON(this.getUrl('Shows/Upcoming', options)); 2904 | } 2905 | 2906 | getUserViews(options = {}, userId) { 2907 | const url = this.getUrl(`Users/${userId || this.getCurrentUserId()}/Views`, options); 2908 | 2909 | return this.getJSON(url); 2910 | } 2911 | 2912 | /** 2913 | Gets artists from an item 2914 | */ 2915 | getArtists(userId, options) { 2916 | if (!userId) { 2917 | throw new Error('null userId'); 2918 | } 2919 | 2920 | options = options || {}; 2921 | options.userId = userId; 2922 | 2923 | const url = this.getUrl('Artists', options); 2924 | 2925 | return this.getJSON(url); 2926 | } 2927 | 2928 | /** 2929 | Gets artists from an item 2930 | */ 2931 | getAlbumArtists(userId, options) { 2932 | if (!userId) { 2933 | throw new Error('null userId'); 2934 | } 2935 | 2936 | options = options || {}; 2937 | options.userId = userId; 2938 | 2939 | const url = this.getUrl('Artists/AlbumArtists', options); 2940 | 2941 | return this.getJSON(url); 2942 | } 2943 | 2944 | /** 2945 | Gets genres from an item 2946 | */ 2947 | getGenres(userId, options) { 2948 | if (!userId) { 2949 | throw new Error('null userId'); 2950 | } 2951 | 2952 | options = options || {}; 2953 | options.userId = userId; 2954 | 2955 | const url = this.getUrl('Genres', options); 2956 | 2957 | return this.getJSON(url); 2958 | } 2959 | 2960 | getMusicGenres(userId, options) { 2961 | if (!userId) { 2962 | throw new Error('null userId'); 2963 | } 2964 | 2965 | options = options || {}; 2966 | options.userId = userId; 2967 | 2968 | const url = this.getUrl('MusicGenres', options); 2969 | 2970 | return this.getJSON(url); 2971 | } 2972 | 2973 | /** 2974 | Gets people from an item 2975 | */ 2976 | getPeople(userId, options) { 2977 | if (!userId) { 2978 | throw new Error('null userId'); 2979 | } 2980 | 2981 | options = options || {}; 2982 | options.userId = userId; 2983 | 2984 | const url = this.getUrl('Persons', options); 2985 | 2986 | return this.getJSON(url); 2987 | } 2988 | 2989 | /** 2990 | Gets studios from an item 2991 | */ 2992 | getStudios(userId, options) { 2993 | if (!userId) { 2994 | throw new Error('null userId'); 2995 | } 2996 | 2997 | options = options || {}; 2998 | options.userId = userId; 2999 | 3000 | const url = this.getUrl('Studios', options); 3001 | 3002 | return this.getJSON(url); 3003 | } 3004 | 3005 | /** 3006 | * Gets local trailers for an item 3007 | */ 3008 | getLocalTrailers(userId, itemId) { 3009 | if (!userId) { 3010 | throw new Error('null userId'); 3011 | } 3012 | if (!itemId) { 3013 | throw new Error('null itemId'); 3014 | } 3015 | 3016 | const url = this.getUrl(`Users/${userId}/Items/${itemId}/LocalTrailers`); 3017 | 3018 | return this.getJSON(url); 3019 | } 3020 | 3021 | getAdditionalVideoParts(userId, itemId) { 3022 | if (!itemId) { 3023 | throw new Error('null itemId'); 3024 | } 3025 | 3026 | const options = {}; 3027 | 3028 | if (userId) { 3029 | options.userId = userId; 3030 | } 3031 | 3032 | const url = this.getUrl(`Videos/${itemId}/AdditionalParts`, options); 3033 | 3034 | return this.getJSON(url); 3035 | } 3036 | 3037 | getThemeMedia(userId, itemId, inherit) { 3038 | if (!itemId) { 3039 | throw new Error('null itemId'); 3040 | } 3041 | 3042 | const options = {}; 3043 | 3044 | if (userId) { 3045 | options.userId = userId; 3046 | } 3047 | 3048 | options.InheritFromParent = inherit || false; 3049 | 3050 | const url = this.getUrl(`Items/${itemId}/ThemeMedia`, options); 3051 | 3052 | return this.getJSON(url); 3053 | } 3054 | 3055 | getSearchHints(options) { 3056 | const url = this.getUrl('Search/Hints', options); 3057 | const serverId = this.serverId(); 3058 | 3059 | return this.getJSON(url).then((result) => { 3060 | result.SearchHints.forEach((i) => { 3061 | i.ServerId = serverId; 3062 | }); 3063 | return result; 3064 | }); 3065 | } 3066 | 3067 | /** 3068 | * Gets special features for an item 3069 | */ 3070 | getSpecialFeatures(userId, itemId) { 3071 | if (!userId) { 3072 | throw new Error('null userId'); 3073 | } 3074 | if (!itemId) { 3075 | throw new Error('null itemId'); 3076 | } 3077 | 3078 | const url = this.getUrl(`Users/${userId}/Items/${itemId}/SpecialFeatures`); 3079 | 3080 | return this.getJSON(url); 3081 | } 3082 | 3083 | getDateParamValue(date) { 3084 | return date.toISOString(); 3085 | } 3086 | 3087 | markPlayed(userId, itemId, date) { 3088 | if (!userId) { 3089 | throw new Error('null userId'); 3090 | } 3091 | 3092 | if (!itemId) { 3093 | throw new Error('null itemId'); 3094 | } 3095 | 3096 | const options = {}; 3097 | 3098 | if (date) { 3099 | options.DatePlayed = this.getDateParamValue(date); 3100 | } 3101 | 3102 | const url = this.getUrl(`Users/${userId}/PlayedItems/${itemId}`, options); 3103 | 3104 | return this.ajax({ 3105 | type: 'POST', 3106 | url, 3107 | dataType: 'json' 3108 | }); 3109 | } 3110 | 3111 | markUnplayed(userId, itemId) { 3112 | if (!userId) { 3113 | throw new Error('null userId'); 3114 | } 3115 | 3116 | if (!itemId) { 3117 | throw new Error('null itemId'); 3118 | } 3119 | 3120 | const url = this.getUrl(`Users/${userId}/PlayedItems/${itemId}`); 3121 | 3122 | return this.ajax({ 3123 | type: 'DELETE', 3124 | url, 3125 | dataType: 'json' 3126 | }); 3127 | } 3128 | 3129 | /** 3130 | * Updates a user's favorite status for an item. 3131 | * @param {String} userId 3132 | * @param {String} itemId 3133 | * @param {Boolean} isFavorite 3134 | */ 3135 | updateFavoriteStatus(userId, itemId, isFavorite) { 3136 | if (!userId) { 3137 | throw new Error('null userId'); 3138 | } 3139 | 3140 | if (!itemId) { 3141 | throw new Error('null itemId'); 3142 | } 3143 | 3144 | const url = this.getUrl(`Users/${userId}/FavoriteItems/${itemId}`); 3145 | 3146 | const method = isFavorite ? 'POST' : 'DELETE'; 3147 | 3148 | return this.ajax({ 3149 | type: method, 3150 | url, 3151 | dataType: 'json' 3152 | }); 3153 | } 3154 | 3155 | /** 3156 | * Updates a user's personal rating for an item 3157 | * @param {String} userId 3158 | * @param {String} itemId 3159 | * @param {Boolean} likes 3160 | */ 3161 | updateUserItemRating(userId, itemId, likes) { 3162 | if (!userId) { 3163 | throw new Error('null userId'); 3164 | } 3165 | 3166 | if (!itemId) { 3167 | throw new Error('null itemId'); 3168 | } 3169 | 3170 | const url = this.getUrl(`Users/${userId}/Items/${itemId}/Rating`, { 3171 | likes 3172 | }); 3173 | 3174 | return this.ajax({ 3175 | type: 'POST', 3176 | url, 3177 | dataType: 'json' 3178 | }); 3179 | } 3180 | 3181 | getItemCounts(userId) { 3182 | const options = {}; 3183 | 3184 | if (userId) { 3185 | options.userId = userId; 3186 | } 3187 | 3188 | const url = this.getUrl('Items/Counts', options); 3189 | 3190 | return this.getJSON(url); 3191 | } 3192 | 3193 | /** 3194 | * Clears a user's personal rating for an item 3195 | * @param {String} userId 3196 | * @param {String} itemId 3197 | */ 3198 | clearUserItemRating(userId, itemId) { 3199 | if (!userId) { 3200 | throw new Error('null userId'); 3201 | } 3202 | 3203 | if (!itemId) { 3204 | throw new Error('null itemId'); 3205 | } 3206 | 3207 | const url = this.getUrl(`Users/${userId}/Items/${itemId}/Rating`); 3208 | 3209 | return this.ajax({ 3210 | type: 'DELETE', 3211 | url, 3212 | dataType: 'json' 3213 | }); 3214 | } 3215 | 3216 | /** 3217 | * Reports the user has started playing something 3218 | * @param {String} userId 3219 | * @param {String} itemId 3220 | */ 3221 | async reportPlaybackStart(options) { 3222 | if (!options) { 3223 | throw new Error('null options'); 3224 | } 3225 | 3226 | await resetReportPlaybackProgress(this, false); 3227 | 3228 | stopBitrateDetection(this); 3229 | 3230 | const url = this.getUrl('Sessions/Playing'); 3231 | 3232 | return this.ajax({ 3233 | type: 'POST', 3234 | data: JSON.stringify(options), 3235 | contentType: 'application/json', 3236 | url 3237 | }); 3238 | } 3239 | 3240 | /** 3241 | * Reports progress viewing an item 3242 | * @param {String} userId 3243 | * @param {String} itemId 3244 | */ 3245 | reportPlaybackProgress(options) { 3246 | if (!options) { 3247 | throw new Error('null options'); 3248 | } 3249 | 3250 | const eventName = options.EventName || 'timeupdate'; 3251 | let reportRateLimitTime = reportRateLimits[eventName] || 0; 3252 | 3253 | const now = new Date().getTime(); 3254 | const msSinceLastReport = now - (this.lastPlaybackProgressReport || 0); 3255 | const newPositionTicks = options.PositionTicks; 3256 | 3257 | if (msSinceLastReport < reportRateLimitTime && eventName === 'timeupdate' && newPositionTicks != null) { 3258 | const expectedReportTicks = 1e4 * msSinceLastReport + (this.lastPlaybackProgressReportTicks || 0); 3259 | if (Math.abs(newPositionTicks - expectedReportTicks) >= 5e7) reportRateLimitTime = 0; 3260 | } 3261 | 3262 | const delay = Math.max(0, reportRateLimitTime - msSinceLastReport); 3263 | 3264 | this.lastPlaybackProgressOptions = options; 3265 | 3266 | if (this.reportPlaybackProgressPromiseDelay) { 3267 | if (reportRateLimitTime < this.reportPlaybackProgressTimeout) { 3268 | this.reportPlaybackProgressTimeout = reportRateLimitTime; 3269 | this.reportPlaybackProgressPromiseDelay.reset(delay); 3270 | } 3271 | 3272 | return this.reportPlaybackProgressPromise; 3273 | } 3274 | 3275 | const resetPromise = () => { 3276 | delete this.lastPlaybackProgressOptions; 3277 | delete this.reportPlaybackProgressTimeout; 3278 | delete this.reportPlaybackProgressPromise; 3279 | delete this.reportPlaybackProgressPromiseDelay; 3280 | delete this.reportPlaybackProgressReset; 3281 | }; 3282 | 3283 | const sendReport = () => { 3284 | this.lastPlaybackProgressReport = new Date().getTime(); 3285 | this.lastPlaybackProgressReportTicks = this.lastPlaybackProgressOptions.PositionTicks; 3286 | 3287 | const url = this.getUrl('Sessions/Playing/Progress'); 3288 | 3289 | return this.ajax({ 3290 | type: 'POST', 3291 | data: JSON.stringify(this.lastPlaybackProgressOptions), 3292 | contentType: 'application/json', 3293 | url: url 3294 | }); 3295 | }; 3296 | 3297 | const promiseDelay = new PromiseDelay(delay); 3298 | 3299 | let cancelled = false; 3300 | 3301 | const promise = promiseDelay.promise() 3302 | .catch(() => { 3303 | cancelled = true; 3304 | }) 3305 | .then(() => { 3306 | if (cancelled) return Promise.resolve(); 3307 | return sendReport(); 3308 | }) 3309 | .finally(() => { 3310 | resetPromise(); 3311 | }); 3312 | 3313 | this.reportPlaybackProgressTimeout = reportRateLimitTime; 3314 | this.reportPlaybackProgressPromise = promise; 3315 | this.reportPlaybackProgressPromiseDelay = promiseDelay; 3316 | this.reportPlaybackProgressReset = (resolve) => { 3317 | if (resolve) { 3318 | promiseDelay.resolve(); 3319 | } else { 3320 | promiseDelay.reject(); 3321 | } 3322 | return promise; 3323 | }; 3324 | 3325 | return promise; 3326 | } 3327 | 3328 | reportOfflineActions(actions) { 3329 | if (!actions) { 3330 | throw new Error('null actions'); 3331 | } 3332 | 3333 | const url = this.getUrl('Sync/OfflineActions'); 3334 | 3335 | return this.ajax({ 3336 | type: 'POST', 3337 | data: JSON.stringify(actions), 3338 | contentType: 'application/json', 3339 | url 3340 | }); 3341 | } 3342 | 3343 | syncData(data) { 3344 | if (!data) { 3345 | throw new Error('null data'); 3346 | } 3347 | 3348 | const url = this.getUrl('Sync/Data'); 3349 | 3350 | return this.ajax({ 3351 | type: 'POST', 3352 | data: JSON.stringify(data), 3353 | contentType: 'application/json', 3354 | url, 3355 | dataType: 'json' 3356 | }); 3357 | } 3358 | 3359 | getReadySyncItems(deviceId) { 3360 | if (!deviceId) { 3361 | throw new Error('null deviceId'); 3362 | } 3363 | 3364 | const url = this.getUrl('Sync/Items/Ready', { 3365 | TargetId: deviceId 3366 | }); 3367 | 3368 | return this.getJSON(url); 3369 | } 3370 | 3371 | reportSyncJobItemTransferred(syncJobItemId) { 3372 | if (!syncJobItemId) { 3373 | throw new Error('null syncJobItemId'); 3374 | } 3375 | 3376 | const url = this.getUrl(`Sync/JobItems/${syncJobItemId}/Transferred`); 3377 | 3378 | return this.ajax({ 3379 | type: 'POST', 3380 | url 3381 | }); 3382 | } 3383 | 3384 | cancelSyncItems(itemIds, targetId) { 3385 | if (!itemIds) { 3386 | throw new Error('null itemIds'); 3387 | } 3388 | 3389 | const url = this.getUrl(`Sync/${targetId || this.deviceId()}/Items`, { 3390 | ItemIds: itemIds.join(',') 3391 | }); 3392 | 3393 | return this.ajax({ 3394 | type: 'DELETE', 3395 | url 3396 | }); 3397 | } 3398 | 3399 | /** 3400 | * Reports a user has stopped playing an item 3401 | * @param {String} userId 3402 | * @param {String} itemId 3403 | */ 3404 | async reportPlaybackStopped(options) { 3405 | if (!options) { 3406 | throw new Error('null options'); 3407 | } 3408 | 3409 | await resetReportPlaybackProgress(this, false); 3410 | 3411 | redetectBitrate(this); 3412 | 3413 | const url = this.getUrl('Sessions/Playing/Stopped'); 3414 | 3415 | return this.ajax({ 3416 | type: 'POST', 3417 | data: JSON.stringify(options), 3418 | contentType: 'application/json', 3419 | url 3420 | }); 3421 | } 3422 | 3423 | sendPlayCommand(sessionId, options) { 3424 | if (!sessionId) { 3425 | throw new Error('null sessionId'); 3426 | } 3427 | 3428 | if (!options) { 3429 | throw new Error('null options'); 3430 | } 3431 | 3432 | const url = this.getUrl(`Sessions/${sessionId}/Playing`, options); 3433 | 3434 | return this.ajax({ 3435 | type: 'POST', 3436 | url 3437 | }); 3438 | } 3439 | 3440 | sendCommand(sessionId, command) { 3441 | if (!sessionId) { 3442 | throw new Error('null sessionId'); 3443 | } 3444 | 3445 | if (!command) { 3446 | throw new Error('null command'); 3447 | } 3448 | 3449 | const url = this.getUrl(`Sessions/${sessionId}/Command`); 3450 | 3451 | const ajaxOptions = { 3452 | type: 'POST', 3453 | url 3454 | }; 3455 | 3456 | ajaxOptions.data = JSON.stringify(command); 3457 | ajaxOptions.contentType = 'application/json'; 3458 | 3459 | return this.ajax(ajaxOptions); 3460 | } 3461 | 3462 | sendMessageCommand(sessionId, options) { 3463 | if (!sessionId) { 3464 | throw new Error('null sessionId'); 3465 | } 3466 | 3467 | if (!options) { 3468 | throw new Error('null options'); 3469 | } 3470 | 3471 | const url = this.getUrl(`Sessions/${sessionId}/Message`); 3472 | 3473 | const ajaxOptions = { 3474 | type: 'POST', 3475 | url 3476 | }; 3477 | 3478 | ajaxOptions.data = JSON.stringify(options); 3479 | ajaxOptions.contentType = 'application/json'; 3480 | 3481 | return this.ajax(ajaxOptions); 3482 | } 3483 | 3484 | sendPlayStateCommand(sessionId, command, options) { 3485 | if (!sessionId) { 3486 | throw new Error('null sessionId'); 3487 | } 3488 | 3489 | if (!command) { 3490 | throw new Error('null command'); 3491 | } 3492 | 3493 | const url = this.getUrl(`Sessions/${sessionId}/Playing/${command}`, options || {}); 3494 | 3495 | return this.ajax({ 3496 | type: 'POST', 3497 | url 3498 | }); 3499 | } 3500 | 3501 | /** 3502 | * Gets a list of all the active SyncPlay groups from the server. 3503 | * @returns {Promise} A Promise that resolves to the list of active groups. 3504 | * @since 10.6.0 3505 | */ 3506 | getSyncPlayGroups() { 3507 | const url = this.getUrl(`SyncPlay/List`); 3508 | 3509 | return this.ajax({ 3510 | type: 'GET', 3511 | url: url 3512 | }); 3513 | } 3514 | 3515 | /** 3516 | * Creates a SyncPlay group on the server with the current client as member. 3517 | * @param {object} options Settings for the SyncPlay group to create. 3518 | * @returns {Promise} A Promise fulfilled upon request completion. 3519 | * @since 10.6.0 3520 | */ 3521 | createSyncPlayGroup(options = {}) { 3522 | const url = this.getUrl(`SyncPlay/New`); 3523 | 3524 | return this.ajax({ 3525 | type: 'POST', 3526 | url: url, 3527 | data: JSON.stringify(options), 3528 | contentType: 'application/json' 3529 | }); 3530 | } 3531 | 3532 | /** 3533 | * Joins the client to a given SyncPlay group on the server. 3534 | * @param {object} options Information about the SyncPlay group to join. 3535 | * @returns {Promise} A Promise fulfilled upon request completion. 3536 | * @since 10.6.0 3537 | */ 3538 | joinSyncPlayGroup(options = {}) { 3539 | const url = this.getUrl(`SyncPlay/Join`); 3540 | 3541 | return this.ajax({ 3542 | type: 'POST', 3543 | url: url, 3544 | data: JSON.stringify(options), 3545 | contentType: 'application/json' 3546 | }); 3547 | } 3548 | 3549 | /** 3550 | * Leaves the current SyncPlay group. 3551 | * @returns {Promise} A Promise fulfilled upon request completion. 3552 | * @since 10.6.0 3553 | */ 3554 | leaveSyncPlayGroup() { 3555 | const url = this.getUrl(`SyncPlay/Leave`); 3556 | 3557 | return this.ajax({ 3558 | type: 'POST', 3559 | url: url 3560 | }); 3561 | } 3562 | 3563 | /** 3564 | * Sends a ping to the SyncPlay group on the server. 3565 | * @param {object} options Information about the ping 3566 | * @returns {Promise} A Promise fulfilled upon request completion. 3567 | * @since 10.6.0 3568 | */ 3569 | sendSyncPlayPing(options = {}) { 3570 | const url = this.getUrl(`SyncPlay/Ping`); 3571 | 3572 | return this.ajax({ 3573 | type: 'POST', 3574 | url: url, 3575 | data: JSON.stringify(options), 3576 | contentType: 'application/json' 3577 | }); 3578 | } 3579 | 3580 | /** 3581 | * Requests to set a new playlist for the SyncPlay group. 3582 | * @param {object} options Options about the new playlist. 3583 | * @returns {Promise} A Promise fulfilled upon request completion. 3584 | * @since 10.7.0 3585 | */ 3586 | requestSyncPlaySetNewQueue(options = {}) { 3587 | const url = this.getUrl(`SyncPlay/SetNewQueue`); 3588 | 3589 | return this.ajax({ 3590 | type: 'POST', 3591 | url: url, 3592 | data: JSON.stringify(options), 3593 | contentType: 'application/json' 3594 | }); 3595 | } 3596 | 3597 | /** 3598 | * Requests to change playing item in the SyncPlay group. 3599 | * @param {object} options Options about the new playing item. 3600 | * @returns {Promise} A Promise fulfilled upon request completion. 3601 | * @since 10.7.0 3602 | */ 3603 | requestSyncPlaySetPlaylistItem(options = {}) { 3604 | const url = this.getUrl(`SyncPlay/SetPlaylistItem`); 3605 | 3606 | return this.ajax({ 3607 | type: 'POST', 3608 | url: url, 3609 | data: JSON.stringify(options), 3610 | contentType: 'application/json' 3611 | }); 3612 | } 3613 | 3614 | /** 3615 | * Requests to remove items from the playlist of the SyncPlay group. 3616 | * @param {object} options Options about the items to remove. 3617 | * @returns {Promise} A Promise fulfilled upon request completion. 3618 | * @since 10.7.0 3619 | */ 3620 | requestSyncPlayRemoveFromPlaylist(options = {}) { 3621 | const url = this.getUrl(`SyncPlay/RemoveFromPlaylist`); 3622 | 3623 | return this.ajax({ 3624 | type: 'POST', 3625 | url: url, 3626 | data: JSON.stringify(options), 3627 | contentType: 'application/json' 3628 | }); 3629 | } 3630 | 3631 | /** 3632 | * Requests to move an item in the playlist of the SyncPlay group. 3633 | * @param {object} options Options about the item to move. 3634 | * @returns {Promise} A Promise fulfilled upon request completion. 3635 | * @since 10.7.0 3636 | */ 3637 | requestSyncPlayMovePlaylistItem(options = {}) { 3638 | const url = this.getUrl(`SyncPlay/MovePlaylistItem`); 3639 | 3640 | return this.ajax({ 3641 | type: 'POST', 3642 | url: url, 3643 | data: JSON.stringify(options), 3644 | contentType: 'application/json' 3645 | }); 3646 | } 3647 | 3648 | /** 3649 | * Requests to queue items in the playlist of the SyncPlay group. 3650 | * @param {object} options Options about the new items. 3651 | * @returns {Promise} A Promise fulfilled upon request completion. 3652 | * @since 10.7.0 3653 | */ 3654 | requestSyncPlayQueue(options = {}) { 3655 | const url = this.getUrl(`SyncPlay/Queue`); 3656 | 3657 | return this.ajax({ 3658 | type: 'POST', 3659 | url: url, 3660 | data: JSON.stringify(options), 3661 | contentType: 'application/json' 3662 | }); 3663 | } 3664 | 3665 | /** 3666 | * Requests a playback unpause for the SyncPlay group. 3667 | * @returns {Promise} A Promise fulfilled upon request completion. 3668 | * @since 10.7.0 3669 | */ 3670 | requestSyncPlayUnpause() { 3671 | const url = this.getUrl(`SyncPlay/Unpause`); 3672 | 3673 | return this.ajax({ 3674 | type: 'POST', 3675 | url: url 3676 | }); 3677 | } 3678 | 3679 | /** 3680 | * Requests a playback pause for the SyncPlay group. 3681 | * @returns {Promise} A Promise fulfilled upon request completion. 3682 | * @since 10.6.0 3683 | */ 3684 | requestSyncPlayPause() { 3685 | const url = this.getUrl(`SyncPlay/Pause`); 3686 | 3687 | return this.ajax({ 3688 | type: 'POST', 3689 | url: url 3690 | }); 3691 | } 3692 | 3693 | /** 3694 | * Requests a playback seek for the SyncPlay group. 3695 | * @param {object} options Object containing the requested seek position. 3696 | * @returns {Promise} A Promise fulfilled upon request completion. 3697 | * @since 10.6.0 3698 | */ 3699 | requestSyncPlaySeek(options = {}) { 3700 | const url = this.getUrl(`SyncPlay/Seek`); 3701 | 3702 | return this.ajax({ 3703 | type: 'POST', 3704 | url: url, 3705 | data: JSON.stringify(options), 3706 | contentType: 'application/json' 3707 | }); 3708 | } 3709 | 3710 | /** 3711 | * Requests the next item for the SyncPlay group. 3712 | * @param {object} options Options about the current playlist. 3713 | * @returns {Promise} A Promise fulfilled upon request completion. 3714 | * @since 10.7.0 3715 | */ 3716 | requestSyncPlayNextItem(options = {}) { 3717 | const url = this.getUrl(`SyncPlay/NextItem`); 3718 | 3719 | return this.ajax({ 3720 | type: 'POST', 3721 | url: url, 3722 | data: JSON.stringify(options), 3723 | contentType: 'application/json' 3724 | }); 3725 | } 3726 | 3727 | /** 3728 | * Requests the previous item for the SyncPlay group. 3729 | * @param {object} options Options about the current playlist. 3730 | * @returns {Promise} A Promise fulfilled upon request completion. 3731 | * @since 10.7.0 3732 | */ 3733 | requestSyncPlayPreviousItem(options = {}) { 3734 | const url = this.getUrl(`SyncPlay/PreviousItem`); 3735 | 3736 | return this.ajax({ 3737 | type: 'POST', 3738 | url: url, 3739 | data: JSON.stringify(options), 3740 | contentType: 'application/json' 3741 | }); 3742 | } 3743 | 3744 | /** 3745 | * Requests to change repeat mode for the SyncPlay group. 3746 | * @param {object} options Options about the repeat mode. 3747 | * @returns {Promise} A Promise fulfilled upon request completion. 3748 | * @since 10.7.0 3749 | */ 3750 | requestSyncPlaySetRepeatMode(options = {}) { 3751 | const url = this.getUrl(`SyncPlay/SetRepeatMode`); 3752 | 3753 | return this.ajax({ 3754 | type: 'POST', 3755 | url: url, 3756 | data: JSON.stringify(options), 3757 | contentType: 'application/json' 3758 | }); 3759 | } 3760 | 3761 | /** 3762 | * Requests to change shuffle mode for the SyncPlay group. 3763 | * @param {object} options Options about the shuffle mode. 3764 | * @returns {Promise} A Promise fulfilled upon request completion. 3765 | * @since 10.7.0 3766 | */ 3767 | requestSyncPlaySetShuffleMode(options = {}) { 3768 | const url = this.getUrl(`SyncPlay/SetShuffleMode`); 3769 | 3770 | return this.ajax({ 3771 | type: 'POST', 3772 | url: url, 3773 | data: JSON.stringify(options), 3774 | contentType: 'application/json' 3775 | }); 3776 | } 3777 | 3778 | /** 3779 | * Notifies the server that this client is buffering. 3780 | * @param {object} options The player status. 3781 | * @returns {Promise} A Promise fulfilled upon request completion. 3782 | * @since 10.7.0 3783 | */ 3784 | requestSyncPlayBuffering(options = {}) { 3785 | const url = this.getUrl(`SyncPlay/Buffering`); 3786 | 3787 | return this.ajax({ 3788 | type: 'POST', 3789 | url: url, 3790 | data: JSON.stringify(options), 3791 | contentType: 'application/json' 3792 | }); 3793 | } 3794 | 3795 | /** 3796 | * Notifies the server that this client is ready for playback. 3797 | * @param {object} options The player status. 3798 | * @returns {Promise} A Promise fulfilled upon request completion. 3799 | * @since 10.7.0 3800 | */ 3801 | requestSyncPlayReady(options = {}) { 3802 | const url = this.getUrl(`SyncPlay/Ready`); 3803 | 3804 | return this.ajax({ 3805 | type: 'POST', 3806 | url: url, 3807 | data: JSON.stringify(options), 3808 | contentType: 'application/json' 3809 | }); 3810 | } 3811 | 3812 | /** 3813 | * Requests to change this client's ignore-wait state. 3814 | * @param {object} options Options about the ignore-wait state. 3815 | * @returns {Promise} A Promise fulfilled upon request completion. 3816 | * @since 10.7.0 3817 | */ 3818 | requestSyncPlaySetIgnoreWait(options = {}) { 3819 | const url = this.getUrl(`SyncPlay/SetIgnoreWait`); 3820 | 3821 | return this.ajax({ 3822 | type: 'POST', 3823 | url: url, 3824 | data: JSON.stringify(options), 3825 | contentType: 'application/json' 3826 | }); 3827 | } 3828 | 3829 | createPackageReview(review) { 3830 | const url = this.getUrl(`Packages/Reviews/${review.id}`, review); 3831 | 3832 | return this.ajax({ 3833 | type: 'POST', 3834 | url 3835 | }); 3836 | } 3837 | 3838 | getPackageReviews(packageId, minRating, maxRating, limit) { 3839 | if (!packageId) { 3840 | throw new Error('null packageId'); 3841 | } 3842 | 3843 | const options = {}; 3844 | 3845 | if (minRating) { 3846 | options.MinRating = minRating; 3847 | } 3848 | if (maxRating) { 3849 | options.MaxRating = maxRating; 3850 | } 3851 | if (limit) { 3852 | options.Limit = limit; 3853 | } 3854 | 3855 | const url = this.getUrl(`Packages/${packageId}/Reviews`, options); 3856 | 3857 | return this.getJSON(url); 3858 | } 3859 | 3860 | getSavedEndpointInfo() { 3861 | return this._endPointInfo; 3862 | } 3863 | 3864 | getEndpointInfo() { 3865 | const savedValue = this._endPointInfo; 3866 | if (savedValue) { 3867 | return Promise.resolve(savedValue); 3868 | } 3869 | 3870 | const instance = this; 3871 | return this.getJSON(this.getUrl('System/Endpoint')).then((endPointInfo) => { 3872 | setSavedEndpointInfo(instance, endPointInfo); 3873 | return endPointInfo; 3874 | }); 3875 | } 3876 | 3877 | getLatestItems(options = {}) { 3878 | return this.getJSON(this.getUrl(`Users/${this.getCurrentUserId()}/Items/Latest`, options)); 3879 | } 3880 | 3881 | getFilters(options) { 3882 | return this.getJSON(this.getUrl('Items/Filters2', options)); 3883 | } 3884 | 3885 | setSystemInfo(info) { 3886 | this._serverVersion = info.Version; 3887 | } 3888 | 3889 | serverVersion() { 3890 | return this._serverVersion; 3891 | } 3892 | 3893 | isMinServerVersion(version) { 3894 | const serverVersion = this.serverVersion(); 3895 | 3896 | if (serverVersion) { 3897 | return compareVersions(serverVersion, version) >= 0; 3898 | } 3899 | 3900 | return false; 3901 | } 3902 | 3903 | handleMessageReceived(msg) { 3904 | onMessageReceivedInternal(this, msg); 3905 | } 3906 | } 3907 | 3908 | function setSavedEndpointInfo(instance, info) { 3909 | instance._endPointInfo = info; 3910 | } 3911 | 3912 | function getTryConnectPromise(instance, url, state, resolve, reject) { 3913 | console.log('getTryConnectPromise ' + url); 3914 | 3915 | fetchWithTimeout( 3916 | instance.getUrl('system/info/public', null, url), 3917 | { 3918 | method: 'GET', 3919 | accept: 'application/json' 3920 | 3921 | // Commenting this out since the fetch api doesn't have a timeout option yet 3922 | //timeout: timeout 3923 | }, 3924 | 15000 3925 | ).then( 3926 | () => { 3927 | if (!state.resolved) { 3928 | state.resolved = true; 3929 | 3930 | console.log('Reconnect succeeded to ' + url); 3931 | instance.serverAddress(url); 3932 | resolve(); 3933 | } 3934 | }, 3935 | () => { 3936 | if (!state.resolved) { 3937 | console.log('Reconnect failed to ' + url); 3938 | 3939 | state.rejects++; 3940 | if (state.rejects >= state.numAddresses) { 3941 | reject(); 3942 | } 3943 | } 3944 | } 3945 | ); 3946 | } 3947 | 3948 | function tryReconnectInternal(instance) { 3949 | const addresses = []; 3950 | const addressesStrings = []; 3951 | 3952 | const serverInfo = instance.serverInfo(); 3953 | if (serverInfo.LocalAddress && addressesStrings.indexOf(serverInfo.LocalAddress) === -1) { 3954 | addresses.push({ url: serverInfo.LocalAddress, timeout: 0 }); 3955 | addressesStrings.push(addresses[addresses.length - 1].url); 3956 | } 3957 | if (serverInfo.ManualAddress && addressesStrings.indexOf(serverInfo.ManualAddress) === -1) { 3958 | addresses.push({ url: serverInfo.ManualAddress, timeout: 100 }); 3959 | addressesStrings.push(addresses[addresses.length - 1].url); 3960 | } 3961 | if (serverInfo.RemoteAddress && addressesStrings.indexOf(serverInfo.RemoteAddress) === -1) { 3962 | addresses.push({ url: serverInfo.RemoteAddress, timeout: 200 }); 3963 | addressesStrings.push(addresses[addresses.length - 1].url); 3964 | } 3965 | 3966 | console.log('tryReconnect: ' + addressesStrings.join('|')); 3967 | 3968 | return new Promise((resolve, reject) => { 3969 | const state = {}; 3970 | state.numAddresses = addresses.length; 3971 | state.rejects = 0; 3972 | 3973 | addresses.map((url) => { 3974 | setTimeout(() => { 3975 | if (!state.resolved) { 3976 | getTryConnectPromise(instance, url.url, state, resolve, reject); 3977 | } 3978 | }, url.timeout); 3979 | }); 3980 | }); 3981 | } 3982 | 3983 | function tryReconnect(instance, retryCount) { 3984 | retryCount = retryCount || 0; 3985 | 3986 | if (retryCount >= 20) { 3987 | return Promise.reject(); 3988 | } 3989 | 3990 | return tryReconnectInternal(instance).catch((err) => { 3991 | console.log('error in tryReconnectInternal: ' + (err || '')); 3992 | 3993 | return new Promise((resolve, reject) => { 3994 | setTimeout(() => { 3995 | tryReconnect(instance, retryCount + 1).then(resolve, reject); 3996 | }, 500); 3997 | }); 3998 | }); 3999 | } 4000 | 4001 | function getCachedUser(instance, userId) { 4002 | const serverId = instance.serverId(); 4003 | if (!serverId) { 4004 | return null; 4005 | } 4006 | 4007 | const json = appStorage.getItem(`user-${userId}-${serverId}`); 4008 | 4009 | if (json) { 4010 | return JSON.parse(json); 4011 | } 4012 | 4013 | return null; 4014 | } 4015 | 4016 | function onWebSocketMessage(msg) { 4017 | const instance = this; 4018 | msg = JSON.parse(msg.data); 4019 | onMessageReceivedInternal(instance, msg); 4020 | } 4021 | 4022 | const messageIdsReceived = {}; 4023 | 4024 | function onMessageReceivedInternal(instance, msg) { 4025 | const messageId = msg.MessageId; 4026 | if (messageId) { 4027 | // message was already received via another protocol 4028 | if (messageIdsReceived[messageId]) { 4029 | return; 4030 | } 4031 | 4032 | messageIdsReceived[messageId] = true; 4033 | } 4034 | 4035 | if (msg.MessageType === 'UserDeleted') { 4036 | instance._currentUser = null; 4037 | } else if (msg.MessageType === 'UserUpdated' || msg.MessageType === 'UserConfigurationUpdated') { 4038 | const user = msg.Data; 4039 | if (user.Id === instance.getCurrentUserId()) { 4040 | instance._currentUser = null; 4041 | } 4042 | } else if (msg.MessageType === 'KeepAlive') { 4043 | console.debug('Received KeepAlive from server.'); 4044 | } else if (msg.MessageType === 'ForceKeepAlive') { 4045 | console.debug(`Received ForceKeepAlive from server. Timeout is ${msg.Data} seconds.`); 4046 | instance.sendWebSocketMessage('KeepAlive'); 4047 | scheduleKeepAlive(instance, msg.Data); 4048 | } 4049 | 4050 | events.trigger(instance, 'message', [msg]); 4051 | } 4052 | 4053 | /** 4054 | * Starts a poller that sends KeepAlive messages using a WebSocket connection. 4055 | * @param {Object} apiClient The ApiClient instance. 4056 | * @param {number} timeout The number of seconds after which the WebSocket is considered lost by the server. 4057 | * @returns {number} The id of the interval. 4058 | * @since 10.6.0 4059 | */ 4060 | function scheduleKeepAlive(apiClient, timeout) { 4061 | clearKeepAlive(apiClient); 4062 | apiClient.keepAliveInterval = setInterval(() => { 4063 | apiClient.sendWebSocketMessage('KeepAlive'); 4064 | }, timeout * 1000 * 0.5); 4065 | return apiClient.keepAliveInterval; 4066 | } 4067 | 4068 | /** 4069 | * Stops the poller that is sending KeepAlive messages on a WebSocket connection. 4070 | * @param {Object} apiClient The ApiClient instance. 4071 | * @since 10.6.0 4072 | */ 4073 | function clearKeepAlive(apiClient) { 4074 | console.debug('Clearing KeepAlive for', apiClient._webSocket); 4075 | if (apiClient.keepAliveInterval) { 4076 | clearInterval(apiClient.keepAliveInterval); 4077 | apiClient.keepAliveInterval = null; 4078 | } 4079 | } 4080 | 4081 | function onWebSocketOpen() { 4082 | const instance = this; 4083 | console.log('web socket connection opened'); 4084 | events.trigger(instance, 'websocketopen'); 4085 | } 4086 | 4087 | function onWebSocketError() { 4088 | const instance = this; 4089 | clearKeepAlive(instance); 4090 | events.trigger(instance, 'websocketerror'); 4091 | } 4092 | 4093 | function setSocketOnClose(apiClient, socket) { 4094 | socket.onclose = () => { 4095 | console.log('web socket closed'); 4096 | 4097 | clearKeepAlive(apiClient); 4098 | if (apiClient._webSocket === socket) { 4099 | console.log('nulling out web socket'); 4100 | apiClient._webSocket = null; 4101 | } 4102 | 4103 | setTimeout(() => { 4104 | events.trigger(apiClient, 'websocketclose'); 4105 | }, 0); 4106 | }; 4107 | } 4108 | 4109 | function normalizeReturnBitrate(instance, bitrate) { 4110 | if (!bitrate) { 4111 | if (instance.lastDetectedBitrate) { 4112 | return instance.lastDetectedBitrate; 4113 | } 4114 | 4115 | return Promise.reject(); 4116 | } 4117 | 4118 | let result = Math.min(Math.round(bitrate * 0.7), MAX_BITRATE); 4119 | 4120 | // allow configuration of this 4121 | if (instance.getMaxBandwidth) { 4122 | const maxRate = instance.getMaxBandwidth(); 4123 | if (maxRate) { 4124 | result = Math.min(result, maxRate); 4125 | } 4126 | } 4127 | 4128 | instance.lastDetectedBitrate = result; 4129 | instance.lastDetectedBitrateTime = new Date().getTime(); 4130 | 4131 | return result; 4132 | } 4133 | 4134 | function detectBitrateInternal(instance, tests, index, currentBitrate) { 4135 | if (index >= tests.length) { 4136 | return normalizeReturnBitrate(instance, currentBitrate); 4137 | } 4138 | 4139 | const test = tests[index]; 4140 | 4141 | return instance.getDownloadSpeed(test.bytes).then( 4142 | (bitrate) => { 4143 | if (bitrate < test.threshold) { 4144 | return normalizeReturnBitrate(instance, bitrate); 4145 | } else { 4146 | return detectBitrateInternal(instance, tests, index + 1, bitrate); 4147 | } 4148 | }, 4149 | () => normalizeReturnBitrate(instance, currentBitrate) 4150 | ); 4151 | } 4152 | 4153 | function detectBitrateWithEndpointInfo(instance, endpointInfo) { 4154 | return detectBitrateInternal( 4155 | instance, 4156 | [ 4157 | { 4158 | bytes: 500000, 4159 | threshold: 500000 4160 | }, 4161 | { 4162 | bytes: 1000000, 4163 | threshold: 20000000 4164 | }, 4165 | { 4166 | bytes: 3000000, 4167 | threshold: 50000000 4168 | } 4169 | ], 4170 | 0 4171 | ).then((result) => { 4172 | if (endpointInfo.IsInNetwork) { 4173 | result = Math.max(result || 0, LAN_BITRATE); 4174 | 4175 | instance.lastDetectedBitrate = result; 4176 | instance.lastDetectedBitrateTime = new Date().getTime(); 4177 | } 4178 | return result; 4179 | }); 4180 | } 4181 | 4182 | function getRemoteImagePrefix(instance, options) { 4183 | let urlPrefix; 4184 | 4185 | if (options.artist) { 4186 | urlPrefix = `Artists/${instance.encodeName(options.artist)}`; 4187 | delete options.artist; 4188 | } else if (options.person) { 4189 | urlPrefix = `Persons/${instance.encodeName(options.person)}`; 4190 | delete options.person; 4191 | } else if (options.genre) { 4192 | urlPrefix = `Genres/${instance.encodeName(options.genre)}`; 4193 | delete options.genre; 4194 | } else if (options.musicGenre) { 4195 | urlPrefix = `MusicGenres/${instance.encodeName(options.musicGenre)}`; 4196 | delete options.musicGenre; 4197 | } else if (options.studio) { 4198 | urlPrefix = `Studios/${instance.encodeName(options.studio)}`; 4199 | delete options.studio; 4200 | } else { 4201 | urlPrefix = `Items/${options.itemId}`; 4202 | delete options.itemId; 4203 | } 4204 | 4205 | return urlPrefix; 4206 | } 4207 | 4208 | function normalizeImageOptions(instance, options) { 4209 | let ratio = window && window.devicePixelRatio || 1; 4210 | 4211 | if (ratio) { 4212 | if (options.minScale) { 4213 | ratio = Math.max(options.minScale, ratio); 4214 | } 4215 | 4216 | if (options.width) { 4217 | options.width = Math.round(options.width * ratio); 4218 | } 4219 | if (options.height) { 4220 | options.height = Math.round(options.height * ratio); 4221 | } 4222 | if (options.maxWidth) { 4223 | options.maxWidth = Math.round(options.maxWidth * ratio); 4224 | } 4225 | if (options.maxHeight) { 4226 | options.maxHeight = Math.round(options.maxHeight * ratio); 4227 | } 4228 | if (options.fillWidth) { 4229 | options.fillWidth = Math.round(options.fillWidth * ratio); 4230 | } 4231 | if (options.fillHeight) { 4232 | options.fillHeight = Math.round(options.fillHeight * ratio); 4233 | } 4234 | } 4235 | 4236 | options.quality = options.quality || instance.getDefaultImageQuality(options.type); 4237 | 4238 | if (instance.normalizeImageOptions) { 4239 | instance.normalizeImageOptions(options); 4240 | } 4241 | } 4242 | 4243 | function compareVersions(a, b) { 4244 | // -1 a is smaller 4245 | // 1 a is larger 4246 | // 0 equal 4247 | a = a.split('.'); 4248 | b = b.split('.'); 4249 | 4250 | for (let i = 0, length = Math.max(a.length, b.length); i < length; i++) { 4251 | const aVal = parseInt(a[i] || '0'); 4252 | const bVal = parseInt(b[i] || '0'); 4253 | 4254 | if (aVal < bVal) { 4255 | return -1; 4256 | } 4257 | 4258 | if (aVal > bVal) { 4259 | return 1; 4260 | } 4261 | } 4262 | 4263 | return 0; 4264 | } 4265 | 4266 | export default ApiClient; 4267 | -------------------------------------------------------------------------------- /src/apiClientCore.js: -------------------------------------------------------------------------------- 1 | import ApiClient from './apiClient'; 2 | 3 | const localPrefix = 'local:'; 4 | const localViewPrefix = 'localview:'; 5 | 6 | function isLocalId(str) { 7 | return startsWith(str, localPrefix); 8 | } 9 | 10 | function isLocalViewId(str) { 11 | return startsWith(str, localViewPrefix); 12 | } 13 | 14 | function isTopLevelLocalViewId(str) { 15 | return str === 'localview'; 16 | } 17 | 18 | function stripLocalPrefix(str) { 19 | let res = stripStart(str, localPrefix); 20 | res = stripStart(res, localViewPrefix); 21 | 22 | return res; 23 | } 24 | 25 | function startsWith(str, find) { 26 | if (str && find && str.length > find.length) { 27 | if (str.indexOf(find) === 0) { 28 | return true; 29 | } 30 | } 31 | 32 | return false; 33 | } 34 | 35 | function stripStart(str, find) { 36 | if (startsWith(str, find)) { 37 | return str.slice(find.length); 38 | } 39 | 40 | return str; 41 | } 42 | 43 | function createEmptyList() { 44 | const result = { 45 | Items: [], 46 | TotalRecordCount: 0 47 | }; 48 | 49 | return result; 50 | } 51 | 52 | function convertGuidToLocal(guid) { 53 | if (!guid) { 54 | return null; 55 | } 56 | 57 | if (isLocalId(guid)) { 58 | return guid; 59 | } 60 | 61 | return `local:${guid}`; 62 | } 63 | 64 | function adjustGuidProperties(downloadedItem) { 65 | downloadedItem.Id = convertGuidToLocal(downloadedItem.Id); 66 | downloadedItem.SeriesId = convertGuidToLocal(downloadedItem.SeriesId); 67 | downloadedItem.SeasonId = convertGuidToLocal(downloadedItem.SeasonId); 68 | 69 | downloadedItem.AlbumId = convertGuidToLocal(downloadedItem.AlbumId); 70 | downloadedItem.ParentId = convertGuidToLocal(downloadedItem.ParentId); 71 | downloadedItem.ParentThumbItemId = convertGuidToLocal(downloadedItem.ParentThumbItemId); 72 | downloadedItem.ParentPrimaryImageItemId = convertGuidToLocal(downloadedItem.ParentPrimaryImageItemId); 73 | downloadedItem.PrimaryImageItemId = convertGuidToLocal(downloadedItem.PrimaryImageItemId); 74 | downloadedItem.ParentLogoItemId = convertGuidToLocal(downloadedItem.ParentLogoItemId); 75 | downloadedItem.ParentBackdropItemId = convertGuidToLocal(downloadedItem.ParentBackdropItemId); 76 | 77 | downloadedItem.ParentBackdropImageTags = null; 78 | } 79 | 80 | function getLocalView(instance, serverId, userId) { 81 | return instance.getLocalFolders(serverId, userId).then((views) => { 82 | let localView = null; 83 | 84 | if (views.length > 0) { 85 | localView = { 86 | Name: instance.downloadsTitleText || 'Downloads', 87 | ServerId: serverId, 88 | Id: 'localview', 89 | Type: 'localview', 90 | IsFolder: true 91 | }; 92 | } 93 | 94 | return Promise.resolve(localView); 95 | }); 96 | } 97 | 98 | /** 99 | * Creates a new api client instance 100 | * @param {String} serverAddress 101 | * @param {String} clientName s 102 | * @param {String} applicationVersion 103 | */ 104 | class ApiClientCore extends ApiClient { 105 | constructor( 106 | serverAddress, 107 | clientName, 108 | applicationVersion, 109 | deviceName, 110 | deviceId, 111 | devicePixelRatio, 112 | localAssetManager 113 | ) { 114 | super(serverAddress, clientName, applicationVersion, deviceName, deviceId, devicePixelRatio); 115 | this.localAssetManager = localAssetManager; 116 | } 117 | 118 | getPlaybackInfo(itemId, options, deviceProfile) { 119 | const onFailure = () => ApiClient.prototype.getPlaybackInfo.call(instance, itemId, options, deviceProfile); 120 | 121 | if (isLocalId(itemId)) { 122 | return this.localAssetManager.getLocalItem(this.serverId(), stripLocalPrefix(itemId)).then((item) => { 123 | // TODO: This was already done during the sync process, right? If so, remove it 124 | const mediaSources = item.Item.MediaSources.map((m) => { 125 | m.SupportsDirectPlay = true; 126 | m.SupportsDirectStream = false; 127 | m.SupportsTranscoding = false; 128 | m.IsLocal = true; 129 | return m; 130 | }); 131 | 132 | return { 133 | MediaSources: mediaSources 134 | }; 135 | }, onFailure); 136 | } 137 | 138 | var instance = this; 139 | return this.localAssetManager.getLocalItem(this.serverId(), itemId).then((item) => { 140 | if (item) { 141 | const mediaSources = item.Item.MediaSources.map((m) => { 142 | m.SupportsDirectPlay = true; 143 | m.SupportsDirectStream = false; 144 | m.SupportsTranscoding = false; 145 | m.IsLocal = true; 146 | return m; 147 | }); 148 | 149 | return instance.localAssetManager.fileExists(item.LocalPath).then((exists) => { 150 | if (exists) { 151 | const res = { 152 | MediaSources: mediaSources 153 | }; 154 | 155 | return Promise.resolve(res); 156 | } 157 | 158 | return ApiClient.prototype.getPlaybackInfo.call(instance, itemId, options, deviceProfile); 159 | }, onFailure); 160 | } 161 | 162 | return ApiClient.prototype.getPlaybackInfo.call(instance, itemId, options, deviceProfile); 163 | }, onFailure); 164 | } 165 | 166 | getItems(userId, options) { 167 | const serverInfo = this.serverInfo(); 168 | let i; 169 | 170 | if (serverInfo && options.ParentId === 'localview') { 171 | return this.getLocalFolders(serverInfo.Id, userId).then((items) => { 172 | const result = { 173 | Items: items, 174 | TotalRecordCount: items.length 175 | }; 176 | 177 | return Promise.resolve(result); 178 | }); 179 | } else if ( 180 | serverInfo && 181 | options && 182 | (isLocalId(options.ParentId) || 183 | isLocalId(options.SeriesId) || 184 | isLocalId(options.SeasonId) || 185 | isLocalViewId(options.ParentId) || 186 | isLocalId(options.AlbumIds)) 187 | ) { 188 | return this.localAssetManager.getViewItems(serverInfo.Id, userId, options).then((items) => { 189 | items.forEach((item) => { 190 | adjustGuidProperties(item); 191 | }); 192 | 193 | const result = { 194 | Items: items, 195 | TotalRecordCount: items.length 196 | }; 197 | 198 | return Promise.resolve(result); 199 | }); 200 | } else if (options && options.ExcludeItemIds && options.ExcludeItemIds.length) { 201 | const exItems = options.ExcludeItemIds.split(','); 202 | 203 | for (i = 0; i < exItems.length; i++) { 204 | if (isLocalId(exItems[i])) { 205 | return Promise.resolve(createEmptyList()); 206 | } 207 | } 208 | } else if (options && options.Ids && options.Ids.length) { 209 | const ids = options.Ids.split(','); 210 | let hasLocal = false; 211 | 212 | for (i = 0; i < ids.length; i++) { 213 | if (isLocalId(ids[i])) { 214 | hasLocal = true; 215 | } 216 | } 217 | 218 | if (hasLocal) { 219 | return this.localAssetManager.getItemsFromIds(serverInfo.Id, ids).then((items) => { 220 | items.forEach((item) => { 221 | adjustGuidProperties(item); 222 | }); 223 | 224 | const result = { 225 | Items: items, 226 | TotalRecordCount: items.length 227 | }; 228 | 229 | return Promise.resolve(result); 230 | }); 231 | } 232 | } 233 | 234 | return ApiClient.prototype.getItems.call(this, userId, options); 235 | } 236 | 237 | getUserViews(options, userId) { 238 | const instance = this; 239 | 240 | options = options || {}; 241 | 242 | const basePromise = ApiClient.prototype.getUserViews.call(instance, options, userId); 243 | 244 | if (!options.enableLocalView) { 245 | return basePromise; 246 | } 247 | 248 | return basePromise.then((result) => { 249 | const serverInfo = instance.serverInfo(); 250 | if (serverInfo) { 251 | return getLocalView(instance, serverInfo.Id, userId).then((localView) => { 252 | if (localView) { 253 | result.Items.push(localView); 254 | result.TotalRecordCount++; 255 | } 256 | 257 | return Promise.resolve(result); 258 | }); 259 | } 260 | 261 | return Promise.resolve(result); 262 | }); 263 | } 264 | 265 | getItem(userId, itemId) { 266 | if (!itemId) { 267 | throw new Error('null itemId'); 268 | } 269 | 270 | if (itemId) { 271 | itemId = itemId.toString(); 272 | } 273 | 274 | let serverInfo; 275 | 276 | if (isTopLevelLocalViewId(itemId)) { 277 | serverInfo = this.serverInfo(); 278 | 279 | if (serverInfo) { 280 | return getLocalView(this, serverInfo.Id, userId); 281 | } 282 | } 283 | 284 | if (isLocalViewId(itemId)) { 285 | serverInfo = this.serverInfo(); 286 | 287 | if (serverInfo) { 288 | return this.getLocalFolders(serverInfo.Id, userId).then((items) => { 289 | const views = items.filter((item) => item.Id === itemId); 290 | 291 | if (views.length > 0) { 292 | return Promise.resolve(views[0]); 293 | } 294 | 295 | // TODO: Test consequence of this 296 | return Promise.reject(); 297 | }); 298 | } 299 | } 300 | 301 | if (isLocalId(itemId)) { 302 | serverInfo = this.serverInfo(); 303 | 304 | if (serverInfo) { 305 | return this.localAssetManager.getLocalItem(serverInfo.Id, stripLocalPrefix(itemId)).then((item) => { 306 | adjustGuidProperties(item.Item); 307 | 308 | return Promise.resolve(item.Item); 309 | }); 310 | } 311 | } 312 | 313 | return ApiClient.prototype.getItem.call(this, userId, itemId); 314 | } 315 | 316 | getLocalFolders(userId) { 317 | const serverInfo = this.serverInfo(); 318 | userId = userId || serverInfo.UserId; 319 | 320 | return this.localAssetManager.getViews(serverInfo.Id, userId); 321 | } 322 | 323 | getNextUpEpisodes(options) { 324 | if (options.SeriesId) { 325 | if (isLocalId(options.SeriesId)) { 326 | return Promise.resolve(createEmptyList()); 327 | } 328 | } 329 | 330 | return ApiClient.prototype.getNextUpEpisodes.call(this, options); 331 | } 332 | 333 | getSeasons(itemId, options) { 334 | if (isLocalId(itemId)) { 335 | options.SeriesId = itemId; 336 | options.IncludeItemTypes = 'Season'; 337 | return this.getItems(this.getCurrentUserId(), options); 338 | } 339 | 340 | return ApiClient.prototype.getSeasons.call(this, itemId, options); 341 | } 342 | 343 | getEpisodes(itemId, options) { 344 | if (isLocalId(options.SeasonId) || isLocalId(options.seasonId)) { 345 | options.SeriesId = itemId; 346 | options.IncludeItemTypes = 'Episode'; 347 | return this.getItems(this.getCurrentUserId(), options); 348 | } 349 | 350 | // get episodes by recursion 351 | if (isLocalId(itemId)) { 352 | options.SeriesId = itemId; 353 | options.IncludeItemTypes = 'Episode'; 354 | return this.getItems(this.getCurrentUserId(), options); 355 | } 356 | 357 | return ApiClient.prototype.getEpisodes.call(this, itemId, options); 358 | } 359 | 360 | getLatestOfflineItems(options) { 361 | // Supported options 362 | // MediaType - Audio/Video/Photo/Book/Game 363 | // Limit 364 | // Filters: 'IsNotFolder' or 'IsFolder' 365 | 366 | options.SortBy = 'DateCreated'; 367 | options.SortOrder = 'Descending'; 368 | 369 | const serverInfo = this.serverInfo(); 370 | 371 | if (serverInfo) { 372 | return this.localAssetManager.getViewItems(serverInfo.Id, null, options).then((items) => { 373 | items.forEach((item) => { 374 | adjustGuidProperties(item); 375 | }); 376 | 377 | return Promise.resolve(items); 378 | }); 379 | } 380 | 381 | return Promise.resolve([]); 382 | } 383 | 384 | getThemeMedia(userId, itemId, inherit) { 385 | if (isLocalViewId(itemId) || isLocalId(itemId) || isTopLevelLocalViewId(itemId)) { 386 | return Promise.reject(); 387 | } 388 | 389 | return ApiClient.prototype.getThemeMedia.call(this, userId, itemId, inherit); 390 | } 391 | 392 | getSpecialFeatures(userId, itemId) { 393 | if (isLocalId(itemId)) { 394 | return Promise.resolve([]); 395 | } 396 | 397 | return ApiClient.prototype.getSpecialFeatures.call(this, userId, itemId); 398 | } 399 | 400 | getSimilarItems(itemId, options) { 401 | if (isLocalId(itemId)) { 402 | return Promise.resolve(createEmptyList()); 403 | } 404 | 405 | return ApiClient.prototype.getSimilarItems.call(this, itemId, options); 406 | } 407 | 408 | updateFavoriteStatus(userId, itemId, isFavorite) { 409 | if (isLocalId(itemId)) { 410 | return Promise.resolve(); 411 | } 412 | 413 | return ApiClient.prototype.updateFavoriteStatus.call(this, userId, itemId, isFavorite); 414 | } 415 | 416 | getScaledImageUrl(itemId, options) { 417 | if (isLocalId(itemId) || (options && options.itemid && isLocalId(options.itemid))) { 418 | const serverInfo = this.serverInfo(); 419 | const id = stripLocalPrefix(itemId); 420 | 421 | return this.localAssetManager.getImageUrl(serverInfo.Id, id, options); 422 | } 423 | 424 | return ApiClient.prototype.getScaledImageUrl.call(this, itemId, options); 425 | } 426 | 427 | reportPlaybackStart(options) { 428 | if (!options) { 429 | throw new Error('null options'); 430 | } 431 | 432 | if (isLocalId(options.ItemId)) { 433 | return Promise.resolve(); 434 | } 435 | 436 | return ApiClient.prototype.reportPlaybackStart.call(this, options); 437 | } 438 | 439 | reportPlaybackProgress(options) { 440 | if (!options) { 441 | throw new Error('null options'); 442 | } 443 | 444 | if (isLocalId(options.ItemId)) { 445 | const serverInfo = this.serverInfo(); 446 | 447 | if (serverInfo) { 448 | const instance = this; 449 | return this.localAssetManager 450 | .getLocalItem(serverInfo.Id, stripLocalPrefix(options.ItemId)) 451 | .then((item) => { 452 | const libraryItem = item.Item; 453 | 454 | if (libraryItem.MediaType === 'Video' || libraryItem.Type === 'AudioBook') { 455 | libraryItem.UserData = libraryItem.UserData || {}; 456 | libraryItem.UserData.PlaybackPositionTicks = options.PositionTicks; 457 | libraryItem.UserData.PlayedPercentage = Math.min( 458 | libraryItem.RunTimeTicks 459 | ? 100 * ((options.PositionTicks || 0) / libraryItem.RunTimeTicks) 460 | : 0, 461 | 100 462 | ); 463 | return instance.localAssetManager.addOrUpdateLocalItem(item); 464 | } 465 | 466 | return Promise.resolve(); 467 | }); 468 | } 469 | 470 | return Promise.resolve(); 471 | } 472 | 473 | return ApiClient.prototype.reportPlaybackProgress.call(this, options); 474 | } 475 | 476 | reportPlaybackStopped(options) { 477 | if (!options) { 478 | throw new Error('null options'); 479 | } 480 | 481 | if (isLocalId(options.ItemId)) { 482 | const serverInfo = this.serverInfo(); 483 | 484 | const action = { 485 | Date: new Date().getTime(), 486 | ItemId: stripLocalPrefix(options.ItemId), 487 | PositionTicks: options.PositionTicks, 488 | ServerId: serverInfo.Id, 489 | Type: 0, // UserActionType.PlayedItem 490 | UserId: this.getCurrentUserId() 491 | }; 492 | 493 | return this.localAssetManager.recordUserAction(action); 494 | } 495 | 496 | return ApiClient.prototype.reportPlaybackStopped.call(this, options); 497 | } 498 | 499 | getIntros(itemId) { 500 | if (isLocalId(itemId)) { 501 | return Promise.resolve({ 502 | Items: [], 503 | TotalRecordCount: 0 504 | }); 505 | } 506 | 507 | return ApiClient.prototype.getIntros.call(this, itemId); 508 | } 509 | 510 | getInstantMixFromItem(itemId, options) { 511 | if (isLocalId(itemId)) { 512 | return Promise.resolve({ 513 | Items: [], 514 | TotalRecordCount: 0 515 | }); 516 | } 517 | 518 | return ApiClient.prototype.getInstantMixFromItem.call(this, itemId, options); 519 | } 520 | 521 | getItemDownloadUrl(itemId) { 522 | if (isLocalId(itemId)) { 523 | const serverInfo = this.serverInfo(); 524 | 525 | if (serverInfo) { 526 | return this.localAssetManager 527 | .getLocalItem(serverInfo.Id, stripLocalPrefix(itemId)) 528 | .then((item) => Promise.resolve(item.LocalPath)); 529 | } 530 | } 531 | 532 | return ApiClient.prototype.getItemDownloadUrl.call(this, itemId); 533 | } 534 | } 535 | 536 | export default ApiClientCore; 537 | -------------------------------------------------------------------------------- /src/appStorage.js: -------------------------------------------------------------------------------- 1 | function onCachePutFail(e) { 2 | console.log(e); 3 | } 4 | 5 | function updateCache(instance) { 6 | const cache = instance.cache; 7 | if (cache) { 8 | cache.put('data', new Response(JSON.stringify(instance.localData))).catch(onCachePutFail); 9 | } 10 | } 11 | 12 | function onCacheOpened(result) { 13 | this.cache = result; 14 | this.localData = {}; 15 | } 16 | 17 | class AppStore { 18 | constructor() { 19 | try { 20 | if (self && self.caches) { 21 | caches.open('embydata').then(onCacheOpened.bind(this)); 22 | } 23 | } catch (err) { 24 | console.log(`Error opening cache: ${err}`); 25 | } 26 | } 27 | 28 | setItem(name, value) { 29 | localStorage.setItem(name, value); 30 | const localData = this.localData; 31 | if (localData) { 32 | const changed = localData[name] !== value; 33 | if (changed) { 34 | localData[name] = value; 35 | updateCache(this); 36 | } 37 | } 38 | } 39 | 40 | static getInstance() { 41 | if (!AppStore.instance) { 42 | AppStore.instance = new AppStore(); 43 | } 44 | 45 | return AppStore.instance; 46 | } 47 | 48 | getItem(name) { 49 | return localStorage.getItem(name); 50 | } 51 | 52 | removeItem(name) { 53 | localStorage.removeItem(name); 54 | const localData = this.localData; 55 | if (localData) { 56 | localData[name] = null; 57 | delete localData[name]; 58 | updateCache(this); 59 | } 60 | } 61 | } 62 | 63 | export default AppStore.getInstance(); 64 | -------------------------------------------------------------------------------- /src/connectionManager.js: -------------------------------------------------------------------------------- 1 | import events from './events'; 2 | import ApiClient from './apiClient'; 3 | 4 | const defaultTimeout = 20000; 5 | 6 | const ConnectionMode = { 7 | Local: 0, 8 | Remote: 1, 9 | Manual: 2 10 | }; 11 | 12 | function getServerAddress(server, mode) { 13 | switch (mode) { 14 | case ConnectionMode.Local: 15 | return server.LocalAddress; 16 | case ConnectionMode.Manual: 17 | return server.ManualAddress; 18 | case ConnectionMode.Remote: 19 | return server.RemoteAddress; 20 | default: 21 | return server.ManualAddress || server.LocalAddress || server.RemoteAddress; 22 | } 23 | } 24 | 25 | function paramsToString(params) { 26 | const values = []; 27 | 28 | for (const key in params) { 29 | const value = params[key]; 30 | 31 | if (value !== null && value !== undefined && value !== '') { 32 | values.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`); 33 | } 34 | } 35 | return values.join('&'); 36 | } 37 | 38 | function resolveFailure(instance, resolve) { 39 | resolve({ 40 | State: 'Unavailable' 41 | }); 42 | } 43 | 44 | function mergeServers(credentialProvider, list1, list2) { 45 | for (let i = 0, length = list2.length; i < length; i++) { 46 | credentialProvider.addOrUpdateServer(list1, list2[i]); 47 | } 48 | 49 | return list1; 50 | } 51 | 52 | function updateServerInfo(server, systemInfo) { 53 | server.Name = systemInfo.ServerName; 54 | 55 | if (systemInfo.Id) { 56 | server.Id = systemInfo.Id; 57 | } 58 | if (systemInfo.LocalAddress) { 59 | server.LocalAddress = systemInfo.LocalAddress; 60 | } 61 | } 62 | 63 | function getEmbyServerUrl(baseUrl, handler) { 64 | return `${baseUrl}/${handler}`; 65 | } 66 | 67 | function getFetchPromise(request) { 68 | const headers = request.headers || {}; 69 | 70 | if (request.dataType === 'json') { 71 | headers.accept = 'application/json'; 72 | } 73 | 74 | const fetchRequest = { 75 | headers, 76 | method: request.type, 77 | credentials: 'same-origin' 78 | }; 79 | 80 | let contentType = request.contentType; 81 | 82 | if (request.data) { 83 | if (typeof request.data === 'string') { 84 | fetchRequest.body = request.data; 85 | } else { 86 | fetchRequest.body = paramsToString(request.data); 87 | 88 | contentType = contentType || 'application/x-www-form-urlencoded; charset=UTF-8'; 89 | } 90 | } 91 | 92 | if (contentType) { 93 | headers['Content-Type'] = contentType; 94 | } 95 | 96 | if (!request.timeout) { 97 | return fetch(request.url, fetchRequest); 98 | } 99 | 100 | return fetchWithTimeout(request.url, fetchRequest, request.timeout); 101 | } 102 | 103 | function fetchWithTimeout(url, options, timeoutMs) { 104 | console.log(`fetchWithTimeout: timeoutMs: ${timeoutMs}, url: ${url}`); 105 | 106 | return new Promise((resolve, reject) => { 107 | const timeout = setTimeout(reject, timeoutMs); 108 | 109 | options = options || {}; 110 | options.credentials = 'same-origin'; 111 | 112 | fetch(url, options).then( 113 | (response) => { 114 | clearTimeout(timeout); 115 | 116 | console.log(`fetchWithTimeout: succeeded connecting to url: ${url}`); 117 | 118 | resolve(response); 119 | }, 120 | (error) => { 121 | clearTimeout(timeout); 122 | 123 | console.log(`fetchWithTimeout: timed out connecting to url: ${url}`); 124 | 125 | reject(); 126 | } 127 | ); 128 | }); 129 | } 130 | 131 | function ajax(request) { 132 | if (!request) { 133 | throw new Error('Request cannot be null'); 134 | } 135 | 136 | request.headers = request.headers || {}; 137 | 138 | console.log(`ConnectionManager requesting url: ${request.url}`); 139 | 140 | return getFetchPromise(request).then( 141 | (response) => { 142 | console.log(`ConnectionManager response status: ${response.status}, url: ${request.url}`); 143 | 144 | if (response.status < 400) { 145 | if (request.dataType === 'json' || request.headers.accept === 'application/json') { 146 | return response.json(); 147 | } else { 148 | return response; 149 | } 150 | } else { 151 | return Promise.reject(response); 152 | } 153 | }, 154 | (err) => { 155 | console.log(`ConnectionManager request failed to url: ${request.url}`); 156 | throw err; 157 | } 158 | ); 159 | } 160 | 161 | function replaceAll(originalString, strReplace, strWith) { 162 | const reg = new RegExp(strReplace, 'ig'); 163 | return originalString.replace(reg, strWith); 164 | } 165 | 166 | function normalizeAddress(address) { 167 | // Attempt to correct bad input 168 | address = address.trim(); 169 | 170 | // Seeing failures in iOS when protocol isn't lowercase 171 | address = replaceAll(address, 'Http:', 'http:'); 172 | address = replaceAll(address, 'Https:', 'https:'); 173 | 174 | return address; 175 | } 176 | 177 | function stringEqualsIgnoreCase(str1, str2) { 178 | return (str1 || '').toLowerCase() === (str2 || '').toLowerCase(); 179 | } 180 | 181 | function compareVersions(a, b) { 182 | // -1 a is smaller 183 | // 1 a is larger 184 | // 0 equal 185 | a = a.split('.'); 186 | b = b.split('.'); 187 | 188 | for (let i = 0, length = Math.max(a.length, b.length); i < length; i++) { 189 | const aVal = parseInt(a[i] || '0'); 190 | const bVal = parseInt(b[i] || '0'); 191 | 192 | if (aVal < bVal) { 193 | return -1; 194 | } 195 | 196 | if (aVal > bVal) { 197 | return 1; 198 | } 199 | } 200 | 201 | return 0; 202 | } 203 | 204 | export default class ConnectionManager { 205 | constructor(credentialProvider, appName, appVersion, deviceName, deviceId, capabilities) { 206 | console.log('Begin ConnectionManager constructor'); 207 | 208 | const self = this; 209 | this._apiClients = []; 210 | 211 | self._minServerVersion = '3.2.33'; 212 | 213 | self.appVersion = () => appVersion; 214 | 215 | self.appName = () => appName; 216 | 217 | self.capabilities = () => capabilities; 218 | 219 | self.deviceId = () => deviceId; 220 | 221 | self.credentialProvider = () => credentialProvider; 222 | 223 | self.getServerInfo = (id) => { 224 | const servers = credentialProvider.credentials().Servers; 225 | 226 | return servers.filter((s) => s.Id === id)[0]; 227 | }; 228 | 229 | self.getLastUsedServer = () => { 230 | const servers = credentialProvider.credentials().Servers; 231 | 232 | servers.sort((a, b) => (b.DateLastAccessed || 0) - (a.DateLastAccessed || 0)); 233 | 234 | if (!servers.length) { 235 | return null; 236 | } 237 | 238 | return servers[0]; 239 | }; 240 | 241 | self.addApiClient = (apiClient) => { 242 | self._apiClients.push(apiClient); 243 | 244 | const existingServers = credentialProvider 245 | .credentials() 246 | .Servers.filter( 247 | (s) => 248 | stringEqualsIgnoreCase(s.ManualAddress, apiClient.serverAddress()) || 249 | stringEqualsIgnoreCase(s.LocalAddress, apiClient.serverAddress()) || 250 | stringEqualsIgnoreCase(s.RemoteAddress, apiClient.serverAddress()) 251 | ); 252 | 253 | const existingServer = existingServers.length ? existingServers[0] : apiClient.serverInfo(); 254 | existingServer.DateLastAccessed = new Date().getTime(); 255 | existingServer.LastConnectionMode = ConnectionMode.Manual; 256 | existingServer.ManualAddress = apiClient.serverAddress(); 257 | 258 | if (apiClient.manualAddressOnly) { 259 | existingServer.manualAddressOnly = true; 260 | } 261 | 262 | apiClient.serverInfo(existingServer); 263 | 264 | apiClient.onAuthenticated = (instance, result) => onAuthenticated(instance, result, {}, true); 265 | 266 | if (!existingServers.length) { 267 | const credentials = credentialProvider.credentials(); 268 | credentials.Servers = [existingServer]; 269 | credentialProvider.credentials(credentials); 270 | } 271 | 272 | events.trigger(self, 'apiclientcreated', [apiClient]); 273 | }; 274 | 275 | self.clearData = () => { 276 | console.log('connection manager clearing data'); 277 | 278 | const credentials = credentialProvider.credentials(); 279 | credentials.Servers = []; 280 | credentialProvider.credentials(credentials); 281 | }; 282 | 283 | self._getOrAddApiClient = (server, serverUrl) => { 284 | let apiClient = self.getApiClient(server.Id); 285 | 286 | if (!apiClient) { 287 | apiClient = new ApiClient(serverUrl, appName, appVersion, deviceName, deviceId); 288 | 289 | self._apiClients.push(apiClient); 290 | 291 | apiClient.serverInfo(server); 292 | 293 | apiClient.onAuthenticated = (instance, result) => { 294 | return onAuthenticated(instance, result, {}, true); 295 | }; 296 | 297 | events.trigger(self, 'apiclientcreated', [apiClient]); 298 | } 299 | 300 | console.log('returning instance from getOrAddApiClient'); 301 | return apiClient; 302 | }; 303 | 304 | self.getOrCreateApiClient = (serverId) => { 305 | const credentials = credentialProvider.credentials(); 306 | const servers = credentials.Servers.filter((s) => stringEqualsIgnoreCase(s.Id, serverId)); 307 | 308 | if (!servers.length) { 309 | throw new Error(`Server not found: ${serverId}`); 310 | } 311 | 312 | const server = servers[0]; 313 | 314 | return self._getOrAddApiClient(server, getServerAddress(server, server.LastConnectionMode)); 315 | }; 316 | 317 | function onAuthenticated(apiClient, result, options, saveCredentials) { 318 | const credentials = credentialProvider.credentials(); 319 | const servers = credentials.Servers.filter((s) => s.Id === result.ServerId); 320 | 321 | const server = servers.length ? servers[0] : apiClient.serverInfo(); 322 | 323 | if (options.updateDateLastAccessed !== false) { 324 | server.DateLastAccessed = new Date().getTime(); 325 | } 326 | server.Id = result.ServerId; 327 | 328 | if (saveCredentials) { 329 | server.UserId = result.User.Id; 330 | server.AccessToken = result.AccessToken; 331 | } else { 332 | server.UserId = null; 333 | server.AccessToken = null; 334 | } 335 | 336 | credentialProvider.addOrUpdateServer(credentials.Servers, server); 337 | credentialProvider.credentials(credentials); 338 | 339 | // set this now before updating server info, otherwise it won't be set in time 340 | apiClient.enableAutomaticBitrateDetection = options.enableAutomaticBitrateDetection; 341 | 342 | apiClient.serverInfo(server); 343 | apiClient.setAuthenticationInfo(result.AccessToken, result.User.Id); 344 | afterConnected(apiClient, options); 345 | 346 | return onLocalUserSignIn(server, apiClient.serverAddress(), result.User); 347 | } 348 | 349 | function afterConnected(apiClient, options = {}) { 350 | if (options.reportCapabilities !== false) { 351 | apiClient.reportCapabilities(capabilities); 352 | } 353 | apiClient.enableAutomaticBitrateDetection = options.enableAutomaticBitrateDetection; 354 | 355 | if (options.enableWebSocket !== false) { 356 | console.log('calling apiClient.ensureWebSocket'); 357 | 358 | apiClient.ensureWebSocket(); 359 | } 360 | } 361 | 362 | function onLocalUserSignIn(server, serverUrl, user) { 363 | // Ensure this is created so that listeners of the event can get the apiClient instance 364 | self._getOrAddApiClient(server, serverUrl); 365 | 366 | // This allows the app to have a single hook that fires before any other 367 | const promise = self.onLocalUserSignedIn ? self.onLocalUserSignedIn.call(self, user) : Promise.resolve(); 368 | 369 | return promise.then(() => { 370 | events.trigger(self, 'localusersignedin', [user]); 371 | }); 372 | } 373 | 374 | function validateAuthentication(server, serverUrl) { 375 | return ajax({ 376 | type: 'GET', 377 | url: getEmbyServerUrl(serverUrl, 'System/Info'), 378 | dataType: 'json', 379 | headers: { 380 | 'X-MediaBrowser-Token': server.AccessToken 381 | } 382 | }).then( 383 | (systemInfo) => { 384 | updateServerInfo(server, systemInfo); 385 | return Promise.resolve(); 386 | }, 387 | () => { 388 | server.UserId = null; 389 | server.AccessToken = null; 390 | return Promise.resolve(); 391 | } 392 | ); 393 | } 394 | 395 | function getImageUrl(localUser) { 396 | if (localUser && localUser.PrimaryImageTag) { 397 | const apiClient = self.getApiClient(localUser); 398 | 399 | const url = apiClient.getUserImageUrl(localUser.Id, { 400 | tag: localUser.PrimaryImageTag, 401 | type: 'Primary' 402 | }); 403 | 404 | return { 405 | url, 406 | supportsParams: true 407 | }; 408 | } 409 | 410 | return { 411 | url: null, 412 | supportsParams: false 413 | }; 414 | } 415 | 416 | self.user = (apiClient) => 417 | new Promise((resolve, reject) => { 418 | let localUser; 419 | 420 | function onLocalUserDone(e) { 421 | if (apiClient && apiClient.getCurrentUserId()) { 422 | apiClient.getCurrentUser().then((u) => { 423 | localUser = u; 424 | const image = getImageUrl(localUser); 425 | 426 | resolve({ 427 | localUser, 428 | name: localUser ? localUser.Name : null, 429 | imageUrl: image.url, 430 | supportsImageParams: image.supportsParams 431 | }); 432 | }); 433 | } 434 | } 435 | 436 | if (apiClient && apiClient.getCurrentUserId()) { 437 | onLocalUserDone(); 438 | } 439 | }); 440 | 441 | self.logout = () => { 442 | const promises = []; 443 | 444 | for (let i = 0, length = self._apiClients.length; i < length; i++) { 445 | const apiClient = self._apiClients[i]; 446 | 447 | if (apiClient.accessToken()) { 448 | promises.push(logoutOfServer(apiClient)); 449 | } 450 | } 451 | 452 | return Promise.all(promises).then(() => { 453 | const credentials = credentialProvider.credentials(); 454 | 455 | const servers = credentials.Servers.filter((u) => u.UserLinkType !== 'Guest'); 456 | 457 | for (let j = 0, numServers = servers.length; j < numServers; j++) { 458 | const server = servers[j]; 459 | 460 | server.UserId = null; 461 | server.AccessToken = null; 462 | server.ExchangeToken = null; 463 | } 464 | }); 465 | }; 466 | 467 | function logoutOfServer(apiClient) { 468 | const serverInfo = apiClient.serverInfo() || {}; 469 | 470 | const logoutInfo = { 471 | serverId: serverInfo.Id 472 | }; 473 | 474 | return apiClient.logout().then( 475 | () => { 476 | events.trigger(self, 'localusersignedout', [logoutInfo]); 477 | }, 478 | () => { 479 | events.trigger(self, 'localusersignedout', [logoutInfo]); 480 | } 481 | ); 482 | } 483 | 484 | self.getSavedServers = () => { 485 | const credentials = credentialProvider.credentials(); 486 | 487 | const servers = credentials.Servers.slice(0); 488 | 489 | servers.sort((a, b) => (b.DateLastAccessed || 0) - (a.DateLastAccessed || 0)); 490 | 491 | return servers; 492 | }; 493 | 494 | self.getAvailableServers = () => { 495 | console.log('Begin getAvailableServers'); 496 | 497 | // Clone the array 498 | const credentials = credentialProvider.credentials(); 499 | 500 | return Promise.all([findServers()]).then((responses) => { 501 | const foundServers = responses[0]; 502 | let servers = credentials.Servers.slice(0); 503 | mergeServers(credentialProvider, servers, foundServers); 504 | 505 | servers.sort((a, b) => (b.DateLastAccessed || 0) - (a.DateLastAccessed || 0)); 506 | credentials.Servers = servers; 507 | credentialProvider.credentials(credentials); 508 | 509 | return servers; 510 | }); 511 | }; 512 | 513 | function findServers() { 514 | return new Promise((resolve, reject) => { 515 | var onFinish = function (foundServers) { 516 | var servers = foundServers.map((foundServer) => { 517 | var info = { 518 | Id: foundServer.Id, 519 | LocalAddress: convertEndpointAddressToManualAddress(foundServer) || foundServer.Address, 520 | Name: foundServer.Name 521 | }; 522 | info.LastConnectionMode = info.ManualAddress ? ConnectionMode.Manual : ConnectionMode.Local; 523 | return info; 524 | }); 525 | resolve(servers); 526 | }; 527 | 528 | if (window && window.NativeShell && typeof window.NativeShell.findServers === 'function') { 529 | window.NativeShell.findServers(1e3).then(onFinish, function () { 530 | onFinish([]); 531 | }); 532 | } else { 533 | resolve([]); 534 | } 535 | }); 536 | } 537 | 538 | function convertEndpointAddressToManualAddress(info) { 539 | if (info.Address && info.EndpointAddress) { 540 | let address = info.EndpointAddress.split(':')[0]; 541 | 542 | // Determine the port, if any 543 | const parts = info.Address.split(':'); 544 | if (parts.length > 1) { 545 | const portString = parts[parts.length - 1]; 546 | 547 | if (!isNaN(parseInt(portString))) { 548 | address += `:${portString}`; 549 | } 550 | } 551 | 552 | return normalizeAddress(address); 553 | } 554 | 555 | return null; 556 | } 557 | 558 | self.connectToServers = (servers, options) => { 559 | console.log(`Begin connectToServers, with ${servers.length} servers`); 560 | 561 | const firstServer = servers.length ? servers[0] : null; 562 | // See if we have any saved credentials and can auto sign in 563 | if (firstServer) { 564 | return self.connectToServer(firstServer, options).then((result) => { 565 | if (result.State === 'Unavailable') { 566 | result.State = 'ServerSelection'; 567 | } 568 | 569 | console.log('resolving connectToServers with result.State: ' + result.State); 570 | return result; 571 | }); 572 | } 573 | 574 | return Promise.resolve({ 575 | Servers: servers, 576 | State: 'ServerSelection' 577 | }); 578 | }; 579 | 580 | function getTryConnectPromise(url, connectionMode, state, resolve, reject) { 581 | console.log('getTryConnectPromise ' + url); 582 | 583 | ajax({ 584 | url: getEmbyServerUrl(url, 'system/info/public'), 585 | timeout: defaultTimeout, 586 | type: 'GET', 587 | dataType: 'json' 588 | }).then( 589 | (result) => { 590 | if (!state.resolved) { 591 | state.resolved = true; 592 | 593 | console.log('Reconnect succeeded to ' + url); 594 | resolve({ 595 | url: url, 596 | connectionMode: connectionMode, 597 | data: result 598 | }); 599 | } 600 | }, 601 | () => { 602 | console.log('Reconnect failed to ' + url); 603 | 604 | if (!state.resolved) { 605 | state.rejects++; 606 | if (state.rejects >= state.numAddresses) { 607 | reject(); 608 | } 609 | } 610 | } 611 | ); 612 | } 613 | 614 | function tryReconnect(serverInfo) { 615 | const addresses = []; 616 | const addressesStrings = []; 617 | 618 | // the timeouts are a small hack to try and ensure the remote address doesn't resolve first 619 | 620 | // manualAddressOnly is used for the local web app that always connects to a fixed address 621 | if ( 622 | !serverInfo.manualAddressOnly && 623 | serverInfo.LocalAddress && 624 | addressesStrings.indexOf(serverInfo.LocalAddress) === -1 625 | ) { 626 | addresses.push({ 627 | url: serverInfo.LocalAddress, 628 | mode: ConnectionMode.Local, 629 | timeout: 0 630 | }); 631 | addressesStrings.push(addresses[addresses.length - 1].url); 632 | } 633 | if (serverInfo.ManualAddress && addressesStrings.indexOf(serverInfo.ManualAddress) === -1) { 634 | addresses.push({ 635 | url: serverInfo.ManualAddress, 636 | mode: ConnectionMode.Manual, 637 | timeout: 100 638 | }); 639 | addressesStrings.push(addresses[addresses.length - 1].url); 640 | } 641 | if ( 642 | !serverInfo.manualAddressOnly && 643 | serverInfo.RemoteAddress && 644 | addressesStrings.indexOf(serverInfo.RemoteAddress) === -1 645 | ) { 646 | addresses.push({ 647 | url: serverInfo.RemoteAddress, 648 | mode: ConnectionMode.Remote, 649 | timeout: 200 650 | }); 651 | addressesStrings.push(addresses[addresses.length - 1].url); 652 | } 653 | 654 | console.log('tryReconnect: ' + addressesStrings.join('|')); 655 | 656 | return new Promise((resolve, reject) => { 657 | const state = {}; 658 | state.numAddresses = addresses.length; 659 | state.rejects = 0; 660 | 661 | addresses.map((url) => { 662 | setTimeout(() => { 663 | if (!state.resolved) { 664 | getTryConnectPromise(url.url, url.mode, state, resolve, reject); 665 | } 666 | }, url.timeout); 667 | }); 668 | }); 669 | } 670 | 671 | self.connectToServer = (server, options) => { 672 | console.log('begin connectToServer'); 673 | 674 | return new Promise((resolve, reject) => { 675 | options = options || {}; 676 | 677 | tryReconnect(server).then( 678 | (result) => { 679 | const serverUrl = result.url; 680 | const connectionMode = result.connectionMode; 681 | result = result.data; 682 | 683 | if (compareVersions(self.minServerVersion(), result.Version) === 1) { 684 | console.log('minServerVersion requirement not met. Server version: ' + result.Version); 685 | resolve({ 686 | State: 'ServerUpdateNeeded', 687 | Servers: [server] 688 | }); 689 | } else if (server.Id && result.Id !== server.Id) { 690 | console.log( 691 | 'http request succeeded, but found a different server Id than what was expected' 692 | ); 693 | resolveFailure(self, resolve); 694 | } else { 695 | onSuccessfulConnection(server, result, connectionMode, serverUrl, true, resolve, options); 696 | } 697 | }, 698 | () => { 699 | resolveFailure(self, resolve); 700 | } 701 | ); 702 | }); 703 | }; 704 | 705 | function onSuccessfulConnection(server, systemInfo, connectionMode, serverUrl, verifyLocalAuthentication, resolve, options={}) { 706 | const credentials = credentialProvider.credentials(); 707 | 708 | if (options.enableAutoLogin === false) { 709 | server.UserId = null; 710 | server.AccessToken = null; 711 | } else if (server.AccessToken && verifyLocalAuthentication) { 712 | return void validateAuthentication(server, serverUrl).then(function () { 713 | onSuccessfulConnection(server, systemInfo, connectionMode, serverUrl, false, resolve, options); 714 | }); 715 | } 716 | 717 | updateServerInfo(server, systemInfo); 718 | 719 | server.LastConnectionMode = connectionMode; 720 | 721 | if (options.updateDateLastAccessed !== false) { 722 | server.DateLastAccessed = new Date().getTime(); 723 | } 724 | credentialProvider.addOrUpdateServer(credentials.Servers, server); 725 | credentialProvider.credentials(credentials); 726 | 727 | const result = { 728 | Servers: [] 729 | }; 730 | 731 | result.ApiClient = self._getOrAddApiClient(server, serverUrl); 732 | 733 | result.ApiClient.setSystemInfo(systemInfo); 734 | result.SystemInfo = systemInfo; 735 | 736 | result.State = server.AccessToken && options.enableAutoLogin !== false ? 'SignedIn' : 'ServerSignIn'; 737 | 738 | result.Servers.push(server); 739 | 740 | // set this now before updating server info, otherwise it won't be set in time 741 | result.ApiClient.enableAutomaticBitrateDetection = options.enableAutomaticBitrateDetection; 742 | 743 | result.ApiClient.updateServerInfo(server, serverUrl); 744 | result.ApiClient.setAuthenticationInfo(server.AccessToken, server.UserId); 745 | 746 | const resolveActions = function () { 747 | resolve(result); 748 | 749 | events.trigger(self, 'connected', [result]); 750 | }; 751 | 752 | if (result.State === 'SignedIn') { 753 | afterConnected(result.ApiClient, options); 754 | 755 | result.ApiClient.getCurrentUser().then((user) => { 756 | onLocalUserSignIn(server, serverUrl, user).then(resolveActions, resolveActions); 757 | }, resolveActions); 758 | } else { 759 | resolveActions(); 760 | } 761 | } 762 | 763 | function tryConnectToAddress(address, options) { 764 | const server = { 765 | ManualAddress: address, 766 | LastConnectionMode: ConnectionMode.Manual 767 | }; 768 | 769 | return self.connectToServer(server, options).then((result) => { 770 | // connectToServer never rejects, but resolves with State='Unavailable' 771 | if (result.State === 'Unavailable') { 772 | return Promise.reject(); 773 | } 774 | return result; 775 | }); 776 | } 777 | 778 | self.connectToAddress = function (address, options) { 779 | if (!address) { 780 | return Promise.reject(); 781 | } 782 | 783 | address = normalizeAddress(address); 784 | 785 | let urls = []; 786 | 787 | if (/^[^:]+:\/\//.test(address)) { 788 | // Protocol specified - connect as is 789 | urls.push(address); 790 | } else { 791 | urls.push(`https://${address}`); 792 | urls.push(`http://${address}`); 793 | } 794 | 795 | let i = 0; 796 | 797 | function onFail() { 798 | console.log(`connectToAddress ${urls[i]} failed`); 799 | 800 | if (++i < urls.length) { 801 | return tryConnectToAddress(urls[i], options).catch(onFail); 802 | } 803 | 804 | return Promise.resolve({ 805 | State: 'Unavailable' 806 | }); 807 | } 808 | 809 | return tryConnectToAddress(urls[i], options).catch(onFail); 810 | }; 811 | 812 | self.deleteServer = (serverId) => { 813 | if (!serverId) { 814 | throw new Error('null serverId'); 815 | } 816 | 817 | let server = credentialProvider.credentials().Servers.filter((s) => s.Id === serverId); 818 | server = server.length ? server[0] : null; 819 | 820 | return new Promise((resolve, reject) => { 821 | function onDone() { 822 | const credentials = credentialProvider.credentials(); 823 | 824 | credentials.Servers = credentials.Servers.filter((s) => s.Id !== serverId); 825 | 826 | credentialProvider.credentials(credentials); 827 | resolve(); 828 | } 829 | 830 | if (!server.ConnectServerId) { 831 | onDone(); 832 | return; 833 | } 834 | }); 835 | }; 836 | } 837 | 838 | connect(options) { 839 | console.log('Begin connect'); 840 | 841 | return this.getAvailableServers().then((servers) => { 842 | return this.connectToServers(servers, options); 843 | }); 844 | } 845 | 846 | handleMessageReceived(msg) { 847 | const serverId = msg.ServerId; 848 | if (serverId) { 849 | const apiClient = this.getApiClient(serverId); 850 | if (apiClient) { 851 | if (typeof msg.Data === 'string') { 852 | try { 853 | msg.Data = JSON.parse(msg.Data); 854 | } catch (err) { 855 | console.log('unable to parse json content: ' + err); 856 | } 857 | } 858 | 859 | apiClient.handleMessageReceived(msg); 860 | } 861 | } 862 | } 863 | 864 | getApiClients() { 865 | const servers = this.getSavedServers(); 866 | 867 | for (let i = 0, length = servers.length; i < length; i++) { 868 | const server = servers[i]; 869 | if (server.Id) { 870 | this._getOrAddApiClient(server, getServerAddress(server, server.LastConnectionMode)); 871 | } 872 | } 873 | 874 | return this._apiClients; 875 | } 876 | 877 | getApiClient(item) { 878 | if (!item) { 879 | throw new Error('item or serverId cannot be null'); 880 | } 881 | 882 | // Accept string + object 883 | if (item.ServerId) { 884 | item = item.ServerId; 885 | } 886 | 887 | return this._apiClients.filter((a) => { 888 | const serverInfo = a.serverInfo(); 889 | 890 | // We have to keep this hack in here because of the addApiClient method 891 | return !serverInfo || serverInfo.Id === item; 892 | })[0]; 893 | } 894 | 895 | minServerVersion(val) { 896 | if (val) { 897 | this._minServerVersion = val; 898 | } 899 | 900 | return this._minServerVersion; 901 | } 902 | } 903 | -------------------------------------------------------------------------------- /src/credentials.js: -------------------------------------------------------------------------------- 1 | import events from './events'; 2 | import appStorage from './appStorage'; 3 | 4 | function initialize(appStorage, key) { 5 | const json = appStorage.getItem(key) || '{}'; 6 | 7 | console.log(`Stored JSON credentials: ${json}`); 8 | let credentials = JSON.parse(json); 9 | credentials.Servers = credentials.Servers || []; 10 | return credentials; 11 | } 12 | 13 | function set(instance, data) { 14 | if (data) { 15 | instance._credentials = data; 16 | instance.appStorage.setItem(instance.key, JSON.stringify(data)); 17 | } else { 18 | instance.clear(); 19 | } 20 | 21 | events.trigger(instance, 'credentialsupdated'); 22 | } 23 | 24 | export default class Credentials { 25 | constructor(key) { 26 | this.key = key || 'jellyfin_credentials'; 27 | this.appStorage = appStorage; 28 | this._credentials = initialize(this.appStorage, this.key); 29 | } 30 | 31 | clear() { 32 | this._credentials = null; 33 | this.appStorage.removeItem(this.key); 34 | } 35 | 36 | credentials(data) { 37 | if (data) { 38 | set(this, data); 39 | } 40 | 41 | return this._credentials; 42 | } 43 | 44 | addOrUpdateServer(list, server) { 45 | if (!server.Id) { 46 | throw new Error('Server.Id cannot be null or empty'); 47 | } 48 | 49 | const existing = list.filter(({ Id }) => Id === server.Id)[0]; 50 | 51 | if (existing) { 52 | // Merge the data 53 | existing.DateLastAccessed = Math.max(existing.DateLastAccessed || 0, server.DateLastAccessed || 0); 54 | 55 | existing.UserLinkType = server.UserLinkType; 56 | 57 | if (server.AccessToken) { 58 | existing.AccessToken = server.AccessToken; 59 | existing.UserId = server.UserId; 60 | } 61 | if (server.ExchangeToken) { 62 | existing.ExchangeToken = server.ExchangeToken; 63 | } 64 | if (server.RemoteAddress) { 65 | existing.RemoteAddress = server.RemoteAddress; 66 | } 67 | if (server.ManualAddress) { 68 | existing.ManualAddress = server.ManualAddress; 69 | } 70 | if (server.LocalAddress) { 71 | existing.LocalAddress = server.LocalAddress; 72 | } 73 | if (server.Name) { 74 | existing.Name = server.Name; 75 | } 76 | if (server.LastConnectionMode != null) { 77 | existing.LastConnectionMode = server.LastConnectionMode; 78 | } 79 | if (server.ConnectServerId) { 80 | existing.ConnectServerId = server.ConnectServerId; 81 | } 82 | 83 | return existing; 84 | } else { 85 | list.push(server); 86 | return server; 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/events.js: -------------------------------------------------------------------------------- 1 | function getCallbacks(obj, name) { 2 | if (!obj) { 3 | throw new Error('obj cannot be null!'); 4 | } 5 | 6 | obj._callbacks = obj._callbacks || {}; 7 | 8 | let list = obj._callbacks[name]; 9 | 10 | if (!list) { 11 | obj._callbacks[name] = []; 12 | list = obj._callbacks[name]; 13 | } 14 | 15 | return list; 16 | } 17 | 18 | export default { 19 | on(obj, eventName, fn) { 20 | const list = getCallbacks(obj, eventName); 21 | 22 | list.push(fn); 23 | }, 24 | 25 | off(obj, eventName, fn) { 26 | const list = getCallbacks(obj, eventName); 27 | 28 | const i = list.indexOf(fn); 29 | if (i !== -1) { 30 | list.splice(i, 1); 31 | } 32 | }, 33 | 34 | trigger(obj, eventName) { 35 | const eventObject = { 36 | type: eventName 37 | }; 38 | 39 | const eventArgs = []; 40 | eventArgs.push(eventObject); 41 | 42 | const additionalArgs = arguments[2] || []; 43 | for (let i = 0, length = additionalArgs.length; i < length; i++) { 44 | eventArgs.push(additionalArgs[i]); 45 | } 46 | 47 | const callbacks = getCallbacks(obj, eventName).slice(0); 48 | 49 | callbacks.forEach((c) => { 50 | c.apply(obj, eventArgs); 51 | }); 52 | } 53 | }; 54 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import ApiClient from './apiClient'; 2 | import ApiClientCore from './apiClientCore'; 3 | import AppStorage from './appStorage'; 4 | import ConnectionManager from './connectionManager'; 5 | import Credentials from './credentials'; 6 | import Events from './events'; 7 | 8 | export default { 9 | ApiClient, 10 | ApiClientCore, 11 | AppStorage, 12 | ConnectionManager, 13 | Credentials, 14 | Events 15 | }; 16 | -------------------------------------------------------------------------------- /src/promiseDelay.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creates a new delayed Promise instance. 3 | * @param {number} ms Delay in milliseconds. 4 | */ 5 | export default class PromiseDelay { 6 | constructor(ms) { 7 | this._fulfilled = false; 8 | this._promise = new Promise((resolve, reject) => { 9 | this._promiseResolve = resolve; 10 | this._promiseReject = reject; 11 | this.reset(ms); 12 | }); 13 | } 14 | 15 | /** 16 | * Delayed promise. 17 | * @returns {Promise} A Promise fulfilled after timeout. 18 | */ 19 | promise() { 20 | return this._promise; 21 | } 22 | 23 | /** 24 | * Resets delay. 25 | * @param {number} ms New delay in milliseconds. 26 | */ 27 | reset(ms) { 28 | if (this._fulfilled) return; 29 | clearTimeout(this._timer); 30 | this._timer = setTimeout(() => this.resolve(), ms); 31 | } 32 | 33 | /** 34 | * Immediately resolves delayed Promise. 35 | */ 36 | resolve() { 37 | if (this._fulfilled) return; 38 | clearTimeout(this._timer); 39 | this._fulfilled = true; 40 | this._promiseResolve(); 41 | } 42 | 43 | /** 44 | * Immediately rejects delayed Promise. 45 | */ 46 | reject() { 47 | if (this._fulfilled) return; 48 | clearTimeout(this._timer); 49 | this._fulfilled = true; 50 | this._promiseReject(); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /tests/apiClient.test.js: -------------------------------------------------------------------------------- 1 | import apiClient from '../src/apiClient'; 2 | 3 | let client; 4 | 5 | beforeEach(() => { 6 | client = new apiClient( 7 | 'https://demo.jellyfin.org/stable', 8 | 'Jellyfin Web', 9 | '10.5.0', 10 | 'Firefox', 11 | 'TW96aWxsYS81LjAgKFgxMTsgTGludXggeDg2XzY0OyBydjo3NC4wKSBHZWNrby8yMDEwMDEwMSBGaXJlZm94Lzc0LjB8MTU4NDkwMTA5OTY3NQ11' 12 | ); 13 | }); 14 | 15 | describe('ApiClient class', () => { 16 | it('is instantiable', () => { 17 | expect(client).toBeInstanceOf(apiClient); 18 | }); 19 | 20 | it('has the expected constructor', () => { 21 | expect(client._serverAddress).toBe('https://demo.jellyfin.org/stable'); 22 | expect(client._appName).toBe('Jellyfin Web'); 23 | expect(client._appVersion).toBe('10.5.0'); 24 | expect(client._deviceName).toBe('Firefox'); 25 | expect(client._deviceId).toBe( 26 | 'TW96aWxsYS81LjAgKFgxMTsgTGludXggeDg2XzY0OyBydjo3NC4wKSBHZWNrby8yMDEwMDEwMSBGaXJlZm94Lzc0LjB8MTU4NDkwMTA5OTY3NQ11' 27 | ); 28 | }); 29 | 30 | it('can get serverAddress', () => { 31 | expect(client.serverAddress()).toBe('https://demo.jellyfin.org/stable'); 32 | }); 33 | 34 | it('can get appName', () => { 35 | expect(client.appName()).toBe('Jellyfin Web'); 36 | }); 37 | 38 | it('can get appVersion', () => { 39 | expect(client.appVersion()).toBe('10.5.0'); 40 | }); 41 | 42 | it('can get deviceName', () => { 43 | expect(client.deviceName()).toBe('Firefox'); 44 | }); 45 | 46 | it('can get deviceId', () => { 47 | expect(client.deviceId()).toBe( 48 | 'TW96aWxsYS81LjAgKFgxMTsgTGludXggeDg2XzY0OyBydjo3NC4wKSBHZWNrby8yMDEwMDEwMSBGaXJlZm94Lzc0LjB8MTU4NDkwMTA5OTY3NQ11' 49 | ); 50 | }); 51 | 52 | it('can throw error on setting an invalid server address', () => { 53 | expect(() => { 54 | client.serverAddress('lorem'); 55 | }).toThrow(Error); 56 | }); 57 | 58 | it('can change server address', () => { 59 | expect(client.serverAddress('https://demo.jellyfin.org/nightly')).toBe('https://demo.jellyfin.org/nightly'); 60 | }); 61 | 62 | describe('getUrl()', () => { 63 | it('can get a URL', () => { 64 | expect(client.getUrl('/System/Info/Public')).toBe('https://demo.jellyfin.org/stable/System/Info/Public'); 65 | }); 66 | 67 | it('can throw error on getting an empty URL', () => { 68 | expect(() => { 69 | client.getUrl(); 70 | }).toThrow(Error); 71 | }); 72 | }); 73 | 74 | it('can set valid headers', () => { 75 | const headers = {}; 76 | expect(() => { 77 | client.setRequestHeaders(headers); 78 | }).not.toThrow(Error); 79 | expect(headers).toStrictEqual({ 80 | 'Authorization': 81 | 'MediaBrowser Client="Jellyfin Web", Device="Firefox", DeviceId="TW96aWxsYS81LjAgKFgxMTsgTGludXggeDg2XzY0OyBydjo3NC4wKSBHZWNrby8yMDEwMDEwMSBGaXJlZm94Lzc0LjB8MTU4NDkwMTA5OTY3NQ11", Version="10.5.0"' 82 | }); 83 | }); 84 | 85 | describe('authenticateUserByName()', () => { 86 | it('can authenticate successfully', async () => { 87 | const response = await client.authenticateUserByName('demo'); 88 | expect(response.User).toBeDefined(); 89 | expect(response.User.Name).toBe('demo'); 90 | }); 91 | 92 | it('will reject with no username', () => { 93 | return expect(client.authenticateUserByName()).rejects.toBeUndefined(); 94 | }); 95 | 96 | it('will reject with invalid credentials', () => { 97 | return expect(client.authenticateUserByName('apiclienttest', 'password')).rejects.toBeDefined(); 98 | }); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /tests/events.test.js: -------------------------------------------------------------------------------- 1 | import events from '../src/events'; 2 | 3 | describe('Events', () => { 4 | it('contains an on property', () => { 5 | expect(events).toHaveProperty('on'); 6 | }); 7 | 8 | it('contains an off property', () => { 9 | expect(events).toHaveProperty('off'); 10 | }); 11 | 12 | it('contains a trigger property', () => { 13 | expect(events).toHaveProperty('trigger'); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /tests/index.test.js: -------------------------------------------------------------------------------- 1 | import index from '../src/index'; 2 | 3 | describe('Entry point', () => { 4 | it('contains an ApiClient property', () => { 5 | expect(index).toHaveProperty('ApiClient'); 6 | }); 7 | 8 | it('contains an ApiClientCore property', () => { 9 | expect(index).toHaveProperty('ApiClientCore'); 10 | }); 11 | 12 | it('contains an AppStorage property', () => { 13 | expect(index).toHaveProperty('AppStorage'); 14 | }); 15 | 16 | it('contains an ConnectionManager property', () => { 17 | expect(index).toHaveProperty('ConnectionManager'); 18 | }); 19 | 20 | it('contains an Credentials property', () => { 21 | expect(index).toHaveProperty('Credentials'); 22 | }); 23 | 24 | it('contains an Events property', () => { 25 | expect(index).toHaveProperty('Events'); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | var babelLoader = { 4 | loader: 'babel-loader', 5 | options: { 6 | cacheDirectory: true 7 | } 8 | }; 9 | 10 | module.exports = { 11 | entry: { 12 | 'jellyfin-apiclient': 'index.js' 13 | }, 14 | devtool: 'source-map', 15 | module: { 16 | rules: [ 17 | { 18 | test: /\.js$/, 19 | exclude: /node_modules/, 20 | use: [babelLoader] 21 | } 22 | ] 23 | }, 24 | resolve: { 25 | extensions: ['.js'], 26 | modules: [path.resolve(__dirname, 'node_modules'), path.resolve(__dirname, 'src')] 27 | }, 28 | output: { 29 | filename: '[name].js', 30 | path: path.resolve(__dirname, 'dist'), 31 | library: '[name]', 32 | libraryTarget: 'umd', 33 | libraryExport: 'default' 34 | } 35 | }; 36 | --------------------------------------------------------------------------------