├── .gitignore ├── .prettierrc ├── APIDESIGN.md ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── bin.js ├── collections.js ├── crud.js ├── filebrowser.js ├── fileformats.js ├── install.ps1 ├── install.sh ├── mpvremote.conf ├── mpvremote └── main.js ├── package.json ├── postman ├── MPV Remote.postman_collection.json └── MPV Remote.postman_environment.json ├── remoteServer.js ├── settings.js └── watchlisthandler.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | npm-debug.log 4 | .env 5 | .DS_Store 6 | *.tgz 7 | .prettierrc 8 | package-lock.json -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false 4 | } 5 | -------------------------------------------------------------------------------- /APIDESIGN.md: -------------------------------------------------------------------------------- 1 | # Postman 2 | 3 | [You can download Postman collection and environment here](https://github.com/husudosu/mpv-remote-node/tree/master/postman) 4 | 5 | For safety reasons computer actions route not included! 6 | 7 | # Responses 8 | 9 | Default response: 10 | 11 | ```json 12 | { "message": "success or error message" } 13 | ``` 14 | 15 | # API Routes 16 | 17 | - MPV Status 18 | - Playback controls 19 | - Playlist 20 | - Tracks 21 | - Filebrowser 22 | - Collection handling 23 | - Computer actions 24 | 25 | # Status 26 | 27 | ## /api/v1/mpvinfo 28 | 29 | **Methods:** GET 30 | 31 | Gets info about MPV. MPV remote plugin settings also included. 32 | 33 | ## /api/v1/status 34 | 35 | **Methods:** GET 36 | 37 | Gets status of the player. 38 | 39 | **Query parameters:** 40 | 41 | **exclude:** You can provide properties to exclude as list. 42 | 43 | - example value: exclude=playlist,track-list 44 | 45 | Example response: 46 | 47 | ```json 48 | { 49 | "pause": true, 50 | "mute": false, 51 | "filename": "Dorohedoro 02 - In the Bag - Eat Quietly During Meals - My Neighbor the Sorcerer.mkv", 52 | "duration": 1422.061, 53 | "position": 71.787, 54 | "remaining": 1350.274, 55 | "media-title": "Dorohedoro 02 - In the Bag | Eat Quietly During Meals | My Neighbor the Sorcerer", 56 | "playlist": [ 57 | { 58 | "index": 0, 59 | "id": 1, 60 | "filePath": "V:\\anime\\Vivy - Fluorite Eye's Song S01 1080p\\Vivy - Fluorite Eye's Song - E02.mkv", 61 | "filename": "Vivy - Fluorite Eye's Song - E02.mkv" 62 | }, 63 | { 64 | "index": 1, 65 | "id": 2, 66 | "filePath": "V:\\anime\\Dorohedoro S01+OVA (BD 1080p)\\Dorohedoro 02 - In the Bag - Eat Quietly During Meals - My Neighbor the Sorcerer.mkv", 67 | "current": true, 68 | "filename": "Dorohedoro 02 - In the Bag - Eat Quietly During Meals - My Neighbor the Sorcerer.mkv" 69 | } 70 | ], 71 | "chapter": 0, 72 | "chapter-list": [ 73 | { 74 | "title": "Intro", 75 | "time": 0 76 | }, 77 | { 78 | "title": "OP", 79 | "time": 68.986 80 | }, 81 | { 82 | "title": "Part A", 83 | "time": 159.034 84 | }, 85 | { 86 | "title": "Part B", 87 | "time": 589.089 88 | }, 89 | { 90 | "title": "ED", 91 | "time": 1316.023 92 | }, 93 | { 94 | "title": "Preview", 95 | "time": 1406.03 96 | } 97 | ], 98 | "volume": 100, 99 | "max-volume": 100, 100 | "fullscreen": false, 101 | "speed": 1, 102 | "sub-delay": 0, 103 | "sub-visibility": true, 104 | "track-list": [ 105 | { 106 | "index": 0, 107 | "id": 1, 108 | "type": "video", 109 | "selected": true, 110 | "codec": "hevc", 111 | "demux-w": 1920, 112 | "demux-h": 1080 113 | }, 114 | { 115 | "index": 1, 116 | "id": 1, 117 | "type": "audio", 118 | "selected": true, 119 | "codec": "opus", 120 | "demux-channel-count": 2, 121 | "demux-channels": "unknown2", 122 | "demux-samplerate": 48000, 123 | "lang": "jpn" 124 | }, 125 | { 126 | "index": 2, 127 | "id": 2, 128 | "type": "audio", 129 | "selected": false, 130 | "codec": "opus", 131 | "demux-channel-count": 2, 132 | "demux-channels": "unknown2", 133 | "demux-samplerate": 48000, 134 | "lang": "eng" 135 | }, 136 | { 137 | "index": 3, 138 | "id": 1, 139 | "type": "sub", 140 | "selected": true, 141 | "codec": "subrip", 142 | "lang": "hun" 143 | }, 144 | { 145 | "index": 4, 146 | "id": 2, 147 | "type": "sub", 148 | "selected": false, 149 | "codec": "ass", 150 | "lang": "eng" 151 | }, 152 | { 153 | "index": 5, 154 | "id": 3, 155 | "type": "sub", 156 | "selected": false, 157 | "codec": "ass", 158 | "lang": "eng" 159 | } 160 | ], 161 | "audio-delay": 0, 162 | "sub-font-size": 55, 163 | "sub-ass-override": true, 164 | "metadata": {} 165 | } 166 | ``` 167 | 168 | # Media controls 169 | 170 | ## /api/v1/controls/play-pause 171 | 172 | **Methods:** POST 173 | 174 | Cycles between play and pause 175 | 176 | ## /api/v1/controls/play 177 | 178 | **Methods:** POST 179 | 180 | Start playback 181 | 182 | ## /api/v1/controls/pause 183 | 184 | **Methods:** POST 185 | 186 | Pause playback 187 | 188 | ## /api/v1/controls/stop 189 | 190 | **Methods:** POST 191 | 192 | Stops the playback, also clears playlist 193 | 194 | ## /api/v1/controls/prev 195 | 196 | Alias for /playlist/prev 197 | 198 | ## /api/v1/controls/next 199 | 200 | Alias for /playlist/next 201 | 202 | ## /api/v1/controls/fullscreen 203 | 204 | **Methods:** POST 205 | 206 | Toggles fullscreen mode 207 | 208 | ## /api/v1/controls/mute 209 | 210 | **Methods:** POST 211 | 212 | Mutes volume 213 | 214 | ## /api/v1/controls/volume/:value 215 | 216 | **Methods:** POST 217 | 218 | Sets volume 219 | 220 | ## /api/v1/controls/seek 221 | 222 | **Methods:** POST 223 | 224 | Seek 225 | 226 | Request JSON: 227 | 228 | ```json 229 | { 230 | "target": 10.0, 231 | "flag": "absolute-percent" // if no flag provided defaults to relative 232 | } 233 | ``` 234 | 235 | ### Flags 236 | 237 | - relative (Default) 238 | - absolute 239 | 240 | # Playlist 241 | 242 | ## /api/v1/playlist 243 | 244 | **Methods:** GET, POST 245 | 246 | **Related MPV documentation:** https://mpv.io/manual/master/#command-interface-[%3Coptions%3E] 247 | 248 | ### **GET** 249 | 250 | Gets Playlist items. 251 | 252 | Response JSON: 253 | 254 | ```json 255 | [ 256 | { 257 | "index": 0, 258 | "id": 1, 259 | "filePath": "V:\\anime\\Vivy - Fluorite Eye's Song S01 1080p\\Vivy - Fluorite Eye's Song - E02.mkv", 260 | "current": true, 261 | "filename": "Vivy - Fluorite Eye's Song - E02.mkv" 262 | }, 263 | { 264 | "index": 1, 265 | "id": 2, 266 | "filePath": "V:\\anime\\Dorohedoro S01+OVA (BD 1080p)\\Dorohedoro 02 - In the Bag - Eat Quietly During Meals - My Neighbor the Sorcerer.mkv", 267 | "filename": "Dorohedoro 02 - In the Bag - Eat Quietly During Meals - My Neighbor the Sorcerer.mkv" 268 | } 269 | ] 270 | ``` 271 | 272 | ### **POST** 273 | 274 | Puts an item to playlist. 275 | 276 | **Request JSON:** 277 | 278 | ```JSON 279 | { 280 | "filename": "V:\\anime\\Vivy - Fluorite Eye's Song S01 1080p\\Vivy - Fluorite Eye's Song - E02.mkv", 281 | "flag": "replace", 282 | "seekTo": 60.0, // seekTo works only if flag is replace. Format seconds 283 | "file-local-options": [ 284 | "http-header-fields": "'MyHeader: 1234', 'MyHeader2: 5678'" 285 | ] 286 | } 287 | ``` 288 | 289 | **flag** possible values: 290 | 291 | - replace 292 | - append 293 | - append-play (Default) 294 | 295 | ## /api/v1/playlist/remove/:index 296 | 297 | **Methods:** DELETE 298 | 299 | Deletes a playlist item. 300 | 301 | ## /api/v1/playlist/move?fromIndex=0&toIndex=1 302 | 303 | **Methods**: POST 304 | 305 | **Related MPV documentation:** https://mpv.io/manual/master/#command-interface-playlist-move 306 | 307 | **Query parameters**: 308 | 309 | - **fromIndex (REQUIRED):** Moves this item. 310 | - **toIndex (REQUIRED):** To this index. 311 | 312 | Moves a playlist item (fromIndex), to desired destination (toIndex). 313 | 314 | ## /api/v1/playlist/play/:index 315 | 316 | **Methods**: POST 317 | 318 | Plays playlist item. 319 | 320 | Note: index can be current too, whcih gonna reload the current entry. 321 | 322 | ## /api/v1/playlist/prev 323 | 324 | **Methods**: POST 325 | 326 | Playlist previous item on playlist 327 | 328 | ## /api/v1/playlist/next 329 | 330 | **Methods**: POST 331 | 332 | Playlist next item on playlist 333 | 334 | ## /api/v1/playlist/clear 335 | 336 | **Methods**: POST 337 | 338 | Clears playlist. 339 | 340 | ## /api/v1/playlist/shuffle 341 | 342 | **Methods**: POST 343 | 344 | Shuffle the playlist. 345 | 346 | # Tracks 347 | 348 | ## /api/v1/tracks 349 | 350 | **Methods:** GET 351 | 352 | Gets all audio, video, subtitle tracks. 353 | 354 | Example response: 355 | 356 | ```json 357 | [ 358 | { 359 | "index": 0, 360 | "id": 1, 361 | "type": "video", 362 | "selected": true, 363 | "codec": "hevc", 364 | "demux-w": 1920, 365 | "demux-h": 1080 366 | }, 367 | { 368 | "index": 1, 369 | "id": 1, 370 | "type": "audio", 371 | "selected": true, 372 | "codec": "opus", 373 | "demux-channel-count": 2, 374 | "demux-channels": "unknown2", 375 | "demux-samplerate": 48000, 376 | "lang": "jpn" 377 | }, 378 | { 379 | "index": 2, 380 | "id": 2, 381 | "type": "audio", 382 | "selected": false, 383 | "codec": "opus", 384 | "demux-channel-count": 2, 385 | "demux-channels": "unknown2", 386 | "demux-samplerate": 48000, 387 | "lang": "eng" 388 | }, 389 | { 390 | "index": 3, 391 | "id": 1, 392 | "type": "sub", 393 | "selected": true, 394 | "codec": "subrip", 395 | "lang": "hun" 396 | }, 397 | { 398 | "index": 4, 399 | "id": 2, 400 | "type": "sub", 401 | "selected": false, 402 | "codec": "ass", 403 | "lang": "eng" 404 | }, 405 | { 406 | "index": 5, 407 | "id": 3, 408 | "type": "sub", 409 | "selected": false, 410 | "codec": "ass", 411 | "lang": "eng" 412 | } 413 | ] 414 | ``` 415 | 416 | ## /api/v1/tracks/audio/reload/:id 417 | 418 | **Methods:** POST 419 | 420 | Loads desired audio track ID 421 | 422 | ## /api/v1/tracks/audio/cycle 423 | 424 | **Methods:** POST 425 | 426 | Cycles through audio tracks 427 | 428 | ## /api/v1/tracks/audio/add 429 | 430 | **Methods:** POST 431 | 432 | Adds an audio track 433 | 434 | **Request JSON:** 435 | 436 | ```json 437 | { 438 | "filename": "/home/usr/myaudio.mp3", 439 | "flag": "select" // if no flag provided defaults to select 440 | } 441 | ``` 442 | 443 | ### Flags 444 | 445 | - select 446 | - auto 447 | - cached 448 | 449 | ## /api/v1/tracks/audio/timing/:seconds 450 | 451 | **Methods:** POST 452 | 453 | Sets audio delay to provided second, can be negative. 454 | 455 | ## /api/v1/tracks/sub/reload/:id 456 | 457 | **Methods:** POST 458 | 459 | Loads desired subtitle track ID 460 | 461 | ## /api/v1/tracks/sub/timing/:seconds 462 | 463 | **Methods:** POST 464 | 465 | Sets sub delay to provided value, can be negative number. 466 | 467 | ## /api/v1/tracks/sub/ass-override/:value 468 | 469 | **Methods:** POST 470 | 471 | Changes default behaviour of rendering ASS/SSA subtitles. 472 | MPV mostly renders ASS/SSA subtitles correctly, but if you need it, use it. 473 | 474 | **MPV related documentation:** https://mpv.io/manual/master/#options-sub-ass-override 475 | 476 | **BE VERY CAREFUL SOME VALUES CAN BREAK PLAYBACK CHECK DOCUMENTATION ABOVE** 477 | 478 | Possible values: 479 | 480 | - no 481 | - yes 482 | - force 483 | - scale 484 | - strip 485 | 486 | ## /api/v1/tracks/sub/toggle-visibility 487 | 488 | **Methods:** POST 489 | 490 | Toggles subtitle visibility 491 | 492 | ## /api/v1/tracks/sub/visibility/:value 493 | 494 | **Methods:** POST 495 | Sets subtitle visibility 496 | 497 | Values can be: 498 | 499 | - true 500 | - false 501 | 502 | ## /api/v1/tracks/sub/add 503 | 504 | **Methods:** POST 505 | 506 | Add subtitle file. 507 | 508 | **Request JSON:** 509 | 510 | ```json 511 | { 512 | "filename": "/home/usr/mysub.srt", 513 | "flag": "select" // if no flag provided defaults to select 514 | } 515 | ``` 516 | 517 | ### Flags 518 | 519 | - select 520 | - auto 521 | - cached 522 | 523 | # Filebrowser 524 | 525 | Basic filebrowser which only accepts paths which included at server configured variable `mpvremote-filebrowserpaths` 526 | 527 | ## /api/v1/filebrowser/paths 528 | 529 | **Methods**: GET 530 | 531 | Returns content of `mpvremote-filebrowserpaths` option paths indexed. 532 | 533 | Response JSON: 534 | 535 | ```json 536 | [ 537 | { 538 | "index": 0, 539 | "path": "/home/usr/media1" 540 | }, 541 | { 542 | "index": 1, 543 | "path": "/home/usr/media2" 544 | }, 545 | { 546 | "index": 2, 547 | "path": "/home/usr/media3" 548 | } 549 | ] 550 | ``` 551 | 552 | ## /api/v1/filebrowser/browse 553 | 554 | **Methods:** POST 555 | 556 | Browse a path or collection. 557 | 558 | **Request JSON:** 559 | 560 | ```json 561 | "path": "/home/usr/media2" 562 | // Or 563 | "collection_id": 1 564 | ``` 565 | 566 | ## /api/v1/filebrowser/browse/:index 567 | 568 | **Methods:** GET 569 | 570 | **Note:** Only MPV supported fileformats will return. [Supported file formats](https://github.com/husudosu/mpv-remote-node/blob/master/fileformats.js) 571 | 572 | Returns files with types: 573 | 574 | ```json 575 | { 576 | "content": [ 577 | { 578 | "priority": 1, 579 | "type": "directory", 580 | "name": "Feliratok", 581 | "fullPath": "V:\\anime\\Dorohedoro S01+OVA (BD 1080p)\\Feliratok", 582 | "lastModified": "2021-05-03T19:10:16.008Z" 583 | }, 584 | { 585 | "priority": 2, 586 | "type": "video", 587 | "name": "Dorohedoro 01 - Caiman.mkv", 588 | "fullPath": "V:\\anime\\Dorohedoro S01+OVA (BD 1080p)\\Dorohedoro 01 - Caiman.mkv", 589 | "lastModified": "2021-05-03T19:09:21.857Z", 590 | "mediaStatus": { 591 | // Media status appears only when localdb enabled! 592 | "id": 2, 593 | "directory": "V:\\anime\\Dorohedoro S01+OVA (BD 1080p)", 594 | "file_name": "Dorohedoro 01 - Caiman.mkv", 595 | "current_time": 1422.0545, 596 | "finished": 1 597 | } 598 | }, 599 | { 600 | "priority": 2, 601 | "type": "video", 602 | "name": "Dorohedoro 02 - In the Bag - Eat Quietly During Meals - My Neighbor the Sorcerer.mkv", 603 | "fullPath": "V:\\anime\\Dorohedoro S01+OVA (BD 1080p)\\Dorohedoro 02 - In the Bag - Eat Quietly During Meals - My Neighbor the Sorcerer.mkv", 604 | "lastModified": "2021-05-03T19:04:05.410Z", 605 | "mediaStatus": { 606 | "id": 1, 607 | "directory": "V:\\anime\\Dorohedoro S01+OVA (BD 1080p)", 608 | "file_name": "Dorohedoro 02 - In the Bag - Eat Quietly During Meals - My Neighbor the Sorcerer.mkv", 609 | "current_time": 596.853, 610 | "finished": 0 611 | } 612 | }, 613 | { 614 | "priority": 2, 615 | "type": "video", 616 | "name": "Dorohedoro 03 - Night of the Dead ~ Duel! ~ In Front of the Main Department Store.mkv", 617 | "fullPath": "V:\\anime\\Dorohedoro S01+OVA (BD 1080p)\\Dorohedoro 03 - Night of the Dead ~ Duel! ~ In Front of the Main Department Store.mkv", 618 | "lastModified": "2021-05-03T19:04:26.434Z" 619 | } 620 | ], 621 | "dirname": "Dorohedoro S01+OVA (BD 1080p)", 622 | "prevDir": "V:\\anime", 623 | "cwd": "V:\\anime\\Dorohedoro S01+OVA (BD 1080p)" 624 | } 625 | ``` 626 | 627 | ## /api/v1/drives 628 | 629 | Gets drives from server. 630 | Only if `mpvremote-unsafefilebrowsing=1`. 631 | 632 | If unsafe filebrowsing is disabled returns an error. 633 | 634 | Note for Linux users: snap, flatpak virtual drives will be excluded! 635 | 636 | **Response JSON:** 637 | 638 | ```json 639 | // Windows Example 640 | [ 641 | { 642 | "path": "C" 643 | }, 644 | { 645 | "path": "D:" 646 | } 647 | ] 648 | // Unix systems 649 | [ 650 | { 651 | "path": "/" 652 | }, 653 | { 654 | "path": "/home" 655 | } 656 | ] 657 | ``` 658 | 659 | # Collections 660 | 661 | Local collection handling. Collections only works if you have enabled `mpvremote-uselocaldb` 662 | Collections entries only can be opened if `mpvremote-filebrowserpaths` contains the required paths. 663 | 664 | ## /api/v1/collections 665 | 666 | **Methods:** GET, POST 667 | 668 | ### **GET** 669 | 670 | Get all collections 671 | 672 | **Response JSON:** 673 | 674 | Array of collection. 675 | 676 | ### **POST** 677 | 678 | Creates a collection 679 | 680 | **type** variable/enum created for future use: 681 | 682 | - 1: Movies 683 | - 2: TVShows 684 | - 3: Music 685 | 686 | **Request JSON:** 687 | 688 | ``` 689 | "name": "Anime", 690 | "type": 1, 691 | "paths": [ 692 | { 693 | "path": "/home/usr/media1" 694 | }, 695 | { 696 | "path": "/home/usr/media2" 697 | } 698 | ] 699 | ``` 700 | 701 | ## /api/v1/collections/:id 702 | 703 | **Methods:** GET, PATCH, DELETE 704 | 705 | ### **GET** 706 | 707 | Gets collection 708 | 709 | **Response JSON:** 710 | 711 | ```json 712 | { 713 | "id": 1, 714 | "name": "Anime", 715 | "type": 1, 716 | "paths": [ 717 | { 718 | "id": 1 719 | "path": "/home/usr/media1" 720 | }, 721 | { 722 | "id": 2, 723 | "path": "/home/usr/media2" 724 | } 725 | ] 726 | } 727 | ``` 728 | 729 | ### **PATCH** 730 | 731 | Updates collection. 732 | 733 | **Request JSON:** 734 | 735 | ```json 736 | { 737 | "name": "Anime ja nai", 738 | "type": 2, 739 | "paths": [ 740 | { 741 | "path": "/home/usr/media3" // Adds new path 742 | }, 743 | { 744 | "id": 2, 745 | "path": "/home/usr/media2_other" // Updates existing path 746 | } 747 | ] 748 | } 749 | ``` 750 | 751 | ### **DELETE** 752 | 753 | Deletes collection. 754 | 755 | ## /api/v1/collections/entries/:id 756 | 757 | **Methods:** DELETE 758 | 759 | Deletes a collection entry. 760 | 761 | ## /api/v1/collections/:collection_id/entries 762 | 763 | Adds collection entry to collection. 764 | 765 | **Methods:** POST 766 | 767 | **Request JSON:** 768 | 769 | ```json 770 | { 771 | "collection_id": 1, 772 | "path": "/home/usr/media4" 773 | } 774 | ``` 775 | 776 | # Computer actions 777 | 778 | ## /api/v1/computer/:action 779 | 780 | **Methods:** POST 781 | 782 | action can be: 783 | 784 | - shutdown 785 | - reboot 786 | - quit 787 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.0.2 4 | 5 | - Socket IO server replaced with HTTP API 6 | - Plugin settings added 7 | - Computer actions implemented like Shutdown/Reboot. 8 | - Ability to change subtitle font size, option to ASS override. 9 | - Get MPV version info and plugin settings via route, can be useful when building your own implementation of frontend. 10 | - Filebrowser entries improved and extended with file/directory last modified date 11 | - Ability to change audio timing 12 | - Installer PowerShell and bash script 13 | - remote.socketio.js renamed to remoteServer.js 14 | 15 | ## 1.0.3 16 | 17 | - Code tidy up 18 | - MPV socket Verbose mode made optional 19 | - Better structure, validation, error handling for collections 20 | - Removed most debug messages from server 21 | - Metadata reading fixed 22 | - Filebrowserpath behaviour fixed 23 | - Hotfix: installer scritps fixed 24 | 25 | ## 1.0.4 26 | 27 | - Load file improvements, way to set http-header-fields, needed for playing data from streaming services. 28 | 29 | ## 1.0.5 30 | 31 | - max-volume added (contribution by: https://github.com/byter11) 32 | - multiple MPV instances supported (set webport and webportrangeend on your settings) 33 | - placing "file-local-options.txt" to OS temp directory 34 | 35 | ## 1.0.6 36 | 37 | - Ability to turn off OSD messages via `mpvremote-osd-messages` setting, 38 | - MPV info now includes version of MPV remote plugin 39 | 40 | ## 1.0.7 41 | 42 | - Fixed IP getting on newer Node.JS versions, 43 | - If the CPU usage high on the host machine returning cached properties, 44 | - Ability to exclude properties on /status route as query param, check [APIDESIGN.MD](https://github.com/husudosu/mpv-remote-node/blob/master/APIDESIGN.md) 45 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2021 Ferenc Nánási 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | MPV remote control API. You can use [MPV Remote android application](https://github.com/husudosu/mpv-remote-app/releases/latest) or you can create your own frontend. 2 | 3 | [API documentation accessible here.](https://github.com/husudosu/mpv-remote-node/blob/master/APIDESIGN.md) 4 | 5 | # Installation 6 | 7 | ### Requirements: 8 | 9 | - [Node.JS](https://nodejs.org/en/) (use [13.14.0 LTS](https://nodejs.org/download/release/v13.14.0/) for Windows 7) 10 | - [yt-dlp](https://github.com/yt-dlp/yt-dlp) or [youtube-dl](https://youtube-dl.org/) for playing youtube videos, 11 | 12 | ### Install package 13 | 14 | **Linux:** 15 | 16 | Open your favorite terminal and: 17 | 18 | ```bash 19 | sudo npm install -g mpv-remote 20 | mpv-remote # Follow instructions 21 | ``` 22 | 23 | **Windows:** 24 | Open powershell as admin. It's required only for creating symbolic links. 25 | 26 | ```powershell 27 | Set-ExecutionPolicy Unrestricted -Force # Allows running PS scripts from unknown sources 28 | npm install -g mpv-remote 29 | mpv-remote # Follow instructions 30 | ``` 31 | 32 | ## Update 33 | 34 | You have to re-run installation script after updating the package. 35 | 36 | ```bash 37 | npm update -g mpv-remote 38 | mpv-remote # Follow instructions 39 | ``` 40 | 41 | Note: if you get "Cannot create symbolic link because the path already exists" don't worry the installation will be fine. 42 | 43 | # How to run MPV 44 | 45 | If you don't want MPV close after playback finished use --idle flag or you can add `idle=yes` to your mpv.conf. 46 | 47 | ``` 48 | mpv --idle 49 | ``` 50 | 51 | # Configuration variables 52 | 53 | You can configure server by using `--script-opts` flag of MPV like this (options seperated by ,): 54 | 55 | ``` 56 | mpv --script-opts=mpvremote-filebrowserpaths=/home/sudosu,mpvremote-uselocaldb=0 57 | ``` 58 | 59 | Or you can use a script-opts file. 60 | 61 | scipt-opts location for mpvremote: 62 | 63 | ```bash 64 | %appdata%/mpv/script-opts/mpvremote.conf # For windows 65 | ~/.config/mpv/script-opts/mpvremote.conf # For linux 66 | ``` 67 | 68 | If using script-opts file, you don't need `mpvremote-` prefix at configuration options. 69 | 70 | [More info about script-opts files.](https://mpv.io/manual/master/#configuration) 71 | 72 | Example configuration file: 73 | 74 | ``` 75 | # ~/.config/mpv/script-opts/mpvremote.conf 76 | uselocaldb=1 77 | webport=8000 78 | webportrangeend=8010 79 | unsafefilebrowsing=1 80 | filebrowserpaths="'V:\anime';'W:\anime';'W:\Steins;Gate'" 81 | verbose=0 82 | osd-messages=1 83 | ``` 84 | 85 | ## Available options: 86 | 87 | | Option name | Description | Default value | Available options/example | 88 | | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- | ---------------------------------------- | 89 | | mpvremote-uselocaldb | Use local database to store media statuses and collections. | 1 | 0 - Disabled
1 - Enabled | 90 | | mpvremote-filebrowserpaths | Stores paths which can be browsable by users it's a semicolon seperated list | N/A | "'V:\anime';'W:\anime';'W:\Steins;Gate'" | 91 | | mpvremote-webport | This option gonna be your first available port for MPV. | 8000 | Any port within correct range | 92 | | mpvremote-webportrangeend | Web port range end. if mpvremote-webport is 8000 and this option set to 8004, 5 instances of MPV gonna be supported. Increase/decrease these numbers as you wish. | 8004 | Any port within correct range | 93 | | mpvreomte-address | Server address | Your first local IP | 127.0.0.1 | 94 | | mpvremote-unsafefilebrowsing | Allows you to browse your local filesystem. Be careful though, exposing your whole filesystem not the safest option. For security reasons filebrowser only send results of media files, playlists, subtitle files and subdirectories. | 1 | 0 - Disabled
1 - Enabled | 95 | | mpvremote-verbose | Verbose logging of MPV socket | 0 | 0 - Disabled
1 - Enabled | 96 | | mpvremote-osd-messages | Show OSD messages on the player created by this plugin | 1 | 0 - Disabled
1 - Enabled | 97 | 98 | # Troubleshooting 99 | 100 | ## NPM install/update takes forever 101 | 102 | Sometimes NPM takes forever to install a package, [it's a known bug](https://github.com/npm/cli/issues/3359), try update NPM to the latest version and hope it's going to work. Run this as administrator: 103 | 104 | ``` 105 | npm install -g npm@latest 106 | ``` 107 | 108 | ## Server not starting 109 | 110 | If the server not starts, try run it manually, to get the exception (From terminal/command prompt): 111 | 112 | ```bash 113 | node ~/.config/mpv/scripts/mpvremote/remoteServer.js # On linux systems 114 | node %APPDATA%/mpv/scripts/mpvremote/remoteServer.js # On Windows from command prompt. 115 | ``` 116 | 117 | If you report server non starting issue copy the output of this command. 118 | 119 | If you get "No socket provided" output the server works fine, so there's something up with the plugin or MPV itself. 120 | 121 | ## Youtube playback issues 122 | 123 | I recommend using [yt-dlp](https://github.com/yt-dlp/yt-dlp) for playing Youtube videos, but if you use youtube-dl: 124 | 125 | - If you can't play Youtube videos then try to update the **youtube-dl** package (as admin): `pip3 install --upgrade youtube-dl` 126 | 127 | ## Common issues on Linux 128 | 129 | yargs requires 12 or newer version of Node.JS so you should update your Node.JS version. For example this error occours on Ubuntu 20.04.3 LTS. 130 | 131 | - [How to update Node.JS](https://askubuntu.com/questions/426750/how-can-i-update-my-nodejs-to-the-latest-version) 132 | 133 | If the server works fine, then there's an issue with MPV itself. Some linux distributions like Debian and MX Linux ships pre-built MPV packages without Javascript support. 134 | 135 | You can check it by using this command: 136 | 137 | ```bash 138 | mpv -v | grep javascript 139 | ``` 140 | 141 | if the output is empty, there's no javascript support. 142 | 143 | Install mujs and [build MPV for yourself](https://github.com/mpv-player/mpv-build) 144 | 145 | ## When you report an issue 146 | 147 | It makes problem solving easier if you provide some info about your environment. 148 | 149 | - Your OS, 150 | - Node.JS version (`node -v`) 151 | - NPM version (`npm -v`) 152 | - MPV version (`mpv -v`) 153 | 154 | # TODO 155 | 156 | - Need better installation scripts for Windows and Linux, where update can be handled easily. (NPM postinstall script maybe) 157 | - Improve API documentation, 158 | - Better installation scripts, 159 | - Make a Youtube video about installing/updating MPV-remote on Windows and Linux 160 | 161 | # Disclaimer 162 | 163 | The app developer DOES NOT promotes piracy! Other apps and modules used by this app may include some level of piracy. 164 | -------------------------------------------------------------------------------- /bin.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* 3 | Launcher came from here. 4 | https://github.com/mrxdst/webtorrent-mpv-hook/blob/master/src/bin.ts 5 | */ 6 | const path = require("path"); 7 | const os = require("os"); 8 | 9 | const pluginDir = path.join(__dirname, "mpvremote"); 10 | const MPVHome = getMPVHome(); 11 | const scriptFolder = path.join(MPVHome, "scripts"); 12 | const scriptOptsFolder = path.join(MPVHome, "script-opts"); 13 | 14 | const target = path.join(__dirname, "remoteServer.js"); 15 | const target1 = path.join(__dirname, "watchlisthandler.js"); 16 | const target2 = path.join(__dirname, "mpvremote.conf"); 17 | const powershellInstaller = path.join(__dirname, "install.ps1"); 18 | const bashInstaller = path.join(__dirname, "install.sh"); 19 | 20 | const link = path.join(scriptFolder, "mpvremote", "remoteServer.js"); 21 | const link1 = path.join(scriptFolder, "mpvremote", "watchlisthandler.js"); 22 | 23 | const readline = require("readline"); 24 | const rl = readline.createInterface({ 25 | input: process.stdin, 26 | output: process.stdout, 27 | }); 28 | 29 | function printInsturctions() { 30 | console.log( 31 | [ 32 | `mpv-remote`, 33 | "", 34 | `${ 35 | os.platform() === "win32" 36 | ? "On Windows you can't create symlink without Administrator privileges!" 37 | : "" 38 | }`, 39 | "First copy mpvremote plugin to your MPV plugins folder:", 40 | "", 41 | ` ${ 42 | os.platform() === "win32" 43 | ? `xcopy /i "${pluginDir}" "${path.join(scriptFolder, "mpvremote")}"` 44 | : `mkdir -p "${scriptFolder}" && cp -r "${pluginDir}" "${scriptFolder}"` 45 | }`, 46 | "", 47 | "You need to symlink the script file to your MPV scripts folder:", 48 | "", 49 | ` ${ 50 | os.platform() === "win32" 51 | ? `mklink "${link}" "${target}"\n or\n New-Item -ItemType SymbolicLink -Path "${link}" -Target "${target}"` 52 | : `ln -s "${target}" "${link}"` 53 | }`, 54 | "Copy default config file by using:", 55 | ` ${ 56 | os.platform() === "win32" 57 | ? `echo f | xcopy /f /y "${target2}" "${scriptOptsFolder}"` 58 | : `mkdir -p "${scriptOptsFolder}" && cp -r "${target2}" "${scriptOptsFolder}"` 59 | }`, 60 | "If you want save media status do this:", 61 | ` ${ 62 | os.platform() === "win32" 63 | ? `mklink "${link1}" "${target1}"\n or\n New-Item -ItemType SymbolicLink -Path "${link1}" -Target "${target1}"` 64 | : `ln -s "${target1}" "${link1}"` 65 | }`, 66 | "Download the Android app here: https://github.com/husudosu/mpv-remote-app/blob/master/android/app/release/app-release.apk", 67 | ].join("\n") 68 | ); 69 | } 70 | 71 | function getMPVHome() { 72 | let mpvHome; 73 | 74 | if (os.platform() === "win32") { 75 | mpvHome = process.env["MPV_HOME"] || "%APPDATA%/mpv"; 76 | } else { 77 | mpvHome = process.env["MPV_HOME"]; 78 | if (!mpvHome) { 79 | const xdgConfigHome = process.env["XDG_CONFIG_HOME"] || "$HOME/.config"; 80 | mpvHome = path.join(xdgConfigHome, "mpv"); 81 | } 82 | } 83 | return mpvHome; 84 | } 85 | 86 | function automatedInstaller() { 87 | // If Win32 check if user runs powershell 88 | if (os.platform() === "win32") { 89 | console.log( 90 | `Open PowerShell as admin and run this command: ${powershellInstaller}` 91 | ); 92 | } else { 93 | console.log(`Run this script: ${bashInstaller}`); 94 | } 95 | } 96 | 97 | rl.question("Would you like use wizzard installer? [Y/N]:", (answer) => { 98 | answer = answer.toUpperCase(); 99 | switch (answer) { 100 | case "Y": 101 | automatedInstaller(); 102 | rl.close(); 103 | break; 104 | case "N": 105 | default: 106 | printInsturctions(); 107 | rl.close(); 108 | break; 109 | } 110 | }); 111 | -------------------------------------------------------------------------------- /collections.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const router = express.Router(); 3 | const { 4 | createCollection, 5 | getCollections, 6 | updateCollection, 7 | deleteCollection, 8 | createCollectionEntry, 9 | deleteCollectionEntry, 10 | NotFoundException, 11 | } = require("./crud"); 12 | 13 | const { settings } = require("./settings"); 14 | 15 | // Base URL: /api/v1/collections 16 | 17 | router.use(function checkUseLocalDB(req, res, next) { 18 | if (!settings.uselocaldb) { 19 | return res.status(400).json({ 20 | error: "mpvremote-uselocaldb disabled!", 21 | }); 22 | } else { 23 | next(); 24 | } 25 | }); 26 | 27 | router.get("/:id?", async (req, res) => { 28 | try { 29 | if (req.params.id) { 30 | const collection = await getCollections(req.params.id); 31 | if (!collection) 32 | return res.status(404).json({ message: "Collection not exists" }); 33 | return res.json(collection); 34 | } else { 35 | return res.json(await getCollections()); 36 | } 37 | } catch (exc) { 38 | console.log(exc); 39 | return res.status(500).json({ message: exc }); 40 | } 41 | }); 42 | 43 | router.post("", async (req, res) => { 44 | // TODO Some validation. 45 | try { 46 | const collection = await createCollection(req.body); 47 | return res.json(collection); 48 | } catch (exc) { 49 | console.log(exc); 50 | return res.status(500).json({ message: exc }); 51 | } 52 | }); 53 | 54 | router.patch("/:collection_id/", async (req, res) => { 55 | try { 56 | return res.json(await updateCollection(req.params.collection_id, req.body)); 57 | } catch (exc) { 58 | if (exc instanceof NotFoundException) 59 | return res.status(404).json({ message: exc.message }); 60 | else { 61 | console.log(exc); 62 | return res.status(500).json({ message: exc }); 63 | } 64 | } 65 | }); 66 | 67 | router.delete("/:collection_id/", async (req, res) => { 68 | try { 69 | const collection_id = req.params.collection_id; 70 | deleteCollection(collection_id); 71 | return res.json({}); 72 | } catch (exc) { 73 | return res.status(500).json({ message: exc }); 74 | } 75 | }); 76 | 77 | router.post("/:collection_id/entries/", async (req, res) => { 78 | try { 79 | const collection_entry = await createCollectionEntry( 80 | req.params.collection_id, 81 | req.body 82 | ); 83 | return res.json(collection_entry); 84 | } catch (exc) { 85 | if (exc instanceof NotFoundException) 86 | return res.status(404).json({ message: exc.message }); 87 | else return res.status(500).json({ message: exc }); 88 | } 89 | }); 90 | 91 | router.delete("/entries/:id", async (req, res) => { 92 | try { 93 | deleteCollectionEntry(req.params.id); 94 | return res.json({}); 95 | } catch (exc) { 96 | if (exc instanceof NotFoundException) 97 | return res.status(404).json({ message: exc.message }); 98 | else return res.status(500).json({ message: exc }); 99 | } 100 | }); 101 | 102 | module.exports = router; 103 | -------------------------------------------------------------------------------- /crud.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const os = require("os"); 3 | 4 | const sqlite3 = require("sqlite3"); 5 | const { open } = require("sqlite"); 6 | const DB_PATH = path.join(getScriptFolder(), "mpvremote", "remote.db"); 7 | const fs = require("fs"); 8 | 9 | let db; 10 | 11 | function NotFoundException(message) { 12 | this.message = message || "Object not found"; 13 | this.name = "NotFoundException"; 14 | } 15 | 16 | function getMPVHome() { 17 | let mpvHome; 18 | 19 | if (os.platform() === "win32") { 20 | mpvHome = 21 | process.env["MPV_HOME"] || 22 | path.join(os.homedir(), "AppData", "Roaming", "mpv"); 23 | } else { 24 | mpvHome = process.env["MPV_HOME"]; 25 | if (!mpvHome) { 26 | const xdgConfigHome = 27 | process.env["XDG_CONFIG_HOME"] || `${os.homedir()}/.config`; 28 | mpvHome = path.join(xdgConfigHome, "mpv"); 29 | } 30 | } 31 | return mpvHome; 32 | } 33 | 34 | // Get scripts folder 35 | function getScriptFolder() { 36 | 37 | return path.join(getMPVHome(), "scripts"); 38 | } 39 | 40 | async function init_tables() { 41 | // Collections 42 | // TYPE Can be: Movies - 1, TVShows - 2, Music - 3 43 | await db.exec( 44 | `CREATE TABLE IF NOT EXISTS collection( 45 | id INTEGER PRIMARY KEY ASC, name TEXT NOT NULL, type INTEGER NOT NULL 46 | )` 47 | ); 48 | 49 | // Collection entry 50 | await db.exec( 51 | `CREATE TABLE IF NOT EXISTS collection_entry( 52 | id INTEGER PRIMARY KEY ASC, 53 | collection_id INTEGER NOT NULL, 54 | path TEXT NOT NULL, 55 | CONSTRAINT fk_collection 56 | FOREIGN KEY (collection_id) 57 | REFERENCES collection(id) 58 | ON DELETE CASCADE 59 | )` 60 | ); 61 | 62 | // Media status 63 | await db.exec( 64 | `CREATE TABLE IF NOT EXISTS mediastatus( 65 | id INTEGER PRIMARY KEY ASC, 66 | directory TEXT, 67 | file_name TEXT NOT NULL, 68 | current_time REAL, 69 | finished INTEGER 70 | )` 71 | ); 72 | } 73 | 74 | async function initDB() { 75 | db = await open({ 76 | filename: DB_PATH, 77 | driver: sqlite3.Database, 78 | }); 79 | await db.get("PRAGMA foreign_keys=on;"); 80 | await init_tables(); 81 | } 82 | 83 | async function getMediastatusEntries(filepath = null, directory = null) { 84 | /* 85 | filepath: Gets entry for a single file path 86 | directory: Gets entries for a directory 87 | */ 88 | try { 89 | if (filepath != null) { 90 | // If last char is path.sep remove it 91 | if (filepath[filepath.length - 1] == path.sep) 92 | filepath = filepath.slice(0, -1); 93 | let spl = filepath.split(path.sep); 94 | const fileName = spl[spl.length - 1]; 95 | spl.pop(); 96 | 97 | const directory = spl.join(path.sep); 98 | return await db.get( 99 | "SELECT * FROM mediastatus WHERE directory=? AND file_name=? ORDER BY file_name", 100 | [directory, fileName] 101 | ); 102 | } else if (directory != null) { 103 | // directory = directory.split(path.sep); 104 | if (directory[directory.length - 1] == path.sep) 105 | directory = directory.slice(0, -1); 106 | const entries = await db.all( 107 | "SELECT * FROM mediastatus WHERE directory=? ORDER BY file_name", 108 | [directory] 109 | ); 110 | return entries; 111 | } else { 112 | return await db.all("SELECT * FROM mediastatus"); 113 | } 114 | } catch (exc) { 115 | console.log(exc); 116 | } 117 | } 118 | 119 | async function createMediaStatusEntry(filepath, time, finished) { 120 | try { 121 | const statusEntry = await getMediastatusEntries(filepath); 122 | 123 | let spl = filepath.split(path.sep); 124 | const fileName = spl[spl.length - 1]; 125 | spl.pop(); 126 | 127 | const directory = spl.join(path.sep); 128 | 129 | // Update status 130 | if (statusEntry) { 131 | await db.run( 132 | "UPDATE mediastatus set current_time=?, finished=? WHERE directory=? AND file_name=?", 133 | [time, finished, directory, fileName] 134 | ); 135 | } else { 136 | await db.run( 137 | "INSERT INTO mediastatus (current_time, finished, directory, file_name) VALUES (?, ?, ?, ?)", 138 | [time, finished, directory, fileName] 139 | ); 140 | } 141 | } catch (exc) { 142 | console.log(exc); 143 | } 144 | } 145 | 146 | async function addMediaStatusEntry(filepath, time, percentPos) { 147 | /* 148 | If percentPos 90% consider file finished 149 | If <= 5% don't save status to database. 150 | */ 151 | let finished = 0; 152 | percentPos = parseFloat(percentPos); 153 | time = parseFloat(time); 154 | 155 | if (percentPos >= 90) finished = 1; 156 | else if (percentPos <= 5) return; 157 | 158 | await createMediaStatusEntry(filepath, time, finished); 159 | // Check if entry already exists 160 | } 161 | 162 | /* 163 | *** 164 | COLLECTIONS CRUD 165 | *** 166 | */ 167 | function validateEntry(data) { 168 | if (!fs.existsSync(data.path)) { 169 | throw new NotFoundException(`${data.path} not exists.`); 170 | } 171 | } 172 | 173 | async function createCollection(data) { 174 | // Validate entry path 175 | // if (data.paths && data.paths.length > 0) { 176 | // data.paths.forEach((el) => { 177 | // validateEntry(el); 178 | // }); 179 | // } 180 | 181 | const dbres = await db.run( 182 | "INSERT INTO collection (name, type) VALUES (?, ?)", 183 | data.name, 184 | data.type || 1 185 | ); 186 | 187 | // Get new object 188 | let collection = await db.get( 189 | "SELECT * FROM collection WHERE id=?", 190 | dbres.lastID 191 | ); 192 | collection.paths = []; 193 | if (data.paths && data.paths.length > 0) { 194 | data.paths.forEach(async (element) => { 195 | const entry = await createCollectionEntry(collection.id, element); 196 | collection.paths.push(entry); 197 | }); 198 | } 199 | 200 | return collection; 201 | } 202 | 203 | async function getCollections(id = null) { 204 | if (id) { 205 | let collection = await db.get("SELECT * FROM collection WHERE id=?", id); 206 | 207 | if (collection) { 208 | collection.paths = await getCollectionEntries(collection.id); 209 | return collection; 210 | } else { 211 | return null; 212 | } 213 | } else { 214 | let collections = await db.all("SELECT * FROM collection"); 215 | return collections; 216 | } 217 | } 218 | 219 | async function updateCollection(id, data) { 220 | // Validate entry paths. 221 | // TODO: Rollbacking on validation error would be better. 222 | // if (data.paths && data.paths.length > 0) { 223 | // data.paths.forEach((el) => { 224 | // validateEntry(el); 225 | // }); 226 | // } 227 | 228 | let collection = await db.get("SELECT * FROM collection WHERE id=?", id); 229 | if (!collection) throw new NotFoundException("Collection not exists."); 230 | // Update collection 231 | await db.run( 232 | "UPDATE collection SET name=COALESCE(?, name), type=COALESCE(?, type) WHERE id=?", 233 | [data.name, data.type, id] 234 | ); 235 | // Update paths 236 | if (data.paths) { 237 | data.paths.forEach(async (element) => { 238 | // Add collection entry 239 | if (!element.id) await createCollectionEntry(collection.id, element); 240 | // Update path 241 | else await updateCollectionEntry(element.id, element); 242 | }); 243 | } 244 | return await getCollections(id); 245 | } 246 | 247 | async function deleteCollection(id) { 248 | const collection = getCollections(id); 249 | if (!collection) throw new NotFoundException("Collection not exists."); 250 | await db.run("DELETE FROM collection WHERE id=?", id); 251 | } 252 | 253 | /* 254 | *** 255 | COLLECTION ENTIRES CRUD 256 | *** 257 | */ 258 | async function createCollectionEntry(collection_id, data) { 259 | // Check if collection exists 260 | const collectionExists = await getCollections(collection_id); 261 | if (!collectionExists) throw new NotFoundException("Collection not exists."); 262 | 263 | const dbres = await db.run( 264 | "INSERT INTO collection_entry (collection_id, path) VALUES (?, ?)", 265 | collection_id, 266 | data.path 267 | ); 268 | const collection_entry = await db.get( 269 | "SELECT * FROM collection_entry WHERE id=?", 270 | dbres.lastID 271 | ); 272 | return collection_entry; 273 | } 274 | 275 | async function getCollectionEntries(collection_id) { 276 | return await db.all( 277 | "SELECT * FROM collection_entry WHERE collection_id=?", 278 | collection_id 279 | ); 280 | } 281 | 282 | async function getCollectionEntry(id) { 283 | return await db.get("SELECT * FROM collection_entry WHERE id=?", id); 284 | } 285 | 286 | async function updateCollectionEntry(id, data) { 287 | const collectionEntry = await getCollectionEntry(id); 288 | if (!collectionEntry) 289 | throw new NotFoundException("Collection entry not exists."); 290 | await db.run( 291 | "UPDATE collection_entry SET path=COALESCE(?, path) WHERE id=?", 292 | [data.path, id] 293 | ); 294 | 295 | return await getCollectionEntry(id); 296 | } 297 | 298 | async function deleteCollectionEntry(id) { 299 | const collectionEntry = await getCollectionEntry(id); 300 | if (!collectionEntry) 301 | throw new NotFoundException("Collection entry not exists."); 302 | await db.run("DELETE FROM collection_entry WHERE id=?", id); 303 | } 304 | 305 | // Exceptions 306 | exports.NotFoundException = NotFoundException; 307 | 308 | exports.initDB = initDB; 309 | // Media status entries 310 | exports.addMediaStatusEntry = addMediaStatusEntry; 311 | exports.getMediastatusEntries = getMediastatusEntries; 312 | 313 | // Collections 314 | exports.createCollection = createCollection; 315 | exports.getCollections = getCollections; 316 | exports.updateCollection = updateCollection; 317 | exports.deleteCollection = deleteCollection; 318 | 319 | // Collection Entries 320 | exports.createCollectionEntry = createCollectionEntry; 321 | exports.getCollectionEntries = getCollectionEntries; 322 | exports.deleteCollectionEntry = deleteCollectionEntry; 323 | exports.updateCollection = updateCollection; 324 | 325 | 326 | // Get script folder 327 | exports.getScriptFolder = getScriptFolder; 328 | // Get mpv home folder 329 | exports.getMPVHome = getMPVHome; 330 | -------------------------------------------------------------------------------- /filebrowser.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const fs_async = require("fs").promises; 3 | const path = require("path"); 4 | 5 | const express = require("express"); 6 | const router = express.Router(); 7 | const nodeDiskInfo = require("node-disk-info"); 8 | 9 | const { getMediastatusEntries, getCollections } = require("./crud"); 10 | const { settings } = require("./settings"); 11 | const FILE_FORMATS = require("./fileformats").FILE_FORMATS; 12 | 13 | function detectFileType(extension) { 14 | extension = extension.toLowerCase(); 15 | 16 | if (FILE_FORMATS.video.includes(extension)) { 17 | return "video"; 18 | } else if (FILE_FORMATS.audio.includes(extension)) { 19 | return "audio"; 20 | } else if (FILE_FORMATS.subtitle.includes(extension)) { 21 | return "subtitle"; 22 | } else { 23 | return "file"; 24 | } 25 | } 26 | 27 | async function getDirectoryContents(qpath) { 28 | // TODO Handle exceptions 29 | let content = []; 30 | 31 | // Add path seperator to qpath end 32 | /* 33 | Interesting because C: (System partition) needs a path seperator to work correctly, 34 | but for my network drives works without path sep. 35 | */ 36 | if (qpath[qpath.length - 1] != path.sep) qpath += path.sep; 37 | let mediaStatus = []; 38 | 39 | if (settings.uselocaldb) 40 | mediaStatus = await getMediastatusEntries(null, qpath); 41 | 42 | for (const item of await fs_async.readdir(qpath)) { 43 | try { 44 | if (fs.lstatSync(path.join(qpath, item)).isDirectory()) { 45 | let entry = { 46 | priority: 1, 47 | type: "directory", 48 | name: item, 49 | fullPath: path.join(qpath, item), 50 | }; 51 | entry.lastModified = await fs_async 52 | .stat(entry.fullPath) 53 | .then((stat) => stat.mtime) 54 | .catch(() => null); 55 | 56 | content.push(entry); 57 | } else { 58 | let fileType = detectFileType(path.extname(item)); 59 | // Render only media, sub types. 60 | if (fileType !== "file") { 61 | let entry = { 62 | priority: 2, 63 | type: fileType, 64 | name: item, 65 | fullPath: path.join(qpath, item), 66 | }; 67 | entry.lastModified = await fs_async 68 | .stat(entry.fullPath) 69 | .then((stat) => stat.mtime) 70 | .catch(() => null); 71 | if (settings.uselocaldb) 72 | entry.mediaStatus = mediaStatus.find((el) => el.file_name == item); 73 | 74 | content.push(entry); 75 | } 76 | } 77 | } catch (exc) { 78 | console.log(exc); 79 | } 80 | } 81 | return content; 82 | } 83 | 84 | router.get("/api/v1/drives", async (req, res) => { 85 | try { 86 | if (settings.unsafefilebrowsing) { 87 | let disks = await nodeDiskInfo.getDiskInfo(); 88 | // ignore snap, flatpak stuff linux 89 | disks = disks.filter( 90 | (disk) => 91 | !disk._mounted.includes("snap") && !disk._mounted.includes("flatpak") 92 | ); 93 | disks = disks.map((disk) => { 94 | return { 95 | path: disk._mounted, 96 | }; 97 | }); 98 | return res.json(disks); 99 | } else 100 | return res 101 | .status(403) 102 | .json({ message: "mpvremote-unsafefilebrowsing disabled!" }); 103 | } catch (e) { 104 | console.log(e); 105 | res.status(500).json({ message: exc }); 106 | } 107 | }); 108 | 109 | router.get("/api/v1/filebrowser/paths", async (req, res) => { 110 | try { 111 | return res.json(settings.filebrowserPaths); 112 | } catch (exc) { 113 | console.log(exc); 114 | return res.status(500).json({ message: exc }); 115 | } 116 | }); 117 | 118 | router.post("/api/v1/filebrowser/browse", async (req, res) => { 119 | try { 120 | let p = req.body.path; 121 | let collectionId = req.body.collection_id; 122 | 123 | // Find FILEBROWSER_PATH entry 124 | if (!p && !collectionId) 125 | return res 126 | .status(400) 127 | .json({ message: "path or collection id missing from request data!" }); 128 | 129 | let retval = {}; 130 | if (p) { 131 | // If unsafe filebrowsing disabled we've to check FILEBROWSER_PATHS 132 | if (!settings.unsafefilebrowsing) { 133 | // Security: Protect against path-traversal attack by resolving synlinks and .. 134 | p = await fs_async.realpath(p); 135 | let fbe = settings.filebrowserPaths.find((el) => { 136 | return p.startsWith(el.path); 137 | }); 138 | 139 | if (!fbe) 140 | return res 141 | .status(400) 142 | .send({ message: `Path not exists on filebrowserpaths: ${p}` }); 143 | } 144 | 145 | if (!fs.existsSync(p)) 146 | return res.status(404).send({ message: "Path not exists!" }); 147 | // Get files from directory 148 | 149 | retval.content = await getDirectoryContents(p); 150 | retval.dirname = path.basename(p); 151 | retval.prevDir = path.resolve(p, ".."); 152 | retval.cwd = p; 153 | } else if (collectionId) { 154 | // Get collection contents if local database enabled! 155 | if (!settings.uselocaldb) 156 | return res 157 | .status(400) 158 | .send({ message: "mpvremote-uselocaldb is disabled!" }); 159 | 160 | let collection = await getCollections(collectionId); 161 | if (!collection) return res.status(404).send("Collection not exists!"); 162 | retval.content = []; 163 | retval.errors = []; 164 | await Promise.all( 165 | collection.paths.map(async (item) => { 166 | // Check if exists on filebrowserpaths 167 | if (!settings.unsafefilebrowsing) { 168 | let fbe = settings.filebrowserPaths.find((el) => { 169 | return item.path.includes(el.path); 170 | }); 171 | if (!fbe) { 172 | console.log(`Not exists on filebrowserpaths: ${item.path}`); 173 | retval.errors.push( 174 | `Not exists on filebrowserpaths: ${item.path}` 175 | ); 176 | } 177 | } else if (fs.existsSync(item.path)) { 178 | const dir = await getDirectoryContents(item.path); 179 | retval.content = [...retval.content, ...dir]; 180 | } else { 181 | console.log(`Path not exists ${item.path}`); 182 | } 183 | }) 184 | ); 185 | retval.collection_id = collectionId; 186 | } 187 | 188 | // Sort content firstly by priority and alphabet order. 189 | retval.content.sort((a, b) => { 190 | return ( 191 | a.priority - b.priority || 192 | a.name.toLowerCase().localeCompare(b.name.toLowerCase()) 193 | ); 194 | }); 195 | 196 | return res.json(retval); 197 | } catch (exc) { 198 | console.log(exc); 199 | return res.status(500).json({ message: exc }); 200 | } 201 | }); 202 | 203 | exports.getDirectoryContents = getDirectoryContents; 204 | exports.detectFileType = detectFileType; 205 | module.exports = router; 206 | -------------------------------------------------------------------------------- /fileformats.js: -------------------------------------------------------------------------------- 1 | const FILE_FORMATS = { 2 | audio: [ 3 | ".ac3", 4 | ".a52", 5 | ".eac3", 6 | ".mlp", 7 | ".dts", 8 | ".dts-hd", 9 | ".dtshd", 10 | ".true-hd", 11 | ".thd", 12 | ".truehd", 13 | ".thd+ac3", 14 | ".tta", 15 | ".pcm", 16 | ".wav", 17 | ".aiff", 18 | ".aif", 19 | ".aifc", 20 | ".amr", 21 | ".awb", 22 | ".au", 23 | ".snd", 24 | ".lpcm", 25 | ".ape", 26 | ".wv", 27 | ".shn", 28 | ".adts", 29 | ".adt", 30 | ".mpa", 31 | ".m1a", 32 | ".m2a", 33 | ".mp1", 34 | ".mp2", 35 | ".mp3", 36 | ".m4a", 37 | ".aac", 38 | ".flac", 39 | ".oga", 40 | ".ogg", 41 | ".opus", 42 | ".spx", 43 | ".mka", 44 | ".weba", 45 | ".wma", 46 | ".f4a", 47 | ".ra", 48 | ".ram", 49 | ".3ga", 50 | ".3ga2", 51 | ".ay", 52 | ".gbs", 53 | ".gym", 54 | ".hes", 55 | ".kss", 56 | ".nsf", 57 | ".nsfe", 58 | ".sap", 59 | ".spc", 60 | ".vgm", 61 | ".vgz", 62 | ".cue", 63 | ], 64 | video: [ 65 | ".yuv", 66 | ".y4m", 67 | ".m2ts", 68 | ".m2t", 69 | ".mts", 70 | ".mtv", 71 | ".ts", 72 | ".tsv", 73 | ".tsa", 74 | ".tts", 75 | ".trp", 76 | ".mpeg", 77 | ".mpg", 78 | ".mpe", 79 | ".mpeg2", 80 | ".m1v", 81 | ".m2v", 82 | ".mp2v", 83 | ".mpv", 84 | ".mpv2", 85 | ".mod", 86 | ".tod", 87 | ".vob", 88 | ".vro", 89 | ".evob", 90 | ".evo", 91 | ".mpeg4", 92 | ".m4v", 93 | ".mp4", 94 | ".mp4v", 95 | ".mpg4", 96 | ".h264", 97 | ".avc", 98 | ".x264", 99 | ".264", 100 | ".hevc", 101 | ".h265", 102 | ".x265", 103 | ".265", 104 | ".ogv", 105 | ".ogm", 106 | ".ogx", 107 | ".mkv", 108 | ".mk3d", 109 | ".webm", 110 | ".avi", 111 | ".vfw", 112 | ".divx", 113 | ".3iv", 114 | ".xvid", 115 | ".nut", 116 | ".flic", 117 | ".fli", 118 | ".flc", 119 | ".nsv", 120 | ".gxf", 121 | ".mxf", 122 | ".wm", 123 | ".wmv", 124 | ".asf", 125 | ".dvr-ms", 126 | ".dvr", 127 | ".wt", 128 | ".dv", 129 | ".hdv", 130 | ".flv", 131 | ".f4v", 132 | ".qt", 133 | ".mov", 134 | ".hdmov", 135 | ".rm", 136 | ".rmvb", 137 | ".3gpp", 138 | ".3gp", 139 | ".3gp2", 140 | ".3g2", 141 | ".iso", 142 | ], 143 | playlist: [".m3u", ".m3u8", ".pls"], 144 | subtitle: [ 145 | ".aqt", 146 | ".cvd", 147 | ".dks", 148 | ".jss", 149 | ".sub", 150 | ".ttxt", 151 | ".mpl", 152 | ".sub", 153 | ".pjs", 154 | ".psb", 155 | ".rt", 156 | ".smi", 157 | ".ssf", 158 | ".srt", 159 | ".ssa", 160 | ".ass", 161 | ".sub", 162 | ".svcd", 163 | ".usf", 164 | ".sub", 165 | ".idx", 166 | ".txt", 167 | ], 168 | }; 169 | 170 | exports.FILE_FORMATS = FILE_FORMATS; 171 | -------------------------------------------------------------------------------- /install.ps1: -------------------------------------------------------------------------------- 1 | # Automated installer for mpvremote Windows version 2 | 3 | $MPV_PATH = "$env:APPDATA/mpv"; 4 | if ($Env:MPV_HOME){ 5 | $MPV_PATH = $Env:MPV_HOME 6 | } 7 | 8 | # Script path for older Powershell versions. 9 | $scriptPath = split-path -parent $MyInvocation.MyCommand.Definition 10 | 11 | $pluginPath = Join-Path -Path "$scriptPath" -ChildPath "mpvremote" 12 | $scriptOptsPath = Join-Path -Path "$scriptPath" -ChildPath "mpvremote.conf" 13 | $mainPath = Join-Path -Path "$scriptPath" -ChildPath "remoteServer.js" 14 | $watchlistHandlerPath = Join-Path -Path "$scriptPath" -ChildPath "watchlisthandler.js" 15 | 16 | $destPluginPath = Join-Path -Path "$MPV_PATH" -ChildPath "\scripts\mpvremote" 17 | $destScriptOptsPath = Join-Path "$MPV_PATH" -ChildPath "\script-opts\mpvremote.conf" 18 | $destMainPath = Join-Path -Path "$destPluginPath" -ChildPath "remoteServer.js" 19 | $destWatchlistHandlerPath = Join-Path -Path "$destPluginPath" -ChildPath "watchlisthandler.js" 20 | 21 | function Check-IsElevated 22 | { 23 | $id = [System.Security.Principal.WindowsIdentity]::GetCurrent() 24 | $p = New-Object System.Security.Principal.WindowsPrincipal($id) 25 | if ($p.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)) 26 | { Write-Output $true } 27 | else 28 | { Write-Output $false } 29 | } 30 | 31 | 32 | if (-NOT(Check-IsElevated)){ 33 | "For creating symbolic links, you need run this script as administrator!" 34 | Exit 35 | } 36 | 37 | # Copy base plugin directory 38 | xcopy /i $pluginPath $destPluginPath 39 | # Symlink for remoteServer.js 40 | Remove-Item $destMainPath -ErrorAction Ignore 41 | New-Item -ItemType SymbolicLink -Path $destMainPath -Target $mainPath 42 | 43 | $shouldUseWatchlist = Read-Host "Use watchlist handler? [Y/N](Default:Y)" 44 | if ($shouldUseWatchlist -ne "N"){ 45 | Remove-Item $destWatchlistHandlerPath -ErrorAction Ignore 46 | New-Item -ItemType SymbolicLink -Path $destWatchlistHandlerPath -Target $watchlistHandlerPath 47 | } 48 | 49 | $shouldCopyConfig = Read-Host "Copy default config? [Y/N](Default:Y)" 50 | 51 | if ($shouldCopyConfig.ToUpper() -ne "N"){ 52 | echo "f" | xcopy /f /y $scriptOptsPath $destscriptOptsPath 53 | } 54 | 55 | 56 | "Wizzard done. MPV remote should launch when running MPV" 57 | "Download the Android app here: https://github.com/husudosu/mpv-remote-app/releases/latest" -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Automated installer for mpvremote Linux version 3 | # Get MPV Path 4 | MPV_PATH="" 5 | if [[ -z "${MPV_HOME}" ]]; then 6 | if [[ -z "${XDG_CONFIG_HOME}" ]]; then 7 | MPV_PATH="${HOME}/.config/mpv" 8 | else 9 | MPV_PATH="${XDG_CONFIG_HOME}/mpv" 10 | fi 11 | else 12 | MPV_PATH=${MPV_HOME} 13 | fi 14 | 15 | bashScriptPath="$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 16 | scriptOptsFolder="$MPV_PATH/script-opts" 17 | mpvScriptFolder="$MPV_PATH/scripts" 18 | 19 | pluginPath="$bashScriptPath/mpvremote" 20 | scriptOptsPath="$bashScriptPath/mpvremote.conf" 21 | mainPath="$bashScriptPath/remoteServer.js" 22 | watchlistHandlerPath="$bashScriptPath/watchlisthandler.js" 23 | 24 | destPluginPath="$MPV_PATH/scripts/mpvremote" 25 | destScriptOptsPath="$scriptOptsFolder/mpvremote.conf" 26 | destMainPath="$destPluginPath/remoteServer.js" 27 | destWatchlistHandlerPath="$destPluginPath/watchlisthandler.js" 28 | 29 | mkdir -p "$mpvScriptFolder" && cp -r "$pluginPath" "$mpvScriptFolder" 30 | ln -sf "$mainPath" "$destMainPath" 31 | 32 | copyOpts(){ 33 | mkdir -p "$scriptOptsFolder" && cp -r "$scriptOptsPath" "$scriptOptsFolder" 34 | } 35 | 36 | watchlistHandler(){ 37 | ln -sf "$watchlistHandlerPath" "$destWatchlistHandlerPath" 38 | } 39 | 40 | while true; do 41 | read -p "Use watchlist handler? [Y/N](Default: Y)" Yn 42 | case $Yn in 43 | [Yy]* ) watchlistHandler;break;; 44 | [Nn]* ) break;; 45 | "") watchlistHandler; break;; 46 | * ) echo "Please answer yes or no.";; 47 | esac 48 | done 49 | 50 | while true; do 51 | read -p "Copy default config? [Y/N](Default: Y)" Yn 52 | case $Yn in 53 | [Yy]* ) copyOpts; break;; 54 | [Nn]* ) break;; 55 | "") copyOpts; break;; 56 | * ) echo "Please answer yes or no.";; 57 | esac 58 | done 59 | 60 | echo "Wizzard done. MPV remote should launch when running MPV" 61 | echo "Download the Android app here: https://github.com/husudosu/mpv-remote-app/releases/latest" -------------------------------------------------------------------------------- /mpvremote.conf: -------------------------------------------------------------------------------- 1 | uselocaldb=1 2 | webport=8000 3 | webportrangeend=8004 4 | unsafefilebrowsing=1 5 | verbose=0 6 | osd-messages=1 -------------------------------------------------------------------------------- /mpvremote/main.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | // This is the plugin file for MPV 3 | 4 | /*Path handling, script idea came from webtorrent plugin : 5 | https://github.com/mrxdst/webtorrent-mpv-hook 6 | ES5 syntax works only. 7 | */ 8 | 9 | var options = { 10 | uselocaldb: 1, 11 | filebrowserpaths: "", 12 | webport: 8000, 13 | webportrangeend: 8005, 14 | address: "", 15 | unsafefilebrowsing: 1, 16 | verbose: 0, 17 | "osd-messages": 1, 18 | }; 19 | 20 | mp.options.read_options(options, "mpvremote"); 21 | 22 | var platform = mp.utils.getenv("windir") ? "win32" : "unix"; 23 | var tempdir = mp.utils.getenv("TEMP") || mp.utils.getenv("TMP") || "/tmp"; // Temp dir 24 | var pathsep = platform === "win32" ? "\\" : "/"; 25 | 26 | function createMPVSocketFilename() { 27 | var i = 0; 28 | var fname = ""; 29 | while (true) { 30 | fname = 31 | platform === "win32" 32 | ? "\\\\.\\pipe\\mpvremote" + i 33 | : "/tmp/mpvremote" + i; 34 | i++; 35 | if (!mp.utils.file_info(fname)) break; 36 | } 37 | return fname; 38 | } 39 | 40 | function getMPVSocket() { 41 | var socketName = mp.get_property("input-ipc-server"); 42 | 43 | if (!socketName) { 44 | var fname = createMPVSocketFilename(); 45 | 46 | mp.set_property("input-ipc-server", fname); 47 | // Check socket 48 | socketName = mp.get_property("input-ipc-server"); 49 | } 50 | 51 | // TODO raise error if socket still not exists! 52 | return socketName; 53 | } 54 | 55 | function getScriptPath(filename) { 56 | var script = mp.get_script_file().replace(/\\/g, "\\\\"); 57 | var p = mp.command_native({ 58 | name: "subprocess", 59 | args: ["node", "-p", "require('fs').realpathSync('" + script + "')"], 60 | playback_only: false, 61 | capture_stdout: true, 62 | }); 63 | var s = p.stdout.split(pathsep); 64 | s[s.length - 1] = filename; 65 | return s.join(pathsep); 66 | } 67 | 68 | var scriptPath = getScriptPath("remoteServer.js"); 69 | var watchlistHandlerPath = getScriptPath("watchlisthandler.js"); 70 | 71 | var socketName = getMPVSocket(); 72 | 73 | var serverArgs = [ 74 | "node", 75 | scriptPath, 76 | socketName, 77 | "-p " + options.webport, 78 | "-e " + options.webportrangeend, 79 | ]; 80 | 81 | if (options.address) { 82 | serverArgs.push("--address"); 83 | serverArgs.push(options.address); 84 | } 85 | if (options.verbose) serverArgs.push("--verbose"); 86 | if (options.uselocaldb) serverArgs.push("--uselocaldb"); 87 | if (options.filebrowserpaths.length > 0) { 88 | var pathsArr = options.filebrowserpaths.split("';'"); 89 | serverArgs.push("--filebrowserpaths"); 90 | for (var i = 0; i < pathsArr.length; i++) { 91 | serverArgs.push(pathsArr[i]); 92 | } 93 | } 94 | if (options.unsafefilebrowsing) serverArgs.push("--unsafefilebrowsing"); 95 | if (options["osd-messages"]) serverArgs.push("--osd-messages"); 96 | 97 | mp.command_native_async( 98 | { 99 | name: "subprocess", 100 | args: serverArgs, 101 | playback_only: false, 102 | capture_stderr: true, 103 | }, 104 | function (success, result, error) { 105 | mp.msg.info(JSON.stringify(success)); 106 | mp.msg.info(JSON.stringify(result)); 107 | mp.msg.info(JSON.stringify(error)); 108 | } 109 | ); 110 | 111 | function setFileLocalOptions(options) { 112 | for (var key in options) { 113 | if (options.hasOwnProperty(key)) mp.set_property(key, options[key]); 114 | } 115 | } 116 | /* For handling file-local-option 117 | Currently storing file-local-options via file-local-options.txt 118 | because have to get file-local-options before the file fully loaded. 119 | */ 120 | mp.add_hook("on_load", 50, function () { 121 | try { 122 | /* 123 | JSON structure should be something like this: 124 | { 125 | "filename1": {"http-header-fields": ["test: a", "test1: b"]}, 126 | "filename2": {"http-header-fields": ["test: a", "test1: b"]} 127 | } 128 | */ 129 | mp.set_property("force-media-title", ""); 130 | // var scriptDir = mp.get_script_directory(); 131 | var fileName = mp.get_property("path"); 132 | var p = tempdir + "/" + "file-local-options.txt"; 133 | var fileLocalOptions = mp.utils.read_file(p); 134 | fileLocalOptions = JSON.parse(fileLocalOptions); 135 | 136 | // Find filename in the file-local-options 137 | for (var key in fileLocalOptions) { 138 | if (key === fileName) setFileLocalOptions(fileLocalOptions[key]); 139 | } 140 | } catch (exc) { 141 | mp.msg.info(exc); 142 | } 143 | }); 144 | 145 | // On unload MPV, need this for saving playbacktime to database 146 | if (options.uselocaldb) { 147 | mp.add_hook("on_unload", 50, function () { 148 | var currentPlaybackTime = mp.get_property("playback-time"); 149 | var currentFilename = mp.get_property("path"); 150 | var currentPercentPos = mp.get_property("percent-pos"); 151 | 152 | /* Calling binary, 153 | not the ideal solution, but I can't find any information regarding hook supporting on JSON-IPC. 154 | Fetch API not supported by MuJS 155 | */ 156 | mp.command_native_async({ 157 | name: "subprocess", 158 | args: [ 159 | "node", 160 | watchlistHandlerPath, 161 | currentFilename, 162 | currentPlaybackTime, 163 | currentPercentPos, 164 | ], 165 | playback_only: false, 166 | capture_stderr: true, 167 | }); 168 | mp.msg.info("Mediastatus updated"); 169 | }); 170 | } 171 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mpv-remote", 3 | "version": "1.0.7", 4 | "description": "MPV remote control", 5 | "main": "./remote.socketio.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "bin": "./bin.js", 10 | "author": "Ferenc Nánási", 11 | "dependencies": { 12 | "cors": "^2.8.5", 13 | "express": "^4.17.1", 14 | "node-disk-info": "^1.3.0", 15 | "node-mpv": "^2.0.0-beta.2", 16 | "portfinder": "^1.0.28", 17 | "sqlite": "^4.0.23", 18 | "sqlite3": "^5.0.2", 19 | "yargs": "^17.2.1" 20 | }, 21 | "type": "commonjs", 22 | "keywords": [ 23 | "mpv", 24 | "Audio", 25 | "Video", 26 | "Stream", 27 | "multimedia", 28 | "Remote" 29 | ], 30 | "license": "MIT", 31 | "homepage": "https://github.com/husudosu/mpv-remote-node" 32 | } 33 | -------------------------------------------------------------------------------- /postman/MPV Remote.postman_collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "_postman_id": "f303aa5d-e14d-4fb1-99f7-c65c5e50badf", 4 | "name": "MPV Remote", 5 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" 6 | }, 7 | "item": [ 8 | { 9 | "name": "Status", 10 | "item": [ 11 | { 12 | "name": "MPV Info", 13 | "request": { 14 | "method": "GET", 15 | "header": [], 16 | "url": { 17 | "raw": "{{baseURL}}/mpvinfo", 18 | "host": [ 19 | "{{baseURL}}" 20 | ], 21 | "path": [ 22 | "mpvinfo" 23 | ] 24 | } 25 | }, 26 | "response": [ 27 | { 28 | "name": "MPV Info", 29 | "originalRequest": { 30 | "method": "GET", 31 | "header": [], 32 | "url": { 33 | "raw": "{{baseURL}}/mpvinfo", 34 | "host": [ 35 | "{{baseURL}}" 36 | ], 37 | "path": [ 38 | "mpvinfo" 39 | ] 40 | } 41 | }, 42 | "_postman_previewlanguage": "json", 43 | "header": null, 44 | "cookie": [], 45 | "body": "{\r\n \"ffmpeg-version\": \"git-2021-10-10-b4d665bf6\",\r\n \"mpv-version\": \"mpv 0.33.0-360-gb3f3c3fec0\",\r\n \"libass-version\": 22028288,\r\n \"mpvremoteConfig\": {\r\n \"_\": [\r\n \"\\\\\\\\.\\\\pipe\\\\mpvremote\"\r\n ],\r\n \"p\": 8000,\r\n \"webport\": 8000,\r\n \"uselocaldb\": true,\r\n \"unsafefilebrowsing\": true,\r\n \"$0\": \"AppData\\\\Roaming\\\\mpv\\\\scripts\\\\mpvremote\\\\remoteServer.js\"\r\n }\r\n}" 46 | } 47 | ] 48 | }, 49 | { 50 | "name": "Playback status", 51 | "request": { 52 | "method": "GET", 53 | "header": [], 54 | "url": { 55 | "raw": "{{baseURL}}/status", 56 | "host": [ 57 | "{{baseURL}}" 58 | ], 59 | "path": [ 60 | "status" 61 | ] 62 | } 63 | }, 64 | "response": [ 65 | { 66 | "name": "No active playback", 67 | "originalRequest": { 68 | "method": "GET", 69 | "header": [], 70 | "url": { 71 | "raw": "{{baseURL}}/status", 72 | "host": [ 73 | "{{baseURL}}" 74 | ], 75 | "path": [ 76 | "status" 77 | ] 78 | } 79 | }, 80 | "status": "OK", 81 | "code": 200, 82 | "_postman_previewlanguage": "json", 83 | "header": [ 84 | { 85 | "key": "X-Powered-By", 86 | "value": "Express" 87 | }, 88 | { 89 | "key": "Access-Control-Allow-Origin", 90 | "value": "*" 91 | }, 92 | { 93 | "key": "Content-Type", 94 | "value": "application/json; charset=utf-8" 95 | }, 96 | { 97 | "key": "Content-Length", 98 | "value": "300" 99 | }, 100 | { 101 | "key": "ETag", 102 | "value": "W/\"12c-EqkziURYVn/MlxExvXBUS8Tj3mg\"" 103 | }, 104 | { 105 | "key": "Date", 106 | "value": "Sat, 06 Nov 2021 08:18:38 GMT" 107 | }, 108 | { 109 | "key": "Connection", 110 | "value": "keep-alive" 111 | }, 112 | { 113 | "key": "Keep-Alive", 114 | "value": "timeout=5" 115 | } 116 | ], 117 | "cookie": [], 118 | "body": "{\n \"pause\": false,\n \"mute\": false,\n \"filename\": null,\n \"duration\": 0,\n \"position\": 0,\n \"remaining\": 0,\n \"media-title\": null,\n \"playlist\": [],\n \"chapter\": 0,\n \"chapter-list\": [],\n \"volume\": 100,\n \"fullscreen\": false,\n \"speed\": 1,\n \"sub-delay\": 0,\n \"sub-visibility\": true,\n \"track-list\": [],\n \"audio-delay\": 0,\n \"sub-font-size\": 55,\n \"sub-ass-override\": \"no\"\n}" 119 | }, 120 | { 121 | "name": "Active playback", 122 | "originalRequest": { 123 | "method": "GET", 124 | "header": [], 125 | "url": { 126 | "raw": "{{baseURL}}/status", 127 | "host": [ 128 | "{{baseURL}}" 129 | ], 130 | "path": [ 131 | "status" 132 | ] 133 | } 134 | }, 135 | "status": "OK", 136 | "code": 200, 137 | "_postman_previewlanguage": "json", 138 | "header": [ 139 | { 140 | "key": "X-Powered-By", 141 | "value": "Express" 142 | }, 143 | { 144 | "key": "Access-Control-Allow-Origin", 145 | "value": "*" 146 | }, 147 | { 148 | "key": "Content-Type", 149 | "value": "application/json; charset=utf-8" 150 | }, 151 | { 152 | "key": "Content-Length", 153 | "value": "1773" 154 | }, 155 | { 156 | "key": "ETag", 157 | "value": "W/\"6ed-XFDKN7pIkjXe/MozYKQiDZDb9SU\"" 158 | }, 159 | { 160 | "key": "Date", 161 | "value": "Sat, 06 Nov 2021 08:26:18 GMT" 162 | }, 163 | { 164 | "key": "Connection", 165 | "value": "keep-alive" 166 | }, 167 | { 168 | "key": "Keep-Alive", 169 | "value": "timeout=5" 170 | } 171 | ], 172 | "cookie": [], 173 | "body": "{\n \"pause\": true,\n \"mute\": false,\n \"filename\": \"Dorohedoro 02 - In the Bag - Eat Quietly During Meals - My Neighbor the Sorcerer.mkv\",\n \"duration\": 1422.061,\n \"position\": 71.787,\n \"remaining\": 1350.274,\n \"media-title\": \"Dorohedoro 02 - In the Bag | Eat Quietly During Meals | My Neighbor the Sorcerer\",\n \"playlist\": [\n {\n \"index\": 0,\n \"id\": 1,\n \"filePath\": \"V:\\\\anime\\\\Vivy - Fluorite Eye's Song S01 1080p\\\\Vivy - Fluorite Eye's Song - E02.mkv\",\n \"filename\": \"Vivy - Fluorite Eye's Song - E02.mkv\"\n },\n {\n \"index\": 1,\n \"id\": 2,\n \"filePath\": \"V:\\\\anime\\\\Dorohedoro S01+OVA (BD 1080p)\\\\Dorohedoro 02 - In the Bag - Eat Quietly During Meals - My Neighbor the Sorcerer.mkv\",\n \"current\": true,\n \"filename\": \"Dorohedoro 02 - In the Bag - Eat Quietly During Meals - My Neighbor the Sorcerer.mkv\"\n }\n ],\n \"chapter\": 0,\n \"chapter-list\": [\n {\n \"title\": \"Intro\",\n \"time\": 0\n },\n {\n \"title\": \"OP\",\n \"time\": 68.986\n },\n {\n \"title\": \"Part A\",\n \"time\": 159.034\n },\n {\n \"title\": \"Part B\",\n \"time\": 589.089\n },\n {\n \"title\": \"ED\",\n \"time\": 1316.023\n },\n {\n \"title\": \"Preview\",\n \"time\": 1406.03\n }\n ],\n \"volume\": 100,\n \"fullscreen\": false,\n \"speed\": 1,\n \"sub-delay\": 0,\n \"sub-visibility\": true,\n \"track-list\": [\n {\n \"index\": 0,\n \"id\": 1,\n \"type\": \"video\",\n \"selected\": true,\n \"codec\": \"hevc\",\n \"demux-w\": 1920,\n \"demux-h\": 1080\n },\n {\n \"index\": 1,\n \"id\": 1,\n \"type\": \"audio\",\n \"selected\": true,\n \"codec\": \"opus\",\n \"demux-channel-count\": 2,\n \"demux-channels\": \"unknown2\",\n \"demux-samplerate\": 48000,\n \"lang\": \"jpn\"\n },\n {\n \"index\": 2,\n \"id\": 2,\n \"type\": \"audio\",\n \"selected\": false,\n \"codec\": \"opus\",\n \"demux-channel-count\": 2,\n \"demux-channels\": \"unknown2\",\n \"demux-samplerate\": 48000,\n \"lang\": \"eng\"\n },\n {\n \"index\": 3,\n \"id\": 1,\n \"type\": \"sub\",\n \"selected\": true,\n \"codec\": \"subrip\",\n \"lang\": \"hun\"\n },\n {\n \"index\": 4,\n \"id\": 2,\n \"type\": \"sub\",\n \"selected\": false,\n \"codec\": \"ass\",\n \"lang\": \"eng\"\n },\n {\n \"index\": 5,\n \"id\": 3,\n \"type\": \"sub\",\n \"selected\": false,\n \"codec\": \"ass\",\n \"lang\": \"eng\"\n }\n ],\n \"audio-delay\": 0,\n \"sub-font-size\": 55,\n \"sub-ass-override\": true,\n \"metadata\": {}\n}" 174 | } 175 | ] 176 | } 177 | ] 178 | }, 179 | { 180 | "name": "Media controls", 181 | "item": [ 182 | { 183 | "name": "Play-Pause", 184 | "request": { 185 | "method": "POST", 186 | "header": [], 187 | "url": { 188 | "raw": "{{baseURL}}/controls/play-pause", 189 | "host": [ 190 | "{{baseURL}}" 191 | ], 192 | "path": [ 193 | "controls", 194 | "play-pause" 195 | ] 196 | } 197 | }, 198 | "response": [] 199 | }, 200 | { 201 | "name": "Play", 202 | "request": { 203 | "method": "POST", 204 | "header": [], 205 | "url": { 206 | "raw": "{{baseURL}}/controls/play", 207 | "host": [ 208 | "{{baseURL}}" 209 | ], 210 | "path": [ 211 | "controls", 212 | "play" 213 | ] 214 | } 215 | }, 216 | "response": [] 217 | }, 218 | { 219 | "name": "Pause", 220 | "request": { 221 | "method": "POST", 222 | "header": [], 223 | "url": { 224 | "raw": "{{baseURL}}/controls/pause", 225 | "host": [ 226 | "{{baseURL}}" 227 | ], 228 | "path": [ 229 | "controls", 230 | "pause" 231 | ] 232 | } 233 | }, 234 | "response": [] 235 | }, 236 | { 237 | "name": "Stop", 238 | "request": { 239 | "method": "POST", 240 | "header": [], 241 | "url": { 242 | "raw": "{{baseURL}}/controls/stop", 243 | "host": [ 244 | "{{baseURL}}" 245 | ], 246 | "path": [ 247 | "controls", 248 | "stop" 249 | ] 250 | } 251 | }, 252 | "response": [] 253 | }, 254 | { 255 | "name": "Cycle fullscreen", 256 | "request": { 257 | "method": "POST", 258 | "header": [], 259 | "url": { 260 | "raw": "{{baseURL}}/controls/fullscreen", 261 | "host": [ 262 | "{{baseURL}}" 263 | ], 264 | "path": [ 265 | "controls", 266 | "fullscreen" 267 | ] 268 | } 269 | }, 270 | "response": [] 271 | }, 272 | { 273 | "name": "Cycle mute", 274 | "request": { 275 | "method": "POST", 276 | "header": [], 277 | "url": { 278 | "raw": "{{baseURL}}/controls/mute", 279 | "host": [ 280 | "{{baseURL}}" 281 | ], 282 | "path": [ 283 | "controls", 284 | "mute" 285 | ] 286 | } 287 | }, 288 | "response": [] 289 | }, 290 | { 291 | "name": "Set volume to 50%", 292 | "request": { 293 | "method": "POST", 294 | "header": [], 295 | "url": { 296 | "raw": "{{baseURL}}/controls/volume/50", 297 | "host": [ 298 | "{{baseURL}}" 299 | ], 300 | "path": [ 301 | "controls", 302 | "volume", 303 | "50" 304 | ] 305 | } 306 | }, 307 | "response": [] 308 | }, 309 | { 310 | "name": "Seek", 311 | "request": { 312 | "method": "POST", 313 | "header": [], 314 | "body": { 315 | "mode": "raw", 316 | "raw": "{\r\n \"target\": 50.0,\r\n \"flag\": \"relative\"\r\n}", 317 | "options": { 318 | "raw": { 319 | "language": "json" 320 | } 321 | } 322 | }, 323 | "url": { 324 | "raw": "{{baseURL}}/controls/seek", 325 | "host": [ 326 | "{{baseURL}}" 327 | ], 328 | "path": [ 329 | "controls", 330 | "seek" 331 | ] 332 | } 333 | }, 334 | "response": [ 335 | { 336 | "name": "Seek relative pos", 337 | "originalRequest": { 338 | "method": "POST", 339 | "header": [], 340 | "body": { 341 | "mode": "raw", 342 | "raw": "{\r\n \"target\": 50.0,\r\n \"flag\": \"relative\"\r\n}", 343 | "options": { 344 | "raw": { 345 | "language": "json" 346 | } 347 | } 348 | }, 349 | "url": { 350 | "raw": "{{baseURL}}/controls/seek", 351 | "host": [ 352 | "{{baseURL}}" 353 | ], 354 | "path": [ 355 | "controls", 356 | "seek" 357 | ] 358 | } 359 | }, 360 | "status": "OK", 361 | "code": 200, 362 | "_postman_previewlanguage": "json", 363 | "header": [ 364 | { 365 | "key": "X-Powered-By", 366 | "value": "Express" 367 | }, 368 | { 369 | "key": "Access-Control-Allow-Origin", 370 | "value": "*" 371 | }, 372 | { 373 | "key": "Content-Type", 374 | "value": "application/json; charset=utf-8" 375 | }, 376 | { 377 | "key": "Content-Length", 378 | "value": "21" 379 | }, 380 | { 381 | "key": "ETag", 382 | "value": "W/\"15-ga8EF/lp+ThIsc8w/OHbk4hPrME\"" 383 | }, 384 | { 385 | "key": "Date", 386 | "value": "Sat, 06 Nov 2021 08:38:51 GMT" 387 | }, 388 | { 389 | "key": "Connection", 390 | "value": "keep-alive" 391 | }, 392 | { 393 | "key": "Keep-Alive", 394 | "value": "timeout=5" 395 | } 396 | ], 397 | "cookie": [], 398 | "body": "{\n \"message\": \"success\"\n}" 399 | }, 400 | { 401 | "name": "Seek absolute pos", 402 | "originalRequest": { 403 | "method": "POST", 404 | "header": [], 405 | "body": { 406 | "mode": "raw", 407 | "raw": "{\r\n \"target\": 30.0,\r\n \"flag\": \"absolute\"\r\n}", 408 | "options": { 409 | "raw": { 410 | "language": "json" 411 | } 412 | } 413 | }, 414 | "url": { 415 | "raw": "{{baseURL}}/controls/seek", 416 | "host": [ 417 | "{{baseURL}}" 418 | ], 419 | "path": [ 420 | "controls", 421 | "seek" 422 | ] 423 | } 424 | }, 425 | "status": "OK", 426 | "code": 200, 427 | "_postman_previewlanguage": "json", 428 | "header": [ 429 | { 430 | "key": "X-Powered-By", 431 | "value": "Express" 432 | }, 433 | { 434 | "key": "Access-Control-Allow-Origin", 435 | "value": "*" 436 | }, 437 | { 438 | "key": "Content-Type", 439 | "value": "application/json; charset=utf-8" 440 | }, 441 | { 442 | "key": "Content-Length", 443 | "value": "21" 444 | }, 445 | { 446 | "key": "ETag", 447 | "value": "W/\"15-ga8EF/lp+ThIsc8w/OHbk4hPrME\"" 448 | }, 449 | { 450 | "key": "Date", 451 | "value": "Sat, 06 Nov 2021 08:39:38 GMT" 452 | }, 453 | { 454 | "key": "Connection", 455 | "value": "keep-alive" 456 | }, 457 | { 458 | "key": "Keep-Alive", 459 | "value": "timeout=5" 460 | } 461 | ], 462 | "cookie": [], 463 | "body": "{\n \"message\": \"success\"\n}" 464 | } 465 | ] 466 | } 467 | ] 468 | }, 469 | { 470 | "name": "Playlist", 471 | "item": [ 472 | { 473 | "name": "Get items", 474 | "request": { 475 | "method": "GET", 476 | "header": [], 477 | "url": { 478 | "raw": "{{baseURL}}/playlist", 479 | "host": [ 480 | "{{baseURL}}" 481 | ], 482 | "path": [ 483 | "playlist" 484 | ] 485 | } 486 | }, 487 | "response": [ 488 | { 489 | "name": "Get items", 490 | "originalRequest": { 491 | "method": "GET", 492 | "header": [], 493 | "url": { 494 | "raw": "{{baseURL}}/playlist", 495 | "host": [ 496 | "{{baseURL}}" 497 | ], 498 | "path": [ 499 | "playlist" 500 | ] 501 | } 502 | }, 503 | "status": "OK", 504 | "code": 200, 505 | "_postman_previewlanguage": "json", 506 | "header": [ 507 | { 508 | "key": "X-Powered-By", 509 | "value": "Express" 510 | }, 511 | { 512 | "key": "Access-Control-Allow-Origin", 513 | "value": "*" 514 | }, 515 | { 516 | "key": "Content-Type", 517 | "value": "application/json; charset=utf-8" 518 | }, 519 | { 520 | "key": "Content-Length", 521 | "value": "550" 522 | }, 523 | { 524 | "key": "ETag", 525 | "value": "W/\"226-bTB/dVgK9PfqTZO93rsmWG+eGiI\"" 526 | }, 527 | { 528 | "key": "Date", 529 | "value": "Sat, 06 Nov 2021 08:42:27 GMT" 530 | }, 531 | { 532 | "key": "Connection", 533 | "value": "keep-alive" 534 | }, 535 | { 536 | "key": "Keep-Alive", 537 | "value": "timeout=5" 538 | } 539 | ], 540 | "cookie": [], 541 | "body": "[\n {\n \"index\": 0,\n \"id\": 2,\n \"filePath\": \"V:\\\\anime\\\\Dorohedoro S01+OVA (BD 1080p)\\\\Dorohedoro 02 - In the Bag - Eat Quietly During Meals - My Neighbor the Sorcerer.mkv\",\n \"current\": true,\n \"filename\": \"Dorohedoro 02 - In the Bag - Eat Quietly During Meals - My Neighbor the Sorcerer.mkv\"\n },\n {\n \"index\": 1,\n \"id\": 3,\n \"filePath\": \"V:\\\\anime\\\\Dorohedoro S01+OVA (BD 1080p)\\\\Dorohedoro 03 - Night of the Dead ~ Duel! ~ In Front of the Main Department Store.mkv\",\n \"filename\": \"Dorohedoro 03 - Night of the Dead ~ Duel! ~ In Front of the Main Department Store.mkv\"\n }\n]" 542 | } 543 | ] 544 | }, 545 | { 546 | "name": "Add playlist item", 547 | "request": { 548 | "method": "POST", 549 | "header": [], 550 | "body": { 551 | "mode": "raw", 552 | "raw": "{\r\n \"filename\": \"https://www.youtube.com/watch?v=LXb3EKWsInQ\",\r\n \"flag\": \"replace\"\r\n}", 553 | "options": { 554 | "raw": { 555 | "language": "json" 556 | } 557 | } 558 | }, 559 | "url": { 560 | "raw": "{{baseURL}}/playlist", 561 | "host": [ 562 | "{{baseURL}}" 563 | ], 564 | "path": [ 565 | "playlist" 566 | ] 567 | } 568 | }, 569 | "response": [ 570 | { 571 | "name": "Append to playlist", 572 | "originalRequest": { 573 | "method": "POST", 574 | "header": [], 575 | "body": { 576 | "mode": "raw", 577 | "raw": "{\r\n \"filename\": \"V:\\\\anime\\\\Vivy - Fluorite Eye's Song S01 1080p\\\\Vivy - Fluorite Eye's Song - E02.mkv\",\r\n \"flag\": \"append-play\"\r\n}", 578 | "options": { 579 | "raw": { 580 | "language": "json" 581 | } 582 | } 583 | }, 584 | "url": { 585 | "raw": "{{baseURL}}/playlist", 586 | "host": [ 587 | "{{baseURL}}" 588 | ], 589 | "path": [ 590 | "playlist" 591 | ] 592 | } 593 | }, 594 | "status": "OK", 595 | "code": 200, 596 | "_postman_previewlanguage": "json", 597 | "header": [ 598 | { 599 | "key": "X-Powered-By", 600 | "value": "Express" 601 | }, 602 | { 603 | "key": "Access-Control-Allow-Origin", 604 | "value": "*" 605 | }, 606 | { 607 | "key": "Content-Type", 608 | "value": "application/json; charset=utf-8" 609 | }, 610 | { 611 | "key": "Content-Length", 612 | "value": "21" 613 | }, 614 | { 615 | "key": "ETag", 616 | "value": "W/\"15-ga8EF/lp+ThIsc8w/OHbk4hPrME\"" 617 | }, 618 | { 619 | "key": "Date", 620 | "value": "Sat, 06 Nov 2021 08:47:21 GMT" 621 | }, 622 | { 623 | "key": "Connection", 624 | "value": "keep-alive" 625 | }, 626 | { 627 | "key": "Keep-Alive", 628 | "value": "timeout=5" 629 | } 630 | ], 631 | "cookie": [], 632 | "body": "{\n \"message\": \"success\"\n}" 633 | }, 634 | { 635 | "name": "Replace playlist", 636 | "originalRequest": { 637 | "method": "POST", 638 | "header": [], 639 | "body": { 640 | "mode": "raw", 641 | "raw": "{\r\n \"filename\": \"V:\\\\anime\\\\Vivy - Fluorite Eye's Song S01 1080p\\\\Vivy - Fluorite Eye's Song - E02.mkv\",\r\n \"flag\": \"replace\"\r\n}", 642 | "options": { 643 | "raw": { 644 | "language": "json" 645 | } 646 | } 647 | }, 648 | "url": { 649 | "raw": "{{baseURL}}/playlist", 650 | "host": [ 651 | "{{baseURL}}" 652 | ], 653 | "path": [ 654 | "playlist" 655 | ] 656 | } 657 | }, 658 | "status": "OK", 659 | "code": 200, 660 | "_postman_previewlanguage": "json", 661 | "header": [ 662 | { 663 | "key": "X-Powered-By", 664 | "value": "Express" 665 | }, 666 | { 667 | "key": "Access-Control-Allow-Origin", 668 | "value": "*" 669 | }, 670 | { 671 | "key": "Content-Type", 672 | "value": "application/json; charset=utf-8" 673 | }, 674 | { 675 | "key": "Content-Length", 676 | "value": "21" 677 | }, 678 | { 679 | "key": "ETag", 680 | "value": "W/\"15-ga8EF/lp+ThIsc8w/OHbk4hPrME\"" 681 | }, 682 | { 683 | "key": "Date", 684 | "value": "Sat, 06 Nov 2021 08:48:13 GMT" 685 | }, 686 | { 687 | "key": "Connection", 688 | "value": "keep-alive" 689 | }, 690 | { 691 | "key": "Keep-Alive", 692 | "value": "timeout=5" 693 | } 694 | ], 695 | "cookie": [], 696 | "body": "{\n \"message\": \"success\"\n}" 697 | }, 698 | { 699 | "name": "Play Youtube video", 700 | "originalRequest": { 701 | "method": "POST", 702 | "header": [], 703 | "body": { 704 | "mode": "raw", 705 | "raw": "{\r\n \"filename\": \"https://www.youtube.com/watch?v=LXb3EKWsInQ\",\r\n \"flag\": \"replace\"\r\n}", 706 | "options": { 707 | "raw": { 708 | "language": "json" 709 | } 710 | } 711 | }, 712 | "url": { 713 | "raw": "{{baseURL}}/playlist", 714 | "host": [ 715 | "{{baseURL}}" 716 | ], 717 | "path": [ 718 | "playlist" 719 | ] 720 | } 721 | }, 722 | "status": "OK", 723 | "code": 200, 724 | "_postman_previewlanguage": "json", 725 | "header": [ 726 | { 727 | "key": "X-Powered-By", 728 | "value": "Express" 729 | }, 730 | { 731 | "key": "Access-Control-Allow-Origin", 732 | "value": "*" 733 | }, 734 | { 735 | "key": "Content-Type", 736 | "value": "application/json; charset=utf-8" 737 | }, 738 | { 739 | "key": "Content-Length", 740 | "value": "21" 741 | }, 742 | { 743 | "key": "ETag", 744 | "value": "W/\"15-ga8EF/lp+ThIsc8w/OHbk4hPrME\"" 745 | }, 746 | { 747 | "key": "Date", 748 | "value": "Sat, 06 Nov 2021 08:49:48 GMT" 749 | }, 750 | { 751 | "key": "Connection", 752 | "value": "keep-alive" 753 | }, 754 | { 755 | "key": "Keep-Alive", 756 | "value": "timeout=5" 757 | } 758 | ], 759 | "cookie": [], 760 | "body": "{\n \"message\": \"success\"\n}" 761 | }, 762 | { 763 | "name": "Replace playlist and seek file to 60 seconds", 764 | "originalRequest": { 765 | "method": "POST", 766 | "header": [], 767 | "body": { 768 | "mode": "raw", 769 | "raw": "{\r\n \"filename\": \"V:\\\\anime\\\\Vivy - Fluorite Eye's Song S01 1080p\\\\Vivy - Fluorite Eye's Song - E02.mkv\",\r\n \"flag\": \"replace\",\r\n \"seekTo\": 60.0\r\n}", 770 | "options": { 771 | "raw": { 772 | "language": "json" 773 | } 774 | } 775 | }, 776 | "url": { 777 | "raw": "{{baseURL}}/playlist", 778 | "host": [ 779 | "{{baseURL}}" 780 | ], 781 | "path": [ 782 | "playlist" 783 | ] 784 | } 785 | }, 786 | "status": "OK", 787 | "code": 200, 788 | "_postman_previewlanguage": "json", 789 | "header": [ 790 | { 791 | "key": "X-Powered-By", 792 | "value": "Express" 793 | }, 794 | { 795 | "key": "Access-Control-Allow-Origin", 796 | "value": "*" 797 | }, 798 | { 799 | "key": "Content-Type", 800 | "value": "application/json; charset=utf-8" 801 | }, 802 | { 803 | "key": "Content-Length", 804 | "value": "21" 805 | }, 806 | { 807 | "key": "ETag", 808 | "value": "W/\"15-ga8EF/lp+ThIsc8w/OHbk4hPrME\"" 809 | }, 810 | { 811 | "key": "Date", 812 | "value": "Sat, 06 Nov 2021 08:51:43 GMT" 813 | }, 814 | { 815 | "key": "Connection", 816 | "value": "keep-alive" 817 | }, 818 | { 819 | "key": "Keep-Alive", 820 | "value": "timeout=5" 821 | } 822 | ], 823 | "cookie": [], 824 | "body": "{\n \"message\": \"success\"\n}" 825 | } 826 | ] 827 | }, 828 | { 829 | "name": "Remove first item", 830 | "request": { 831 | "method": "DELETE", 832 | "header": [], 833 | "url": { 834 | "raw": "{{baseURL}}/playlist/remove/0", 835 | "host": [ 836 | "{{baseURL}}" 837 | ], 838 | "path": [ 839 | "playlist", 840 | "remove", 841 | "0" 842 | ] 843 | } 844 | }, 845 | "response": [] 846 | }, 847 | { 848 | "name": "Move item", 849 | "request": { 850 | "method": "POST", 851 | "header": [], 852 | "url": { 853 | "raw": "{{baseURL}}/playlist/move?fromIndex=0&toIndex=2", 854 | "host": [ 855 | "{{baseURL}}" 856 | ], 857 | "path": [ 858 | "playlist", 859 | "move" 860 | ], 861 | "query": [ 862 | { 863 | "key": "fromIndex", 864 | "value": "0" 865 | }, 866 | { 867 | "key": "toIndex", 868 | "value": "2" 869 | } 870 | ] 871 | } 872 | }, 873 | "response": [] 874 | }, 875 | { 876 | "name": "Play an item", 877 | "request": { 878 | "method": "POST", 879 | "header": [], 880 | "url": { 881 | "raw": "{{baseURL}}/playlist/play/1", 882 | "host": [ 883 | "{{baseURL}}" 884 | ], 885 | "path": [ 886 | "playlist", 887 | "play", 888 | "1" 889 | ] 890 | } 891 | }, 892 | "response": [] 893 | }, 894 | { 895 | "name": "Clear playlist", 896 | "request": { 897 | "method": "POST", 898 | "header": [], 899 | "url": { 900 | "raw": "{{baseURL}}/playlist/clear", 901 | "host": [ 902 | "{{baseURL}}" 903 | ], 904 | "path": [ 905 | "playlist", 906 | "clear" 907 | ] 908 | } 909 | }, 910 | "response": [] 911 | }, 912 | { 913 | "name": "Shuffle", 914 | "request": { 915 | "method": "POST", 916 | "header": [], 917 | "url": { 918 | "raw": "{{baseURL}}/playlist/shuffle", 919 | "host": [ 920 | "{{baseURL}}" 921 | ], 922 | "path": [ 923 | "playlist", 924 | "shuffle" 925 | ] 926 | } 927 | }, 928 | "response": [] 929 | } 930 | ] 931 | }, 932 | { 933 | "name": "Tracks", 934 | "item": [ 935 | { 936 | "name": "Audio", 937 | "item": [ 938 | { 939 | "name": "Cycle between tracks", 940 | "request": { 941 | "method": "POST", 942 | "header": [], 943 | "url": { 944 | "raw": "{{baseURL}}/tracks/audio/cycle", 945 | "host": [ 946 | "{{baseURL}}" 947 | ], 948 | "path": [ 949 | "tracks", 950 | "audio", 951 | "cycle" 952 | ] 953 | } 954 | }, 955 | "response": [] 956 | }, 957 | { 958 | "name": "Reload track ID:2", 959 | "request": { 960 | "method": "POST", 961 | "header": [], 962 | "url": { 963 | "raw": "{{baseURL}}/tracks/audio/reload/2", 964 | "host": [ 965 | "{{baseURL}}" 966 | ], 967 | "path": [ 968 | "tracks", 969 | "audio", 970 | "reload", 971 | "2" 972 | ] 973 | } 974 | }, 975 | "response": [] 976 | }, 977 | { 978 | "name": "Set audio timing", 979 | "request": { 980 | "method": "POST", 981 | "header": [], 982 | "url": { 983 | "raw": "{{baseURL}}/tracks/audio/timing/5", 984 | "host": [ 985 | "{{baseURL}}" 986 | ], 987 | "path": [ 988 | "tracks", 989 | "audio", 990 | "timing", 991 | "5" 992 | ] 993 | } 994 | }, 995 | "response": [] 996 | } 997 | ] 998 | }, 999 | { 1000 | "name": "Subtitle", 1001 | "item": [ 1002 | { 1003 | "name": "Reload track ID: 2", 1004 | "request": { 1005 | "method": "POST", 1006 | "header": [], 1007 | "url": { 1008 | "raw": "{{baseURL}}/tracks/sub/reload/2", 1009 | "host": [ 1010 | "{{baseURL}}" 1011 | ], 1012 | "path": [ 1013 | "tracks", 1014 | "sub", 1015 | "reload", 1016 | "2" 1017 | ] 1018 | } 1019 | }, 1020 | "response": [] 1021 | }, 1022 | { 1023 | "name": "Subtitle timing", 1024 | "request": { 1025 | "method": "POST", 1026 | "header": [], 1027 | "url": { 1028 | "raw": "{{baseURL}}/tracks/sub/timing/0", 1029 | "host": [ 1030 | "{{baseURL}}" 1031 | ], 1032 | "path": [ 1033 | "tracks", 1034 | "sub", 1035 | "timing", 1036 | "0" 1037 | ] 1038 | } 1039 | }, 1040 | "response": [] 1041 | }, 1042 | { 1043 | "name": "ASS Override", 1044 | "request": { 1045 | "method": "POST", 1046 | "header": [], 1047 | "url": { 1048 | "raw": "{{baseURL}}/tracks/sub/ass-override/yes", 1049 | "host": [ 1050 | "{{baseURL}}" 1051 | ], 1052 | "path": [ 1053 | "tracks", 1054 | "sub", 1055 | "ass-override", 1056 | "yes" 1057 | ] 1058 | } 1059 | }, 1060 | "response": [] 1061 | }, 1062 | { 1063 | "name": "Toggle visibility", 1064 | "request": { 1065 | "method": "POST", 1066 | "header": [], 1067 | "url": { 1068 | "raw": "{{baseURL}}/tracks/sub/toggle-visibility", 1069 | "host": [ 1070 | "{{baseURL}}" 1071 | ], 1072 | "path": [ 1073 | "tracks", 1074 | "sub", 1075 | "toggle-visibility" 1076 | ] 1077 | } 1078 | }, 1079 | "response": [] 1080 | }, 1081 | { 1082 | "name": "Set visibility", 1083 | "request": { 1084 | "method": "POST", 1085 | "header": [], 1086 | "url": { 1087 | "raw": "{{baseURL}}/tracks/sub/visibility/true", 1088 | "host": [ 1089 | "{{baseURL}}" 1090 | ], 1091 | "path": [ 1092 | "tracks", 1093 | "sub", 1094 | "visibility", 1095 | "true" 1096 | ] 1097 | } 1098 | }, 1099 | "response": [] 1100 | }, 1101 | { 1102 | "name": "Add track", 1103 | "request": { 1104 | "method": "POST", 1105 | "header": [], 1106 | "body": { 1107 | "mode": "raw", 1108 | "raw": "{\r\n \"filename\": \"V:\\\\anime\\\\Dorohedoro S01+OVA (BD 1080p)\\\\Feliratok\\\\[Judas] Dorohedoro - 03 [1080p][HEVC x265 10bit][Multi-Subs].srt\",\r\n \"flag\": \"select\"\r\n}", 1109 | "options": { 1110 | "raw": { 1111 | "language": "json" 1112 | } 1113 | } 1114 | }, 1115 | "url": { 1116 | "raw": "{{baseURL}}/tracks/sub/add", 1117 | "host": [ 1118 | "{{baseURL}}" 1119 | ], 1120 | "path": [ 1121 | "tracks", 1122 | "sub", 1123 | "add" 1124 | ] 1125 | } 1126 | }, 1127 | "response": [] 1128 | } 1129 | ] 1130 | }, 1131 | { 1132 | "name": "Get tracks", 1133 | "request": { 1134 | "method": "GET", 1135 | "header": [], 1136 | "url": { 1137 | "raw": "{{baseURL}}/tracks", 1138 | "host": [ 1139 | "{{baseURL}}" 1140 | ], 1141 | "path": [ 1142 | "tracks" 1143 | ] 1144 | } 1145 | }, 1146 | "response": [ 1147 | { 1148 | "name": "Get tracks", 1149 | "originalRequest": { 1150 | "method": "GET", 1151 | "header": [], 1152 | "url": { 1153 | "raw": "{{baseURL}}/tracks", 1154 | "host": [ 1155 | "{{baseURL}}" 1156 | ], 1157 | "path": [ 1158 | "tracks" 1159 | ] 1160 | } 1161 | }, 1162 | "status": "OK", 1163 | "code": 200, 1164 | "_postman_previewlanguage": "json", 1165 | "header": [ 1166 | { 1167 | "key": "X-Powered-By", 1168 | "value": "Express" 1169 | }, 1170 | { 1171 | "key": "Access-Control-Allow-Origin", 1172 | "value": "*" 1173 | }, 1174 | { 1175 | "key": "Content-Type", 1176 | "value": "application/json; charset=utf-8" 1177 | }, 1178 | { 1179 | "key": "Content-Length", 1180 | "value": "637" 1181 | }, 1182 | { 1183 | "key": "ETag", 1184 | "value": "W/\"27d-suKNO6rn4DGM3+CnAnzqyYORMUU\"" 1185 | }, 1186 | { 1187 | "key": "Date", 1188 | "value": "Sat, 06 Nov 2021 09:06:20 GMT" 1189 | }, 1190 | { 1191 | "key": "Connection", 1192 | "value": "keep-alive" 1193 | }, 1194 | { 1195 | "key": "Keep-Alive", 1196 | "value": "timeout=5" 1197 | } 1198 | ], 1199 | "cookie": [], 1200 | "body": "[\n {\n \"index\": 0,\n \"id\": 1,\n \"type\": \"video\",\n \"selected\": true,\n \"codec\": \"hevc\",\n \"demux-w\": 1920,\n \"demux-h\": 1080\n },\n {\n \"index\": 1,\n \"id\": 1,\n \"type\": \"audio\",\n \"selected\": true,\n \"codec\": \"opus\",\n \"demux-channel-count\": 2,\n \"demux-channels\": \"unknown2\",\n \"demux-samplerate\": 48000,\n \"lang\": \"jpn\"\n },\n {\n \"index\": 2,\n \"id\": 2,\n \"type\": \"audio\",\n \"selected\": false,\n \"codec\": \"opus\",\n \"demux-channel-count\": 2,\n \"demux-channels\": \"unknown2\",\n \"demux-samplerate\": 48000,\n \"lang\": \"eng\"\n },\n {\n \"index\": 3,\n \"id\": 1,\n \"type\": \"sub\",\n \"selected\": true,\n \"codec\": \"subrip\",\n \"lang\": \"hun\"\n },\n {\n \"index\": 4,\n \"id\": 2,\n \"type\": \"sub\",\n \"selected\": false,\n \"codec\": \"ass\",\n \"lang\": \"eng\"\n },\n {\n \"index\": 5,\n \"id\": 3,\n \"type\": \"sub\",\n \"selected\": false,\n \"codec\": \"ass\",\n \"lang\": \"eng\"\n }\n]" 1201 | } 1202 | ] 1203 | } 1204 | ] 1205 | }, 1206 | { 1207 | "name": "Filebrowser", 1208 | "item": [ 1209 | { 1210 | "name": "Browse path", 1211 | "request": { 1212 | "method": "POST", 1213 | "header": [], 1214 | "body": { 1215 | "mode": "raw", 1216 | "raw": "{\r\n \"path\": \"V:\\\\anime\\\\Dorohedoro S01+OVA (BD 1080p)\"\r\n}", 1217 | "options": { 1218 | "raw": { 1219 | "language": "json" 1220 | } 1221 | } 1222 | }, 1223 | "url": { 1224 | "raw": "{{baseURL}}/filebrowser/browse", 1225 | "host": [ 1226 | "{{baseURL}}" 1227 | ], 1228 | "path": [ 1229 | "filebrowser", 1230 | "browse" 1231 | ] 1232 | } 1233 | }, 1234 | "response": [ 1235 | { 1236 | "name": "Browse path", 1237 | "originalRequest": { 1238 | "method": "POST", 1239 | "header": [], 1240 | "body": { 1241 | "mode": "raw", 1242 | "raw": "{\r\n \"path\": \"V:\\\\anime\\\\Dorohedoro S01+OVA (BD 1080p)\"\r\n}", 1243 | "options": { 1244 | "raw": { 1245 | "language": "json" 1246 | } 1247 | } 1248 | }, 1249 | "url": { 1250 | "raw": "{{baseURL}}/filebrowser/browse", 1251 | "host": [ 1252 | "{{baseURL}}" 1253 | ], 1254 | "path": [ 1255 | "filebrowser", 1256 | "browse" 1257 | ] 1258 | } 1259 | }, 1260 | "status": "OK", 1261 | "code": 200, 1262 | "_postman_previewlanguage": "json", 1263 | "header": [ 1264 | { 1265 | "key": "X-Powered-By", 1266 | "value": "Express" 1267 | }, 1268 | { 1269 | "key": "Access-Control-Allow-Origin", 1270 | "value": "*" 1271 | }, 1272 | { 1273 | "key": "Content-Type", 1274 | "value": "application/json; charset=utf-8" 1275 | }, 1276 | { 1277 | "key": "Content-Length", 1278 | "value": "4366" 1279 | }, 1280 | { 1281 | "key": "ETag", 1282 | "value": "W/\"110e-W2Hs55tXyLo4W7snTrwtjT/R+fU\"" 1283 | }, 1284 | { 1285 | "key": "Date", 1286 | "value": "Sat, 06 Nov 2021 09:32:38 GMT" 1287 | }, 1288 | { 1289 | "key": "Connection", 1290 | "value": "keep-alive" 1291 | }, 1292 | { 1293 | "key": "Keep-Alive", 1294 | "value": "timeout=5" 1295 | } 1296 | ], 1297 | "cookie": [], 1298 | "body": "{\n \"content\": [\n {\n \"priority\": 1,\n \"type\": \"directory\",\n \"name\": \"Feliratok\",\n \"fullPath\": \"V:\\\\anime\\\\Dorohedoro S01+OVA (BD 1080p)\\\\Feliratok\",\n \"lastModified\": \"2021-05-03T19:10:16.008Z\"\n },\n {\n \"priority\": 2,\n \"type\": \"video\",\n \"name\": \"Dorohedoro 01 - Caiman.mkv\",\n \"fullPath\": \"V:\\\\anime\\\\Dorohedoro S01+OVA (BD 1080p)\\\\Dorohedoro 01 - Caiman.mkv\",\n \"lastModified\": \"2021-05-03T19:09:21.857Z\",\n \"mediaStatus\": {\n // Media status appears only when localdb enabled!\n \"id\": 2,\n \"directory\": \"V:\\\\anime\\\\Dorohedoro S01+OVA (BD 1080p)\",\n \"file_name\": \"Dorohedoro 01 - Caiman.mkv\",\n \"current_time\": 1422.0545,\n \"finished\": 1\n }\n },\n {\n \"priority\": 2,\n \"type\": \"video\",\n \"name\": \"Dorohedoro 02 - In the Bag - Eat Quietly During Meals - My Neighbor the Sorcerer.mkv\",\n \"fullPath\": \"V:\\\\anime\\\\Dorohedoro S01+OVA (BD 1080p)\\\\Dorohedoro 02 - In the Bag - Eat Quietly During Meals - My Neighbor the Sorcerer.mkv\",\n \"lastModified\": \"2021-05-03T19:04:05.410Z\",\n \"mediaStatus\": {\n \"id\": 1,\n \"directory\": \"V:\\\\anime\\\\Dorohedoro S01+OVA (BD 1080p)\",\n \"file_name\": \"Dorohedoro 02 - In the Bag - Eat Quietly During Meals - My Neighbor the Sorcerer.mkv\",\n \"current_time\": 596.853,\n \"finished\": 0\n }\n },\n {\n \"priority\": 2,\n \"type\": \"video\",\n \"name\": \"Dorohedoro 03 - Night of the Dead ~ Duel! ~ In Front of the Main Department Store.mkv\",\n \"fullPath\": \"V:\\\\anime\\\\Dorohedoro S01+OVA (BD 1080p)\\\\Dorohedoro 03 - Night of the Dead ~ Duel! ~ In Front of the Main Department Store.mkv\",\n \"lastModified\": \"2021-05-03T19:04:26.434Z\"\n }\n ],\n \"dirname\": \"Dorohedoro S01+OVA (BD 1080p)\",\n \"prevDir\": \"V:\\\\anime\",\n \"cwd\": \"V:\\\\anime\\\\Dorohedoro S01+OVA (BD 1080p)\"\n}" 1299 | } 1300 | ] 1301 | }, 1302 | { 1303 | "name": "Paths (Only interesting if mpvremote-filebrowserpaths defined)", 1304 | "request": { 1305 | "method": "GET", 1306 | "header": [], 1307 | "url": { 1308 | "raw": "{{baseURL}}/filebrowser/paths", 1309 | "host": [ 1310 | "{{baseURL}}" 1311 | ], 1312 | "path": [ 1313 | "filebrowser", 1314 | "paths" 1315 | ] 1316 | } 1317 | }, 1318 | "response": [] 1319 | }, 1320 | { 1321 | "name": "Browse path index 1 (Only interesting if mpvremote-filebrowserpaths defined)", 1322 | "request": { 1323 | "method": "GET", 1324 | "header": [], 1325 | "url": { 1326 | "raw": "{{baseURL}}/filebrowser/paths/1", 1327 | "host": [ 1328 | "{{baseURL}}" 1329 | ], 1330 | "path": [ 1331 | "filebrowser", 1332 | "paths", 1333 | "1" 1334 | ] 1335 | } 1336 | }, 1337 | "response": [] 1338 | }, 1339 | { 1340 | "name": "Get drives", 1341 | "request": { 1342 | "method": "GET", 1343 | "header": [], 1344 | "url": { 1345 | "raw": "{{baseURL}}/drives", 1346 | "host": [ 1347 | "{{baseURL}}" 1348 | ], 1349 | "path": [ 1350 | "drives" 1351 | ] 1352 | } 1353 | }, 1354 | "response": [ 1355 | { 1356 | "name": "Get drives (Windows)", 1357 | "originalRequest": { 1358 | "method": "GET", 1359 | "header": [], 1360 | "url": { 1361 | "raw": "{{baseURL}}/drives", 1362 | "host": [ 1363 | "{{baseURL}}" 1364 | ], 1365 | "path": [ 1366 | "drives" 1367 | ] 1368 | } 1369 | }, 1370 | "status": "OK", 1371 | "code": 200, 1372 | "_postman_previewlanguage": "json", 1373 | "header": [ 1374 | { 1375 | "key": "X-Powered-By", 1376 | "value": "Express" 1377 | }, 1378 | { 1379 | "key": "Access-Control-Allow-Origin", 1380 | "value": "*" 1381 | }, 1382 | { 1383 | "key": "Content-Type", 1384 | "value": "application/json; charset=utf-8" 1385 | }, 1386 | { 1387 | "key": "Content-Length", 1388 | "value": "99" 1389 | }, 1390 | { 1391 | "key": "ETag", 1392 | "value": "W/\"63-OKbKxXRqyUYRQu2WGQHNRCkbFbM\"" 1393 | }, 1394 | { 1395 | "key": "Date", 1396 | "value": "Sat, 06 Nov 2021 09:44:40 GMT" 1397 | }, 1398 | { 1399 | "key": "Connection", 1400 | "value": "keep-alive" 1401 | }, 1402 | { 1403 | "key": "Keep-Alive", 1404 | "value": "timeout=5" 1405 | } 1406 | ], 1407 | "cookie": [], 1408 | "body": "[\n {\n \"path\": \"C:\"\n },\n {\n \"path\": \"D:\"\n },\n {\n \"path\": \"E:\"\n },\n {\n \"path\": \"V:\"\n },\n {\n \"path\": \"W:\"\n },\n {\n \"path\": \"X:\"\n },\n {\n \"path\": \"Y:\"\n }\n]" 1409 | }, 1410 | { 1411 | "name": "Get drives (Linux)", 1412 | "originalRequest": { 1413 | "method": "GET", 1414 | "header": [], 1415 | "url": { 1416 | "raw": "{{baseURL}}/drives", 1417 | "host": [ 1418 | "{{baseURL}}" 1419 | ], 1420 | "path": [ 1421 | "drives" 1422 | ] 1423 | } 1424 | }, 1425 | "status": "OK", 1426 | "code": 200, 1427 | "_postman_previewlanguage": "json", 1428 | "header": [ 1429 | { 1430 | "key": "X-Powered-By", 1431 | "value": "Express" 1432 | }, 1433 | { 1434 | "key": "Access-Control-Allow-Origin", 1435 | "value": "*" 1436 | }, 1437 | { 1438 | "key": "Content-Type", 1439 | "value": "application/json; charset=utf-8" 1440 | }, 1441 | { 1442 | "key": "Content-Length", 1443 | "value": "160" 1444 | }, 1445 | { 1446 | "key": "ETag", 1447 | "value": "W/\"a0-1FoXGAYfoynPfQoMfagnvt9tpvs\"" 1448 | }, 1449 | { 1450 | "key": "Date", 1451 | "value": "Sat, 06 Nov 2021 13:12:14 GMT" 1452 | }, 1453 | { 1454 | "key": "Connection", 1455 | "value": "keep-alive" 1456 | }, 1457 | { 1458 | "key": "Keep-Alive", 1459 | "value": "timeout=5" 1460 | } 1461 | ], 1462 | "cookie": [], 1463 | "body": "[\n {\n \"path\": \"/dev\"\n },\n {\n \"path\": \"/run\"\n },\n {\n \"path\": \"/\"\n },\n {\n \"path\": \"/dev/shm\"\n },\n {\n \"path\": \"/run/lock\"\n },\n {\n \"path\": \"/sys/fs/cgroup\"\n },\n {\n \"path\": \"/boot/efi\"\n },\n {\n \"path\": \"/run/user/1000\"\n }\n]" 1464 | } 1465 | ] 1466 | } 1467 | ] 1468 | }, 1469 | { 1470 | "name": "Collections", 1471 | "item": [ 1472 | { 1473 | "name": "Entries", 1474 | "item": [ 1475 | { 1476 | "name": "Delete collection entry", 1477 | "request": { 1478 | "method": "DELETE", 1479 | "header": [], 1480 | "url": { 1481 | "raw": "{{baseURL}}/collections/1", 1482 | "host": [ 1483 | "{{baseURL}}" 1484 | ], 1485 | "path": [ 1486 | "collections", 1487 | "1" 1488 | ] 1489 | } 1490 | }, 1491 | "response": [] 1492 | }, 1493 | { 1494 | "name": "Add collection entry", 1495 | "request": { 1496 | "method": "POST", 1497 | "header": [], 1498 | "body": { 1499 | "mode": "raw", 1500 | "raw": "{\r\n \"path\": \"W:\\\\anime\"\r\n}", 1501 | "options": { 1502 | "raw": { 1503 | "language": "json" 1504 | } 1505 | } 1506 | }, 1507 | "url": { 1508 | "raw": "{{baseURL}}/collections/1/entries", 1509 | "host": [ 1510 | "{{baseURL}}" 1511 | ], 1512 | "path": [ 1513 | "collections", 1514 | "1", 1515 | "entries" 1516 | ] 1517 | } 1518 | }, 1519 | "response": [ 1520 | { 1521 | "name": "Collection not exists", 1522 | "originalRequest": { 1523 | "method": "POST", 1524 | "header": [], 1525 | "body": { 1526 | "mode": "raw", 1527 | "raw": "{\r\n \"path\": \"V:\\\\torrent\\\\anime\\\\\"\r\n}", 1528 | "options": { 1529 | "raw": { 1530 | "language": "json" 1531 | } 1532 | } 1533 | }, 1534 | "url": { 1535 | "raw": "{{baseURL}}/collections/1/entries", 1536 | "host": [ 1537 | "{{baseURL}}" 1538 | ], 1539 | "path": [ 1540 | "collections", 1541 | "1", 1542 | "entries" 1543 | ] 1544 | } 1545 | }, 1546 | "status": "Internal Server Error", 1547 | "code": 500, 1548 | "_postman_previewlanguage": "json", 1549 | "header": [ 1550 | { 1551 | "key": "X-Powered-By", 1552 | "value": "Express" 1553 | }, 1554 | { 1555 | "key": "Access-Control-Allow-Origin", 1556 | "value": "*" 1557 | }, 1558 | { 1559 | "key": "Content-Type", 1560 | "value": "application/json; charset=utf-8" 1561 | }, 1562 | { 1563 | "key": "Content-Length", 1564 | "value": "49" 1565 | }, 1566 | { 1567 | "key": "ETag", 1568 | "value": "W/\"31-oc9di03fr86OECBCbVUILmxLPiA\"" 1569 | }, 1570 | { 1571 | "key": "Date", 1572 | "value": "Sat, 06 Nov 2021 09:54:43 GMT" 1573 | }, 1574 | { 1575 | "key": "Connection", 1576 | "value": "keep-alive" 1577 | }, 1578 | { 1579 | "key": "Keep-Alive", 1580 | "value": "timeout=5" 1581 | } 1582 | ], 1583 | "cookie": [], 1584 | "body": "{\n \"error\": {\n \"errno\": 19,\n \"code\": \"SQLITE_CONSTRAINT\"\n }\n}" 1585 | }, 1586 | { 1587 | "name": "Add collection entry", 1588 | "originalRequest": { 1589 | "method": "POST", 1590 | "header": [], 1591 | "body": { 1592 | "mode": "raw", 1593 | "raw": "{\r\n \"path\": \"W:\\\\anime\"\r\n}", 1594 | "options": { 1595 | "raw": { 1596 | "language": "json" 1597 | } 1598 | } 1599 | }, 1600 | "url": { 1601 | "raw": "{{baseURL}}/collections/1/entries", 1602 | "host": [ 1603 | "{{baseURL}}" 1604 | ], 1605 | "path": [ 1606 | "collections", 1607 | "1", 1608 | "entries" 1609 | ] 1610 | } 1611 | }, 1612 | "status": "OK", 1613 | "code": 200, 1614 | "_postman_previewlanguage": "json", 1615 | "header": [ 1616 | { 1617 | "key": "X-Powered-By", 1618 | "value": "Express" 1619 | }, 1620 | { 1621 | "key": "Access-Control-Allow-Origin", 1622 | "value": "*" 1623 | }, 1624 | { 1625 | "key": "Content-Type", 1626 | "value": "application/json; charset=utf-8" 1627 | }, 1628 | { 1629 | "key": "Content-Length", 1630 | "value": "56" 1631 | }, 1632 | { 1633 | "key": "ETag", 1634 | "value": "W/\"38-KvFbZUM2MBsM16wAq8cMIRAvf9c\"" 1635 | }, 1636 | { 1637 | "key": "Date", 1638 | "value": "Sat, 06 Nov 2021 09:55:58 GMT" 1639 | }, 1640 | { 1641 | "key": "Connection", 1642 | "value": "keep-alive" 1643 | }, 1644 | { 1645 | "key": "Keep-Alive", 1646 | "value": "timeout=5" 1647 | } 1648 | ], 1649 | "cookie": [], 1650 | "body": "{\n \"id\": 2,\n \"collection_id\": 1,\n \"path\": \"W:\\\\anime\\\\\"\n}" 1651 | } 1652 | ] 1653 | } 1654 | ] 1655 | }, 1656 | { 1657 | "name": "Get collections", 1658 | "request": { 1659 | "method": "GET", 1660 | "header": [], 1661 | "url": { 1662 | "raw": "{{baseURL}}/collections", 1663 | "host": [ 1664 | "{{baseURL}}" 1665 | ], 1666 | "path": [ 1667 | "collections" 1668 | ] 1669 | } 1670 | }, 1671 | "response": [] 1672 | }, 1673 | { 1674 | "name": "Add collection", 1675 | "request": { 1676 | "method": "POST", 1677 | "header": [], 1678 | "body": { 1679 | "mode": "raw", 1680 | "raw": "{\r\n \"name\": \"Anime\",\r\n \"type\": 2,\r\n \"paths\": [\r\n {\r\n \"path\": \"V:\\\\anime\"\r\n },\r\n {\r\n \"path\": \"W:\\\\anime\"\r\n }\r\n \r\n ]\r\n}", 1681 | "options": { 1682 | "raw": { 1683 | "language": "json" 1684 | } 1685 | } 1686 | }, 1687 | "url": { 1688 | "raw": "{{baseURL}}/collections", 1689 | "host": [ 1690 | "{{baseURL}}" 1691 | ], 1692 | "path": [ 1693 | "collections" 1694 | ] 1695 | } 1696 | }, 1697 | "response": [ 1698 | { 1699 | "name": "Add collection", 1700 | "originalRequest": { 1701 | "method": "POST", 1702 | "header": [], 1703 | "body": { 1704 | "mode": "raw", 1705 | "raw": "{\r\n \"name\": \"Anime\",\r\n \"type\": 2,\r\n \"paths\": [\r\n {\r\n \"path\": \"V:\\\\anime\"\r\n },\r\n {\r\n \"path\": \"W:\\\\anime\"\r\n }\r\n \r\n ]\r\n}", 1706 | "options": { 1707 | "raw": { 1708 | "language": "json" 1709 | } 1710 | } 1711 | }, 1712 | "url": { 1713 | "raw": "{{baseURL}}/collections", 1714 | "host": [ 1715 | "{{baseURL}}" 1716 | ], 1717 | "path": [ 1718 | "collections" 1719 | ] 1720 | } 1721 | }, 1722 | "status": "OK", 1723 | "code": 200, 1724 | "_postman_previewlanguage": "json", 1725 | "header": [ 1726 | { 1727 | "key": "X-Powered-By", 1728 | "value": "Express" 1729 | }, 1730 | { 1731 | "key": "Access-Control-Allow-Origin", 1732 | "value": "*" 1733 | }, 1734 | { 1735 | "key": "Content-Type", 1736 | "value": "application/json; charset=utf-8" 1737 | }, 1738 | { 1739 | "key": "Content-Length", 1740 | "value": "43" 1741 | }, 1742 | { 1743 | "key": "ETag", 1744 | "value": "W/\"2b-tO5RPbhP0CPR6TPKxgW7R4AeumY\"" 1745 | }, 1746 | { 1747 | "key": "Date", 1748 | "value": "Sat, 06 Nov 2021 09:47:54 GMT" 1749 | }, 1750 | { 1751 | "key": "Connection", 1752 | "value": "keep-alive" 1753 | }, 1754 | { 1755 | "key": "Keep-Alive", 1756 | "value": "timeout=5" 1757 | } 1758 | ], 1759 | "cookie": [], 1760 | "body": "{\n \"id\": 1,\n \"name\": \"Anime\",\n \"type\": 2,\n \"paths\": []\n}" 1761 | } 1762 | ] 1763 | }, 1764 | { 1765 | "name": "Update collection", 1766 | "request": { 1767 | "method": "PATCH", 1768 | "header": [], 1769 | "url": null 1770 | }, 1771 | "response": [ 1772 | { 1773 | "name": "Update collection", 1774 | "originalRequest": { 1775 | "method": "PATCH", 1776 | "header": [], 1777 | "body": { 1778 | "mode": "raw", 1779 | "raw": "{\r\n \"name\": \"Anime ja nai\",\r\n \"type\": 2,\r\n \"paths\": [\r\n {\r\n \"path\": \"/home/usr/media3\"\r\n },\r\n {\r\n \"id\": 2,\r\n \"path\": \"/home/usr/media2_other\"\r\n }\r\n ]\r\n}", 1780 | "options": { 1781 | "raw": { 1782 | "language": "json" 1783 | } 1784 | } 1785 | }, 1786 | "url": { 1787 | "raw": "{{baseURL}}/collections/1", 1788 | "host": [ 1789 | "{{baseURL}}" 1790 | ], 1791 | "path": [ 1792 | "collections", 1793 | "1" 1794 | ] 1795 | } 1796 | }, 1797 | "status": "OK", 1798 | "code": 200, 1799 | "_postman_previewlanguage": "json", 1800 | "header": [ 1801 | { 1802 | "key": "X-Powered-By", 1803 | "value": "Express" 1804 | }, 1805 | { 1806 | "key": "Access-Control-Allow-Origin", 1807 | "value": "*" 1808 | }, 1809 | { 1810 | "key": "Content-Type", 1811 | "value": "application/json; charset=utf-8" 1812 | }, 1813 | { 1814 | "key": "Content-Length", 1815 | "value": "216" 1816 | }, 1817 | { 1818 | "key": "ETag", 1819 | "value": "W/\"d8-Or2otxB/wzQXXaLZEiK3M+vRhj4\"" 1820 | }, 1821 | { 1822 | "key": "Date", 1823 | "value": "Sat, 06 Nov 2021 09:50:09 GMT" 1824 | }, 1825 | { 1826 | "key": "Connection", 1827 | "value": "keep-alive" 1828 | }, 1829 | { 1830 | "key": "Keep-Alive", 1831 | "value": "timeout=5" 1832 | } 1833 | ], 1834 | "cookie": [], 1835 | "body": "{\n \"id\": 1,\n \"name\": \"Anime ja nai\",\n \"type\": 2,\n \"paths\": [\n {\n \"id\": 1,\n \"collection_id\": 1,\n \"path\": \"V:\\\\anime\"\n },\n {\n \"id\": 2,\n \"collection_id\": 1,\n \"path\": \"/home/usr/media2_other\"\n },\n {\n \"id\": 3,\n \"collection_id\": 1,\n \"path\": \"/home/usr/media3\"\n }\n ]\n}" 1836 | } 1837 | ] 1838 | }, 1839 | { 1840 | "name": "Delete collection", 1841 | "request": { 1842 | "method": "DELETE", 1843 | "header": [], 1844 | "url": { 1845 | "raw": "{{baseURL}}/collections/1", 1846 | "host": [ 1847 | "{{baseURL}}" 1848 | ], 1849 | "path": [ 1850 | "collections", 1851 | "1" 1852 | ] 1853 | } 1854 | }, 1855 | "response": [] 1856 | } 1857 | ] 1858 | } 1859 | ] 1860 | } -------------------------------------------------------------------------------- /postman/MPV Remote.postman_environment.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "bea6c28d-fa74-402a-8e31-4d9224774717", 3 | "name": "MPV Remote", 4 | "values": [ 5 | { 6 | "key": "baseURL", 7 | "value": "http://localhost:8000/api/v1", 8 | "enabled": true 9 | } 10 | ], 11 | "_postman_variable_scope": "environment", 12 | "_postman_exported_at": "2021-11-06T09:58:09.228Z", 13 | "_postman_exported_using": "Postman/9.1.2" 14 | } -------------------------------------------------------------------------------- /remoteServer.js: -------------------------------------------------------------------------------- 1 | const os = require("os"); 2 | const process = require("process"); 3 | const fs = require("fs"); 4 | const fs_async = require("fs").promises; 5 | const path = require("path"); 6 | const URL = require("url").URL; 7 | const exec = require("child_process").exec; 8 | 9 | const express = require("express"); 10 | const cors = require("cors"); 11 | const mpvAPI = require("node-mpv"); 12 | 13 | const yargs = require("yargs"); 14 | const portfinder = require("portfinder"); 15 | 16 | const WIN_SHUTDOWN_COMMAND = "shutdown /s /t 1"; 17 | const WIN_REBOOT_COMMAND = "shutdown /r /t 1"; 18 | const UNIX_SHUTDOWN_COMMAND = "/usr/sbin/shutdown now"; 19 | const UNIX_REBOOT_COMMAND = "/usr/sbin/reboot"; 20 | 21 | const { initDB } = require("./crud"); 22 | 23 | const tempdir = process.env.TEMP || process.env.TMP || "/tmp"; // Temp dir 24 | const FILE_LOCAL_OPTIONS_PATH = path.join(tempdir, "file-local-options.txt"); 25 | const filebrowser = require("./filebrowser"); 26 | const collections = require("./collections"); 27 | const { detectFileType } = require("./filebrowser"); 28 | const { loadSettings, settings, CORSOPTIONS, YTDL_DEFAULT_FORMAT } = require("./settings"); 29 | const { version } = require("./package.json"); 30 | // Returning cached properties if the CPU usage high. 31 | let cachedProps = {}; 32 | 33 | const argv = yargs 34 | .option("address", { 35 | description: "Server address to listen on", 36 | type: "string", 37 | }) 38 | .option("webport", { 39 | description: "First available server port", 40 | alias: "p", 41 | type: "number", 42 | default: 8000, 43 | }) 44 | .option("webportrangeend", { 45 | description: "Last available server port", 46 | alias: "e", 47 | type: "number", 48 | default: 8005, 49 | }) 50 | .option("uselocaldb", { 51 | description: "Use database for storing collection & mediastatus", 52 | type: "boolean", 53 | default: false, 54 | }) 55 | .option("filebrowserpaths", { 56 | description: "File browser paths, which can be accessed by the server", 57 | type: "array", 58 | }) 59 | .option("unsafefilebrowsing", { 60 | description: "Allows to browse your filesystem", 61 | type: "boolean", 62 | default: false, 63 | }) 64 | .option("verbose", { 65 | description: "Activates MPV node verbose log", 66 | type: "boolean", 67 | default: false, 68 | }) 69 | .option("osd-messages", { 70 | description: "Enables OSD messages", 71 | type: "boolean", 72 | default: false, 73 | }) 74 | .help() 75 | .alias("help", "h").argv; 76 | if (argv._.length == 0) { 77 | console.log("No socket provided"); 78 | process.exit(); 79 | } 80 | 81 | loadSettings(argv); 82 | 83 | const app = express(); 84 | app.use(cors(CORSOPTIONS)); 85 | app.use(express.json()); 86 | 87 | app.use("/", filebrowser); 88 | app.use("/api/v1/collections", collections); 89 | 90 | const mpv = new mpvAPI({ 91 | socket: settings.socketName, 92 | verbose: settings.verbose, 93 | }); 94 | 95 | function stringIsAValidUrl(s) { 96 | try { 97 | new URL(s); 98 | return true; 99 | } catch (err) { 100 | return false; 101 | } 102 | } 103 | 104 | // Thanks: https://javascript.plainenglish.io/how-to-add-a-timeout-limit-to-asynchronous-javascript-functions-3676d89c186d 105 | const asyncCallWithTimeout = async (asyncPromise, timeLimit) => { 106 | let timeoutHandle; 107 | 108 | const timeoutPromise = new Promise((_resolve, reject) => { 109 | timeoutHandle = setTimeout( 110 | () => reject(new Error("Async call timeout limit reached")), 111 | timeLimit 112 | ); 113 | }); 114 | 115 | return Promise.race([asyncPromise, timeoutPromise]).then((result) => { 116 | clearTimeout(timeoutHandle); 117 | return result; 118 | }); 119 | }; 120 | 121 | app.get("/api/v1/status", async (req, res) => { 122 | try { 123 | const result = await asyncCallWithTimeout( 124 | getMPVProps(req.query.exclude), 125 | 500 126 | ); 127 | // Returning cached properties if the CPU usage high. 128 | cachedProps = Object.assign(cachedProps, result); 129 | return res.json(result); 130 | } catch (exc) { 131 | if (exc.message == "Async call timeout limit reached") 132 | return res.json(cachedProps); 133 | else return res.status(500).json({ message: exc }); 134 | } 135 | }); 136 | 137 | /* 138 | MEDIA CONTROLS 139 | */ 140 | 141 | app.post("/api/v1/controls/play-pause", async (req, res) => { 142 | try { 143 | await mpv.togglePause(); 144 | return res.json({ message: "success" }); 145 | } catch (exc) { 146 | console.log(exc); 147 | return res.status(500).json({ message: exc }); 148 | } 149 | }); 150 | 151 | app.post("/api/v1/controls/play", async (req, res) => { 152 | try { 153 | await mpv.play(); 154 | return res.json({ messsage: "success" }); 155 | } catch (exc) { 156 | return res.status(500).json({ message: exc }); 157 | } 158 | }); 159 | 160 | app.post("/api/v1/controls/pause", async (req, res) => { 161 | try { 162 | await mpv.pause(); 163 | return res.json({ messsage: "success" }); 164 | } catch (exc) { 165 | return res.status(500).json({ message: exc }); 166 | } 167 | }); 168 | 169 | app.post("/api/v1/controls/stop", async (req, res) => { 170 | try { 171 | await mpv.stop(); 172 | return res.json({ message: "success" }); 173 | } catch (exc) { 174 | console.log(exc); 175 | return res.status(500).json({ message: exc }); 176 | } 177 | }); 178 | 179 | async function playlistPrev(req, res) { 180 | try { 181 | await mpv.prev(); 182 | return res.json({ message: "success" }); 183 | } catch (exc) { 184 | console.log(exc); 185 | return res.status(500).json({ message: exc }); 186 | } 187 | } 188 | 189 | async function playlistNext(req, res) { 190 | try { 191 | await mpv.next(); 192 | return res.json({ message: "success" }); 193 | } catch (exc) { 194 | console.log(exc); 195 | return res.status(500).json({ message: exc }); 196 | } 197 | } 198 | 199 | app.post("/api/v1/controls/prev", playlistPrev); 200 | app.post("/api/v1/playlist/prev", playlistPrev); 201 | 202 | app.post("/api/v1/controls/next", playlistNext); 203 | app.post("/api/v1/playlist/next", playlistNext); 204 | 205 | app.post("/api/v1/controls/fullscreen", async (req, res) => { 206 | try { 207 | await mpv.toggleFullscreen(); 208 | return res.json({ message: "success" }); 209 | } catch (exc) { 210 | console.log(exc); 211 | return res.status(500).json({ message: exc }); 212 | } 213 | }); 214 | 215 | app.post("/api/v1/controls/volume/:value", async (req, res) => { 216 | try { 217 | await mpv.volume(req.params.value); 218 | return res.json({ message: "success" }); 219 | } catch (exc) { 220 | console.log(exc); 221 | return res.status(500).json({ message: exc }); 222 | } 223 | }); 224 | 225 | app.post("/api/v1/controls/mute", async (req, res) => { 226 | try { 227 | await mpv.mute(); 228 | return res.json({ message: "success" }); 229 | } catch (exc) { 230 | console.log(exc); 231 | return res.status(500).json({ message: exc }); 232 | } 233 | }); 234 | 235 | app.post("/api/v1/controls/seek", async (req, res) => { 236 | try { 237 | if (!req.body.flag) req.body.flag = "relative"; 238 | await mpv.seek(req.body.target, req.body.flag); 239 | return res.json({ message: "success" }); 240 | } catch (exc) { 241 | console.log(exc); 242 | return res.status(500).json({ message: exc }); 243 | } 244 | }); 245 | 246 | /* 247 | TRACKS 248 | */ 249 | app.get("/api/v1/tracks", async (req, res) => { 250 | try { 251 | return res.json(await getTracks()); 252 | } catch (exc) { 253 | console.log(exc); 254 | return res.status(500).json({ message: exc }); 255 | } 256 | }); 257 | 258 | /* 259 | AUDIO TRACKS 260 | */ 261 | app.post("/api/v1/tracks/audio/reload/:id", async (req, res) => { 262 | try { 263 | await mpv.selectAudioTrack(req.params.id); 264 | return res.json({ message: "success" }); 265 | } catch (exc) { 266 | console.log(exc); 267 | return res.status(500).json({ message: exc }); 268 | } 269 | }); 270 | 271 | app.post("/api/v1/tracks/audio/cycle", async (req, res) => { 272 | try { 273 | await mpv.cycleAudioTracks(); 274 | return res.json({ message: "success" }); 275 | } catch (exc) { 276 | console.log(exc); 277 | return res.status(500).json({ message: exc }); 278 | } 279 | }); 280 | 281 | app.post("/api/v1/tracks/audio/add", async (req, res) => { 282 | try { 283 | if (!req.body.flag) req.body.flag = "select"; 284 | await mpv.addAudioTrack(req.body.filename, req.body.flag); 285 | return res.json({ message: "success" }); 286 | } catch (exc) { 287 | console.log(exc); 288 | return res.status(500).json({ message: exc }); 289 | } 290 | }); 291 | 292 | app.post("/api/v1/tracks/audio/timing/:seconds", async (req, res) => { 293 | try { 294 | await mpv.adjustAudioTiming(req.params.seconds); 295 | return res.json({ message: "success" }); 296 | } catch (exc) { 297 | console.log(exc); 298 | return res.status(500).json({ message: exc }); 299 | } 300 | }); 301 | 302 | /* 303 | SUB TRACKS 304 | */ 305 | app.post("/api/v1/tracks/sub/timing/:seconds", async (req, res) => { 306 | try { 307 | await mpv.adjustSubtitleTiming(req.params.seconds); 308 | return res.json({ message: "success" }); 309 | } catch (exc) { 310 | console.log(exc); 311 | return res.status(500).json({ message: exc }); 312 | } 313 | }); 314 | 315 | app.post("/api/v1/tracks/sub/ass-override/:value", async (req, res) => { 316 | try { 317 | await mpv.setProperty("sub-ass-override", req.params.value); 318 | return res.json({ message: "success" }); 319 | } catch (exc) { 320 | console.log(exc); 321 | return res.status(500).json({ message: exc }); 322 | } 323 | }); 324 | 325 | app.post("/api/v1/tracks/sub/font-size/:size", async (req, res) => { 326 | try { 327 | await mpv.setProperty("sub-font-size", req.params.size); 328 | return res.json({ message: "success" }); 329 | } catch (exc) { 330 | console.log(exc); 331 | return res.status(500).json({ message: exc }); 332 | } 333 | }); 334 | 335 | app.post("/api/v1/tracks/sub/toggle-visibility", async (req, res) => { 336 | try { 337 | await mpv.toggleSubtitleVisibility(); 338 | return res.json({ message: "success" }); 339 | } catch (exc) { 340 | console.log(exc); 341 | return res.status(500).json({ message: exc }); 342 | } 343 | }); 344 | 345 | app.post("/api/v1/tracks/sub/visibility/:value", async (req, res) => { 346 | try { 347 | let val = req.params.value.toLowerCase() == "true" ? true : false; 348 | await mpv.setProperty("sub-visibility", val); 349 | return res.json({ message: "success" }); 350 | } catch (exc) { 351 | console.log(exc); 352 | return res.status(500).json({ message: exc }); 353 | } 354 | }); 355 | 356 | app.post("/api/v1/tracks/sub/add", async (req, res) => { 357 | try { 358 | // TODO: title, lang 359 | if (!req.body.flag) req.body.flag = "select"; 360 | await mpv.addSubtitles(req.body.filename, req.body.flag); 361 | return res.json({ message: "success" }); 362 | } catch (exc) { 363 | console.log(exc); 364 | return res.status(500).json({ message: exc }); 365 | } 366 | }); 367 | 368 | app.post("/api/v1/tracks/sub/reload/:id", async (req, res) => { 369 | try { 370 | await mpv.selectSubtitles(req.params.id); 371 | return res.json({ message: "success" }); 372 | } catch (exc) { 373 | console.log(exc); 374 | return res.status(500).json({ message: exc }); 375 | } 376 | }); 377 | 378 | /* 379 | PLAYLIST 380 | */ 381 | app.get("/api/v1/playlist", async (req, res) => { 382 | try { 383 | return res.json(await getPlaylist()); 384 | } catch (exc) { 385 | console.log(exc); 386 | return res.status(500).json({ message: exc }); 387 | } 388 | }); 389 | 390 | async function writeFileLocalOptions(options) { 391 | await fs_async.writeFile( 392 | FILE_LOCAL_OPTIONS_PATH, 393 | JSON.stringify(options), 394 | "utf-8" 395 | ); 396 | } 397 | 398 | async function readFileLocalOptions() { 399 | const content = await fs_async.readFile(FILE_LOCAL_OPTIONS_PATH, "utf-8"); 400 | return JSON.parse(content); 401 | } 402 | 403 | app.post("/api/v1/playlist", async (req, res) => { 404 | try { 405 | if (!req.body.flag) req.body.flag = "append-play"; 406 | if ( 407 | !stringIsAValidUrl(req.body.filename) && 408 | fs.lstatSync(req.body.filename).isDirectory() 409 | ) { 410 | for (const item of await fs_async.readdir(req.body.filename)) { 411 | let type = detectFileType(path.extname(item)); 412 | if (type === "video" || type == "audio") { 413 | await mpv.load(item, "append-play"); 414 | } 415 | } 416 | } else { 417 | if (req.body["file-local-options"]) { 418 | let fileLocalOptions = await readFileLocalOptions(); 419 | fileLocalOptions[req.body.filename] = req.body["file-local-options"]; 420 | 421 | if (!req.body["file-local-options"]["ytdl-format"]) 422 | req.body["file-local-options"]["ytdl-format"] = YTDL_DEFAULT_FORMAT; 423 | 424 | // Have to write cach file here 425 | await writeFileLocalOptions(fileLocalOptions); 426 | } 427 | await mpv.load(req.body.filename, req.body.flag); 428 | if (req.body.seekTo) { 429 | await mpv.seek(req.body.seekTo, "absolute"); 430 | } 431 | } 432 | return res.json({ message: "success" }); 433 | } catch (exc) { 434 | console.log(exc); 435 | return res.status(500).json({ message: exc }); 436 | } 437 | }); 438 | 439 | app.delete("/api/v1/playlist/remove/:index", async (req, res) => { 440 | try { 441 | await mpv.playlistRemove(req.params.index); 442 | return res.json({ message: "success" }); 443 | } catch (exc) { 444 | console.log(exc); 445 | return res.status(500).json({ message: exc }); 446 | } 447 | }); 448 | 449 | app.post("/api/v1/playlist/move", async (req, res) => { 450 | try { 451 | if (!req.query.fromIndex) 452 | return res 453 | .status(400) 454 | .json({ message: "fromIndex query param required!" }); 455 | if (!req.query.toIndex) 456 | return res.status(400).json({ message: "toIndex query param required!" }); 457 | 458 | await mpv.playlistMove(req.query.fromIndex, req.query.toIndex); 459 | return res.json({ message: "success" }); 460 | } catch (exc) { 461 | console.log(exc); 462 | return res.status(500).json({ message: exc }); 463 | } 464 | }); 465 | 466 | app.post("/api/v1/playlist/play/:index", async (req, res) => { 467 | try { 468 | await mpv.command("playlist-play-index", [req.params.index]); 469 | await mpv.play(); 470 | // return res.json({ message: "success" }); 471 | return res.json(await getPlaylist()); 472 | } catch (exc) { 473 | console.log(exc); 474 | return res.status(500).json({ message: exc }); 475 | } 476 | }); 477 | 478 | app.post("/api/v1/playlist/clear", async (req, res) => { 479 | try { 480 | await mpv.clearPlaylist(); 481 | return res.json({ message: "success" }); 482 | } catch (exc) { 483 | console.log(exc); 484 | return res.status(500).json({ message: exc }); 485 | } 486 | }); 487 | 488 | app.post("/api/v1/playlist/shuffle", async (req, res) => { 489 | try { 490 | await mpv.shuffle(); 491 | return res.json({ message: "success" }); 492 | } catch (exc) { 493 | console.log(exc); 494 | return res.status(500).json({ message: exc }); 495 | } 496 | }); 497 | 498 | app.get("/api/v1/mpvinfo", async (req, res) => { 499 | try { 500 | res.json(await getMPVInfo()); 501 | } catch (exc) { 502 | return res.status(500).json({ message: exc }); 503 | } 504 | }); 505 | 506 | async function shutdownAction(action) { 507 | switch (action) { 508 | case "shutdown": 509 | // First stop MPV playback to save playback data 510 | await mpv.stop(); 511 | exec( 512 | os.platform == "win32" ? WIN_SHUTDOWN_COMMAND : UNIX_SHUTDOWN_COMMAND 513 | ); 514 | break; 515 | case "reboot": 516 | await mpv.stop(); 517 | exec(os.platform == "win32" ? WIN_REBOOT_COMMAND : UNIX_REBOOT_COMMAND); 518 | break; 519 | case "quit": 520 | await mpv.stop(); 521 | 522 | break; 523 | } 524 | } 525 | 526 | app.post("/api/v1/computer/:action", async (req, res) => { 527 | try { 528 | switch (req.params.action) { 529 | case "shutdown": 530 | case "reboot": 531 | case "quit": 532 | shutdownAction(req.params.action); 533 | break; 534 | default: 535 | return res.status(400).json({ message: "Invalid action" }); 536 | } 537 | } catch (exc) { 538 | console.log(exc); 539 | } 540 | }); 541 | 542 | mpv.on("status", async (status) => { 543 | try { 544 | switch (status.property) { 545 | case "pause": 546 | await showOSDMessage(status.value ? "Pause" : "Play"); 547 | break; 548 | case "volume": 549 | await showOSDMessage(`Volume: ${status.value}%`); 550 | break; 551 | case "mute": 552 | let volume = await mpv.getProperty("volume"); 553 | await showOSDMessage(status.value ? "Mute" : `Volume ${volume}`); 554 | break; 555 | case "playlist-count": 556 | case "playlist-pos": 557 | break; 558 | case "path": 559 | playerData = await getMPVProps(); 560 | if (status.value) { 561 | // Reset subdelay to 0 562 | await mpv.setProperty("sub-delay", 0); 563 | // Also reset audio delay to 0 564 | await mpv.adjustAudioTiming(0); 565 | await showOSDMessage( 566 | `Playing: ${playerData["media-title"] || playerData.filename}` 567 | ); 568 | } 569 | break; 570 | } 571 | } catch (exc) { 572 | console.log(exc); 573 | } 574 | }); 575 | 576 | mpv.on("seek", async (data) => { 577 | await showOSDMessage(`Seek: ${formatTime(data.end)}`); 578 | }); 579 | 580 | function formatTime(param) { 581 | var sec_num = parseInt(param); 582 | var hours = Math.floor(sec_num / 3600); 583 | var minutes = Math.floor((sec_num - hours * 3600) / 60); 584 | var seconds = sec_num - hours * 3600 - minutes * 60; 585 | 586 | if (hours < 10) { 587 | hours = "0" + hours; 588 | } 589 | if (minutes < 10) { 590 | minutes = "0" + minutes; 591 | } 592 | if (seconds < 10) { 593 | seconds = "0" + seconds; 594 | } 595 | return hours + ":" + minutes + ":" + seconds; 596 | } 597 | 598 | function handle(promise) { 599 | return promise 600 | .then((data) => [data, undefined]) 601 | .catch((error) => Promise.resolve([undefined, error])); 602 | } 603 | 604 | async function getMPVInfo() { 605 | return { 606 | "ffmpeg-version": await handle(mpv.getProperty("ffmpeg-version")) 607 | .then((resp) => resp[0]) 608 | .catch(() => null), 609 | "mpv-version": await handle(mpv.getProperty("mpv-version")) 610 | .then((resp) => resp[0]) 611 | .catch(() => null), 612 | "libass-version": await handle(mpv.getProperty("libass-version")) 613 | .then((resp) => resp[0]) 614 | .catch(() => null), 615 | mpvremoteConfig: settings, 616 | mpvremoteVersion: version, 617 | }; 618 | } 619 | 620 | async function getTracks() { 621 | const count = await mpv.getProperty("track-list/count"); 622 | let tracks = []; 623 | for (let i = 0; i < count; i++) { 624 | try { 625 | let track = { 626 | index: i, 627 | id: await handle(mpv.getProperty(`track-list/${i}/id`)).then( 628 | (resp) => resp[0] 629 | ), 630 | type: await handle(mpv.getProperty(`track-list/${i}/type`)).then( 631 | (resp) => resp[0] 632 | ), 633 | selected: await handle( 634 | mpv.getProperty(`track-list/${i}/selected`) 635 | ).then((resp) => resp[0]), 636 | codec: await handle(mpv.getProperty(`track-list/${i}/codec`)).then( 637 | (resp) => resp[0] 638 | ), 639 | }; 640 | // Get specific stuff 641 | if (track.type === "video") { 642 | track["demux-w"] = await handle( 643 | mpv.getProperty(`track-list/${i}/demux-w`) 644 | ).then((resp) => resp[0]); 645 | track["demux-h"] = await handle( 646 | mpv.getProperty(`track-list/${i}/demux-h`) 647 | ).then((resp) => resp[0]); 648 | } else if (track.type === "audio") { 649 | track["demux-channel-count"] = await handle( 650 | mpv.getProperty(`track-list/${i}/demux-channel-count`) 651 | ).then((resp) => resp[0]); 652 | track["demux-channels"] = await handle( 653 | mpv.getProperty(`track-list/${i}/demux-channels`) 654 | ).then((resp) => resp[0]); 655 | track["demux-samplerate"] = await handle( 656 | mpv.getProperty(`track-list/${i}/demux-samplerate`) 657 | ).then((resp) => resp[0]); 658 | track["demux-bitrate"] = await handle( 659 | mpv.getProperty(`track-list/${i}/demux-bitrate`) 660 | ).then((resp) => resp[0]); 661 | track.lang = await handle(mpv.getProperty(`track-list/${i}/lang`)).then( 662 | (resp) => resp[0] 663 | ); 664 | track["external-filename"] = await handle( 665 | mpv.getProperty(`track-list/${i}/external-filename`) 666 | ).then((resp) => resp[0]); 667 | } else if (track.type === "sub") { 668 | track.lang = await handle(mpv.getProperty(`track-list/${i}/lang`)).then( 669 | (resp) => resp[0] 670 | ); 671 | track["external-filename"] = await handle( 672 | mpv.getProperty(`track-list/${i}/external-filename`) 673 | ).then((resp) => resp[0]); 674 | } 675 | 676 | tracks.push(track); 677 | } catch (exc) { 678 | console.log(exc); 679 | } 680 | } 681 | return tracks; 682 | } 683 | 684 | async function getPlaylist() { 685 | const count = await mpv.getProperty("playlist-count"); 686 | let playlist = []; 687 | for (let i = 0; i < count; i++) { 688 | try { 689 | let item = { 690 | index: i, 691 | id: await handle(mpv.getProperty(`playlist/${i}/id`)).then( 692 | (resp) => resp[0] 693 | ), 694 | filePath: await handle(mpv.getProperty(`playlist/${i}/filename`)).then( 695 | (resp) => resp[0] 696 | ), 697 | current: await handle(mpv.getProperty(`playlist/${i}/current`)).then( 698 | (resp) => resp[0] 699 | ), 700 | title: await handle(mpv.getProperty(`playlist/${i}/title`)).then( 701 | (resp) => resp[0] 702 | ), 703 | }; 704 | 705 | if (item.filePath) item.filename = path.basename(item.filePath); 706 | playlist.push(item); 707 | } catch (exc) { 708 | console.log(exc); 709 | } 710 | } 711 | return playlist; 712 | } 713 | 714 | async function getChapters() { 715 | const count = await mpv.getProperty("chapter-list/count"); 716 | let chapters = []; 717 | for (let i = 0; i < count; i++) { 718 | chapters.push({ 719 | title: await handle(mpv.getProperty(`chapter-list/${i}/title`)).then( 720 | (resp) => resp[0] 721 | ), 722 | time: await handle(mpv.getProperty(`chapter-list/${i}/time`)).then( 723 | (resp) => resp[0] 724 | ), 725 | }); 726 | } 727 | return chapters; 728 | } 729 | 730 | async function getMetaData() { 731 | const count = await mpv.getProperty("metadata/list/count"); 732 | let metadata = {}; 733 | for (let i = 0; i < count; i++) { 734 | const key = await handle(mpv.getProperty(`metadata/list/${i}/key`)).then( 735 | (resp) => resp[0] 736 | ); 737 | if (key) { 738 | const value = await handle( 739 | mpv.getProperty(`metadata/list/${i}/value`) 740 | ).then((resp) => resp[0]); 741 | 742 | if (value) { 743 | metadata[key] = value; 744 | } 745 | } 746 | } 747 | return metadata; 748 | } 749 | 750 | async function getMPVProp(key) { 751 | try { 752 | switch (key) { 753 | case "playlist": 754 | return await getPlaylist(); 755 | case "chapter-list": 756 | return await getChapters(); 757 | case "track-list": 758 | return await getTracks(); 759 | case "metadata": 760 | return await getMetaData(); 761 | case "position": 762 | return await mpv.getProperty("time-pos"); 763 | case "remaining": 764 | return await mpv.getProperty("time-remaining"); 765 | default: 766 | return await mpv.getProperty(key); 767 | } 768 | } catch (exc) { 769 | if (exc.errmessage != "property unavailable") { 770 | console.log(exc); 771 | } 772 | return null; 773 | } 774 | } 775 | 776 | async function getMPVProps(exclude = []) { 777 | let props = { 778 | pause: false, 779 | mute: false, 780 | filename: null, 781 | duration: 0, 782 | position: 0, 783 | remaining: 0, 784 | "media-title": null, 785 | chapter: 0, 786 | volume: 0, 787 | "volume-max": 100, 788 | fullscreen: false, 789 | speed: 1, 790 | "sub-delay": 0, 791 | "sub-visibility": true, 792 | "audio-delay": 0, 793 | "sub-font-size": 55, 794 | "sub-ass-override": "no", 795 | playlist: [], 796 | "chapter-list": [], 797 | "track-list": [], 798 | metadata: {}, 799 | }; 800 | 801 | retval = {}; 802 | for (key of Object.keys(props)) { 803 | if (!exclude.includes(key)) { 804 | const val = (await getMPVProp(key)) || props[key]; 805 | retval[key] = val; 806 | } 807 | } 808 | return retval; 809 | } 810 | 811 | portfinder 812 | .getPortPromise({ 813 | port: settings.serverPort, 814 | stopPort: settings.serverPortRangeEnd, 815 | }) 816 | .then((port) => { 817 | app.listen(port, settings.realServerIP, () => { 818 | settings.serverPort = port; 819 | console.log(`listening on ${settings.serverIP}:${port}`); 820 | main(); 821 | }); 822 | }) 823 | .catch(() => { 824 | console.log( 825 | "There is no free port available, mpv-remote not started check your settings." 826 | ); 827 | }); 828 | 829 | async function showOSDMessage(text, timeout = null) { 830 | /* 831 | Shows OSD message on MPV if it's enabled on settings. 832 | */ 833 | if (settings.osdMessages) { 834 | if (timeout) return await mpv.command("show-text", [text, timeout]); 835 | else return await mpv.command("show-text", [text]); 836 | } else { 837 | console.log(`OSD message: ${text}`); 838 | } 839 | } 840 | 841 | async function main() { 842 | try { 843 | // Creates/clears file local options file. 844 | await mpv.start(); 845 | 846 | // Create file-local-options if not exists. 847 | if (!fs.existsSync(FILE_LOCAL_OPTIONS_PATH)) writeFileLocalOptions({}); 848 | if (settings.uselocaldb) await initDB(); 849 | 850 | await showOSDMessage( 851 | `Remote access on: ${settings.serverIP}:${settings.serverPort}`, 852 | 5000 853 | ); 854 | } catch (error) { 855 | // handle errors here 856 | console.log(error); 857 | } 858 | } 859 | 860 | process.on("unhandledRejection", (error) => { 861 | // Will print "unhandledRejection err is not defined" 862 | console.log("unhandledRejection", JSON.stringify(error)); 863 | }); 864 | -------------------------------------------------------------------------------- /settings.js: -------------------------------------------------------------------------------- 1 | const { networkInterfaces } = require("os"); 2 | const { readFileSync, existsSync } = require("fs"); 3 | const { getMPVHome } = require("./crud"); 4 | 5 | const path = require("path"); 6 | 7 | const IP_ADDR = Object.values(networkInterfaces()) 8 | .flat() 9 | .find((i) => (i.family == "IPv4" || i.family == 4) && !i.internal); 10 | 11 | const CORSOPTIONS = { 12 | origin: "*", 13 | methods: ["GET", "POST", "DELETE", "UPDATE", "PUT", "PATCH"], 14 | }; 15 | 16 | let settings = { 17 | serverIP: IP_ADDR ? IP_ADDR.address : "127.0.0.1", // Used for displaying the remote access URL 18 | realServerIP: undefined, // Used for app.listen(). Default is all interfaces 19 | serverPort: null, 20 | serverPortRangeEnd: null, 21 | filebrowserPaths: [], 22 | socketName: null, 23 | uselocaldb: false, 24 | unsafefilebrowsing: false, 25 | verbose: false, 26 | }; 27 | 28 | /* 29 | Loads settings 30 | */ 31 | function loadSettings(argv) { 32 | settings.socketName = argv._[0]; 33 | settings.realServerIP = argv.address; 34 | // If we have an explicit address, display that instead 35 | if (argv.address) settings.serverIP = argv.address; 36 | settings.serverPort = argv.webport; 37 | settings.serverPortRangeEnd = argv.webportrangeend; 38 | settings.uselocaldb = argv.uselocaldb; 39 | settings.unsafefilebrowsing = argv.unsafefilebrowsing; 40 | settings.verbose = argv.verbose; 41 | settings.osdMessages = argv["osd-messages"]; 42 | 43 | if (argv.filebrowserpaths) { 44 | settings.filebrowserPaths = argv.filebrowserpaths.map((el, index) => { 45 | return { 46 | index, 47 | path: el.replace(/^"|'+|"|'+$/g, ""), 48 | }; 49 | }); 50 | } 51 | } 52 | 53 | /* 54 | Load default ytdl format, 55 | if it don't exist return empty string 56 | */ 57 | function readDefaultYtdlFormat() { 58 | const mpvConfigPath = path.join(getMPVHome(), "mpv.conf"); 59 | if (!existsSync(mpvConfigPath)) { 60 | console.log("No mpv.conf file found"); 61 | return "" 62 | } 63 | 64 | const ytdlFormatLine = 65 | readFileSync(mpvConfigPath, "utf8") 66 | .split("\n") 67 | .find((line) => line.includes("ytdl-format")) 68 | 69 | if (!ytdlFormatLine) { 70 | console.log("No ytdl-format line found in mpv.conf") 71 | return ""; 72 | } 73 | const regex = /ytdl-format="(.+?)"/; 74 | const match = ytdlFormatLine.match(regex); 75 | 76 | if (match) 77 | return match[1]; 78 | return ""; 79 | } 80 | 81 | 82 | exports.loadSettings = loadSettings; 83 | exports.settings = settings; 84 | exports.CORSOPTIONS = CORSOPTIONS; 85 | exports.YTDL_DEFAULT_FORMAT = readDefaultYtdlFormat(); 86 | -------------------------------------------------------------------------------- /watchlisthandler.js: -------------------------------------------------------------------------------- 1 | const process = require("process"); 2 | const { initDB, addMediaStatusEntry } = require("./crud"); 3 | 4 | console.log("Watchlisthandler called"); 5 | async function main() { 6 | await initDB(); 7 | /* 8 | Required parameters: 9 | [0]: Filepath (Full path) 10 | [1]: Time 11 | [2]: percent-pos 12 | */ 13 | const cliArgs = process.argv.slice(2); 14 | if (cliArgs.length < 3) { 15 | console.log("Not enough parameters"); 16 | process.exit(); 17 | } 18 | await addMediaStatusEntry(cliArgs[0], cliArgs[1], cliArgs[2]); 19 | console.log("Entry added/updated"); 20 | } 21 | 22 | main(); 23 | --------------------------------------------------------------------------------