├── .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 |
3 |
4 | ---
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | > [!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 |
--------------------------------------------------------------------------------