├── .github ├── FUNDING.yml └── workflows │ ├── build-webapp.yml │ ├── build.yml │ ├── deploy-demo.yml │ ├── npm-publish.yml │ └── update-website.yaml ├── .gitignore ├── LICENSE ├── README.md ├── bin ├── ffmpeg │ └── README.md └── syncthing │ ├── syncthing-android │ ├── syncthing-linux │ ├── syncthing-osx │ └── syncthing.exe ├── build ├── electron.js ├── icon.icns ├── icon.png ├── index.html ├── mstream-logo-cut.icns ├── mstream-logo-cut.ico ├── tray-icon-osx.png ├── tray-icon.png └── tray-icon@2x.png ├── cli-boot-wrapper.js ├── dev-app-update.yml ├── docs ├── API.md ├── API │ ├── db_album-songs.md │ ├── db_albums.md │ ├── db_artists-albums.md │ ├── db_artists.md │ ├── db_metadata.md │ ├── db_recursive-scan.md │ ├── db_search.md │ ├── db_status.md │ ├── dirparser.md │ ├── download.md │ ├── jukebox_push-to-client.md │ ├── login.md │ ├── ping.md │ ├── playlist_delete.md │ ├── playlist_getall.md │ ├── playlist_load.md │ ├── playlist_save.md │ ├── shared_get-token-and-playlist.md │ ├── shared_make-shared.md │ └── upload.md ├── deploy.md ├── designs │ ├── admin.png │ ├── devices.png │ ├── devices2.png │ ├── mstream-concept.png │ ├── mstream-express-2.png │ ├── mstream-express.png │ ├── mstream-icon.jpg │ ├── mstream-icon.psd │ ├── mstreamV1.png │ ├── mstreamV2.png │ ├── mstreamv4.png │ ├── mstreamv5.png │ └── shared.png ├── electron.md ├── install.md └── json_config.md ├── image-cache └── README.md ├── package-lock.json ├── package.json ├── save ├── README.md ├── conf │ └── README.md ├── db │ └── README.md ├── logs │ └── README.md └── sync │ └── README.md ├── src ├── api │ ├── admin.js │ ├── auth.js │ ├── db.js │ ├── download.js │ ├── federation.js │ ├── file-explorer.js │ ├── playlist.js │ ├── remote.js │ ├── scanner.js │ ├── scrobbler.js │ ├── shared.js │ └── transcode.js ├── db │ ├── image-compress-manager.js │ ├── image-compress-script.js │ ├── manager.js │ ├── scanner.js │ ├── scanner.mjs │ └── task-queue.js ├── logger.js ├── server.js ├── state │ ├── config.js │ ├── kill-list.js │ ├── lastfm.js │ └── syncthing.js ├── unused │ ├── README.md │ └── ddns.js └── util │ ├── admin.js │ ├── async-error.js │ ├── auth.js │ ├── file-explorer.js │ ├── m3u.js │ ├── ssl-test.js │ ├── validation.js │ ├── vpath.js │ └── web-error.js └── webapp ├── admin ├── index.css ├── index.html └── index.js ├── alpha ├── api.js ├── m.js ├── spa.css ├── spa.js └── vp.js ├── assets ├── css │ ├── foundation.css │ ├── izi-toast.css │ ├── lazy-load-polyfill.css │ ├── master.css │ ├── materialize.css │ ├── modal.css │ ├── mstream-player.css │ ├── spa.css │ ├── spinner.css │ └── waves.css ├── fav │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── browserconfig.xml │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── mstile-150x150.png │ ├── safari-pinned-tab.svg │ └── site.webmanifest ├── fonts │ ├── jura.css │ └── jura │ │ ├── z7NbdRfiaC4VXcBJUQZA3JzsTQ.woff2 │ │ ├── z7NbdRfiaC4VXcRJUQZA3Jw.woff2 │ │ ├── z7NbdRfiaC4VXcdJUQZA3JzsTQ.woff2 │ │ ├── z7NbdRfiaC4VXchJUQZA3JzsTQ.woff2 │ │ ├── z7NbdRfiaC4VXclJUQZA3JzsTQ.woff2 │ │ ├── z7NbdRfiaC4VXcpJUQZA3JzsTQ.woff2 │ │ └── z7NbdRfiaC4VXctJUQZA3JzsTQ.woff2 ├── img │ ├── app-store-logo.png │ ├── default.png │ ├── drag-handle.svg │ ├── folder.svg │ ├── mstream-icon.svg │ ├── mstream-logo.png │ ├── mstream-logo.svg │ ├── music-note.svg │ ├── next-white.svg │ ├── pause-white.svg │ ├── play-store-logo.png │ ├── play-white.svg │ ├── previous-white.svg │ ├── spinner.svg │ ├── star.svg │ ├── struckaxiom.png │ ├── struckaxiom_@2X.png │ ├── volume-mute.svg │ └── volume.svg └── js │ ├── api.js │ ├── api2.js │ ├── lib │ ├── axios.js │ ├── butterchurn-presets-extra.js │ ├── butterchurn-presets.min.js │ ├── butterchurn.min.js │ ├── clipboard.js │ ├── cookie.min.js │ ├── dropzone.js │ ├── hyst-modal.js │ ├── izi-toast.js │ ├── jwt-decode.js │ ├── lazy-load-polyfill.js │ ├── lazy-load.js │ ├── materialize.js │ ├── popper.js │ ├── qr.js │ ├── sortable.js │ ├── star-rating.js │ ├── vue-sortable.js │ └── vue2.js │ ├── mstream.js │ ├── mstream.jukebox.js │ ├── mstream.player.js │ ├── mstream.vue-browser.js │ ├── mstream.vue-player-controls.js │ ├── mstream.vue.player.js │ ├── spa.js │ ├── t.js │ └── waves.js ├── build ├── icon.icns ├── icon.png ├── mstream-logo-cut.icns ├── mstream-logo-cut.ico ├── tray-icon-osx.png ├── tray-icon.png └── tray-icon@2x.png ├── index.html ├── index.js ├── login ├── index.html └── index.js ├── old.html ├── package.json ├── qr └── index.html ├── remote ├── index.css ├── index.html └── index.js └── shared └── index.html /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: IrosTheBeggar 4 | -------------------------------------------------------------------------------- /.github/workflows/build-webapp.yml: -------------------------------------------------------------------------------- 1 | name: Build Webapp 2 | 3 | on: 4 | push: 5 | # Pattern matched against refs/tags 6 | tags: 7 | - '*' 8 | 9 | jobs: 10 | release: 11 | runs-on: ${{ matrix.os }} 12 | 13 | strategy: 14 | matrix: 15 | os: [macos-latest, ubuntu-latest, windows-latest] 16 | 17 | steps: 18 | - name: Check out Git repository 19 | uses: actions/checkout@v4 20 | 21 | - name: Install Node.js, NPM and Yarn 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: 14 25 | 26 | - name: Build/release Electron app 27 | uses: samuelmeuli/action-electron-builder@v1.6.0 28 | with: 29 | package_root: webapp/ 30 | # GitHub token, automatically provided to the action 31 | # (No need to define this secret in the repo settings) 32 | github_token: ${{ secrets.github_token }} 33 | 34 | # If the commit is tagged with a version (e.g. "v1.0.0"), 35 | # release the app after building 36 | release: ${{ startsWith(github.ref, 'refs/tags/v') }} -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build-electron 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | release: 10 | runs-on: ${{ matrix.os }} 11 | 12 | strategy: 13 | matrix: 14 | os: [macos-latest, ubuntu-latest, windows-latest] 15 | 16 | steps: 17 | - name: Check out Git repository 18 | uses: actions/checkout@v4 19 | 20 | - name: Install Node.js, NPM and Yarn 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: 20 24 | 25 | - name: Build/release Electron app 26 | uses: samuelmeuli/action-electron-builder@v1.6.0 27 | with: 28 | # GitHub token, automatically provided to the action 29 | # (No need to define this secret in the repo settings) 30 | github_token: ${{ secrets.github_token }} 31 | 32 | # If the commit is tagged with a version (e.g. "v1.0.0"), 33 | # release the app after building 34 | release: ${{ startsWith(github.ref, 'refs/tags/v') }} -------------------------------------------------------------------------------- /.github/workflows/deploy-demo.yml: -------------------------------------------------------------------------------- 1 | 2 | name: Deploy Demo Site 3 | 4 | on: 5 | release: 6 | types: [published] 7 | 8 | jobs: 9 | deploy-demo: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: pull changes 13 | uses: appleboy/ssh-action@master 14 | with: 15 | host: ${{ secrets.DEMO_HOST }} 16 | username: ${{ secrets.DEMO_SSH_USERNAME }} 17 | key: ${{ secrets.DEMO_SSH_KEY }} 18 | port: ${{ secrets.DEMO_SSH_PORT }} 19 | script: | 20 | cd mStream/ 21 | git pull 22 | npm install --production 23 | pm2 restart all 24 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages 3 | 4 | name: Node.js Package 5 | 6 | on: 7 | release: 8 | types: [published] 9 | 10 | jobs: 11 | publish-npm: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-node@v4 16 | with: 17 | node-version: 20 18 | registry-url: https://registry.npmjs.org/ 19 | - run: npm publish 20 | env: 21 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 22 | 23 | -------------------------------------------------------------------------------- /.github/workflows/update-website.yaml: -------------------------------------------------------------------------------- 1 | 2 | name: Update Website 3 | 4 | on: 5 | release: 6 | types: [published] 7 | 8 | jobs: 9 | update-website: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: get node 13 | uses: actions/setup-node@v4 14 | with: 15 | node-version: '20' 16 | - name: checkout mstream.io source code 17 | uses: actions/checkout@v4 18 | with: 19 | path: website 20 | repository: IrosTheBeggar/mstream-website 21 | token: ${{ secrets.github_token }} 22 | ref: master 23 | - name: checkout mStream app code 24 | uses: actions/checkout@v4 25 | with: 26 | path: mstream 27 | repository: IrosTheBeggar/mStream 28 | token: ${{ secrets.github_token }} 29 | ref: master 30 | - run: ls 31 | - run: | 32 | cd website 33 | VERS=$(node -pe "require('../mstream/package.json').version") 34 | node -pe "require('../mstream/package.json').version" 35 | echo "v$(node -pe "require('../mstream/package.json').version")" 36 | echo $VERS 37 | sed -i "s/[1-9]\+[0-9]*\.[0-9]\+\.[0-9]\+/$VERS/g" templates/express.html 38 | git config user.name github-actions 39 | git config user.email github-actions@github.com 40 | git add . 41 | git commit -m "action: update server" 42 | git push 43 | 44 | 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/* 2 | webapp/node_modules/* 3 | 4 | image-cache/* 5 | !image-cache/README.md 6 | 7 | save/sync/* 8 | !save/sync/README.md 9 | 10 | save/logs/* 11 | !save/logs/README.md 12 | 13 | save/conf/* 14 | !save/conf/README.md 15 | 16 | save/sync-cdn/* 17 | !save/conf/README.md 18 | 19 | bin/ffmpeg/* 20 | !bin/ffmpeg/README.md 21 | 22 | save/*.json 23 | 24 | .DS_Store 25 | .vscode/* 26 | 27 | *.log 28 | *.db 29 | frp/*.ini 30 | 31 | dist/* 32 | 33 | .env -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mStream Music 2 | 3 | mStream is a personal music streaming server. You can use mStream to stream your music from your home computer to any device, anywhere. 4 | 5 | Main|Shared|Admin 6 | ---|---|--- 7 | ![main](/docs/designs/mstreamv5.png?raw=true)|![shared](/docs/designs/shared.png?raw=true)|![admin](/docs/designs/admin.png?raw=true) 8 | 9 | ## Demo & Other Links 10 | 11 | #### [Check Out The Demo!](https://demo.mstream.io/) 12 | 13 | #### [Discord Channel](https://discord.gg/AM896Rr) 14 | 15 | #### [Website](https://mstream.io) 16 | 17 | ### Server Features 18 | * Cross Platform. Works on Windows, OSX, Linux, & FreeBSD 19 | * Light on memory and CPU 20 | * Tested on multi-terabyte libraries 21 | * Runs on ARM boards like the Raspberry Pi 22 | 23 | ### WebApp Features 24 | * Gapless Playback 25 | * Milkdrop Visualizer 26 | * Playlist Sharing 27 | * Upload Files through the file explorer 28 | 29 | ## Installing mStream 30 | 31 | * [Docker Instructions](https://github.com/linuxserver/docker-mstream) 32 | * [Binaries for Win/OSX/Linux](https://mstream.io/server) 33 | * [Install From Source](docs/install.md) 34 | * [AWS Cloud using Terraform](https://gitlab.com/SiliconTao-Systems/nova) 35 | 36 | ## Mobile Apps 37 | 38 | [mStream iOS App](https://apps.apple.com/us/app/mstream-player/id1605378892) 39 | 40 | [mStream Android App](https://play.google.com/store/apps/details?id=com.nieratechinc.mstreamplayer&hl=en_US) 41 | 42 | [Made by Niera Tech](https://mplayer.nieratech.com/) 43 | 44 | ## Quick Install from CLI 45 | 46 | Deploying an mStream server is simple. 47 | 48 | ```shell 49 | # Install From Git 50 | git clone https://github.com/IrosTheBeggar/mStream.git 51 | 52 | cd mStream 53 | 54 | # Install dependencies and run 55 | npm run-script wizard 56 | ``` 57 | 58 | ## Technical Details 59 | 60 | * **Dependencies:** NodeJS v10 or greater 61 | 62 | * **Supported File Formats:** flac, mp3, mp4, wav, ogg, opus, aac, m4a 63 | 64 | ## Credits 65 | 66 | mStream is built on top some great open-source libraries: 67 | 68 | * [music-metadata](https://github.com/Borewit/music-metadata) - The best metadata parser for NodeJS 69 | * [LokiJS](https://github.com/techfort/LokiJS) - A native, in-memory, database written in JavaScript. LokiJS is the reason mStream is so fast and easy to install 70 | * [Butterchurn](https://github.com/jberg/butterchurn) - A clone of Milkdrop Visualizer written in JavaScript 71 | 72 | And thanks to the [LinuxServer.io](https://www.linuxserver.io/) group for maintaining the Docker image! 73 | -------------------------------------------------------------------------------- /bin/ffmpeg/README.md: -------------------------------------------------------------------------------- 1 | ffmpeg binaries will be downloaded here unless otherwise specified 2 | 3 | binaries are only downloaded when transcoding is enabled -------------------------------------------------------------------------------- /bin/syncthing/syncthing-android: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/bin/syncthing/syncthing-android -------------------------------------------------------------------------------- /bin/syncthing/syncthing-linux: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/bin/syncthing/syncthing-linux -------------------------------------------------------------------------------- /bin/syncthing/syncthing-osx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/bin/syncthing/syncthing-osx -------------------------------------------------------------------------------- /bin/syncthing/syncthing.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/bin/syncthing/syncthing.exe -------------------------------------------------------------------------------- /build/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/build/icon.icns -------------------------------------------------------------------------------- /build/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/build/icon.png -------------------------------------------------------------------------------- /build/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 29 | 30 | 31 | 32 |
33 |
34 |
35 | 36 | 37 |
38 |
39 |
40 |
41 |
42 | Admin User 43 |
44 |
45 | 46 | 47 |
48 |
49 | 50 | 51 |
52 |
53 |
54 |
55 | Port 56 |
57 |
58 | 59 |
60 |
61 | 62 | 63 |
64 |
65 | 69 |
70 |
71 | 72 |
73 | 76 |
77 |
78 |
79 |
80 |
81 |
Folders
82 | 83 |
84 | Add Folder 85 |
86 |
87 |
88 |
89 | 90 | 91 |
92 |
93 |
94 | 95 | 115 | 116 | -------------------------------------------------------------------------------- /build/mstream-logo-cut.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/build/mstream-logo-cut.icns -------------------------------------------------------------------------------- /build/mstream-logo-cut.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/build/mstream-logo-cut.ico -------------------------------------------------------------------------------- /build/tray-icon-osx.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/build/tray-icon-osx.png -------------------------------------------------------------------------------- /build/tray-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/build/tray-icon.png -------------------------------------------------------------------------------- /build/tray-icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/build/tray-icon@2x.png -------------------------------------------------------------------------------- /cli-boot-wrapper.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | "use strict"; 3 | 4 | // Check if we are in an electron environment 5 | if (process.versions["electron"]) { 6 | // off to a separate electron boot environment 7 | require("./build/electron"); 8 | } else { 9 | const version = require('./package.json').version; 10 | const { Command } = require('commander'); 11 | const program = new Command(); 12 | program 13 | .version(version) 14 | .option('-j, --json ', 'Specify JSON Boot File', require('path').join(__dirname, 'save/conf/default.json')) 15 | .parse(process.argv); 16 | 17 | console.clear(); 18 | console.log(` 19 | ____ _ 20 | _ __ ___ / ___|| |_ _ __ ___ __ _ _ __ ___ 21 | | '_ \` _ \\\\___ \\| __| '__/ _ \\/ _\` | '_ \` _ \\ 22 | | | | | | |___) | |_| | | __/ (_| | | | | | | 23 | |_| |_| |_|____/ \\__|_| \\___|\\__,_|_| |_| |_|`); 24 | console.log(`v${program.version()}`); 25 | console.log(); 26 | console.log('Check out our Discord server:'); 27 | console.log('https://discord.gg/AM896Rr'); 28 | console.log(); 29 | 30 | // Boot the server 31 | require("./src/server").serveIt(program.opts().json); 32 | } 33 | -------------------------------------------------------------------------------- /dev-app-update.yml: -------------------------------------------------------------------------------- 1 | provider: github 2 | repo: mStream 3 | owner: IrosTheBeggar -------------------------------------------------------------------------------- /docs/API.md: -------------------------------------------------------------------------------- 1 | # mStream API 2 | 3 | mStream uses a REST based API for everything. 4 | 5 | All calls to the API are done through GET and POST requests. Make sure to set your `Content-Type` header to `application/json` when making a POST request 6 | 7 | ``` 8 | // jQuery Example 9 | 10 | var request = $.ajax({ 11 | url: "login", 12 | type: "POST", 13 | contentType: "application/json", 14 | dataType: "json", 15 | data: JSON.stringify( 16 | { 17 | username: "Bojack", 18 | password: "family" 19 | } 20 | ) 21 | }); 22 | ``` 23 | 24 | ## Streaming Files 25 | 26 | To stream a file you need a three pieces of information: 27 | - The filepath - this is the relative filepath as it would show up on your disk 28 | - The vPath - This is a virtual directory that's created on boot for security reasons. It can be obtained through ['/ping'](API/ping.md) or ['/login'](API/login.md) 29 | - The token - The user token (the token is only needed if user system is enable) 30 | 31 | To stream a file create a URL with the following structure 32 | ``` 33 | http://yourserver.com/media/[your vPath]/path/to/song.mp3?token=XXXXXXXX 34 | ``` 35 | 36 | 37 | ## File Explorer 38 | 39 | [/dirparser](API/dirparser.md) 40 | 41 | [/upload](API/upload.md) 42 | 43 | ## Playlists 44 | 45 | [/playlist/getall](API/playlist_getall.md) 46 | 47 | [/playlist/load](API/playlist_load.md) 48 | 49 | [/playlist/save](API/playlist_save.md) 50 | 51 | [/playlist/delete](API/playlist_delete.md) 52 | 53 | ## Metadata (Albums/Artists/Etc) 54 | 55 | [/db/metadata](API/db_metadata.md) 56 | 57 | [/db/search](API/db_search.md) 58 | 59 | [/db/albums](API/db_albums.md) 60 | 61 | [/db/artists](API/db_artists.md) 62 | 63 | [/db/artists-albums](API/db_artists-albums.md) 64 | 65 | [/db/album-songs](API/db_album-songs.md) 66 | 67 | [/db/status](API/db_status.md) 68 | 69 | [/db/recursive-scan](API/db_recursive-scan.md) 70 | 71 | ## JukeBox 72 | 73 | [/jukebox/push-to-client](API/jukebox_push-to-client.md) 74 | 75 | ## Download 76 | 77 | [/download](API/download.md) 78 | 79 | ## Share 80 | 81 | [/shared/make-shared](API/shared_make-shared.md) 82 | 83 | [/shared/get-token-and-playlist](API/shared_get-token-and-playlist.md) 84 | 85 | 86 | ## Login System & Authentication 87 | 88 | mStream uses a token based authentication. The token you get when logging in can be used to access the API endpoints and the music files. 89 | 90 | Login Functions: 91 | 92 | * [/login](API/login.md) 93 | * [/ping](API/ping.md) 94 | * /change-password - Coming Soon 95 | 96 | Failure Endpoints: 97 | 98 | * /access-denied 99 | 100 | The security layer is written as a plugin. If you don't set the username and password on boot the plugin won't load and your server will be accessible by to anyone. All API endpoints require a token to access if the login system is enabled. Tokens can be passed in through the GET or POST param token. Tokens can also be put in the request header under 'x-access-token' 101 | 102 | If you want your tokens to work between reboots you can set the `secret` flag when booting by using `mstream -s YOUR_SECERT_STRING_HERE`. The secret key is used to sign the tokens. If you do not set the secret key mStream will generate a random key on boot 103 | 104 | ## Pages 105 | 106 | These endpoints server various parts of the webapp 107 | 108 | * / 109 | * /remote 110 | * /shared/playlist/[PLAYLIST ID] 111 | -------------------------------------------------------------------------------- /docs/API/db_album-songs.md: -------------------------------------------------------------------------------- 1 | **Get Songs for Album** 2 | ---- 3 | Will retrieve all songs and metadata for a given album name 4 | 5 | * **URL** 6 | 7 | /db/album-songs 8 | 9 | * **Method:** 10 | 11 | `POST` 12 | 13 | * **JSON Params** 14 | 15 | **Required:** 16 | 17 | `album` - Album Name 18 | 19 | * **JSON Example** 20 | 21 | ``` 22 | { 23 | 'album': 'Album Name' 24 | } 25 | ``` 26 | 27 | * **Success Response:** 28 | 29 | * **Code:** 200
30 | **Content:** 31 | 32 | ``` 33 | [ 34 | { 35 | "filepath": 'path/to/file.mp3', 36 | "metadata": { 37 | "artist": 'Artist', 38 | "album": 'Greatest Hits', 39 | "track": 9, 40 | "title":' Title', 41 | "year": 1988, 42 | "album-art": 'album-art-filename.jpg', 43 | "filename": file.mp3, 44 | "hash": "md5 hash" 45 | } 46 | }, 47 | ... 48 | ] 49 | ``` 50 | -------------------------------------------------------------------------------- /docs/API/db_albums.md: -------------------------------------------------------------------------------- 1 | **Get Albums** 2 | ---- 3 | Gets all albums 4 | 5 | * **URL** 6 | 7 | /db/albums 8 | 9 | * **Method:** 10 | 11 | `GET` 12 | 13 | * **Success Response:** 14 | 15 | * **Code:** 200
16 | **Content:** List of albums 17 | 18 | ``` 19 | { albums: ['Album 1', 'Album 2', 'Album 3'] } 20 | ``` 21 | -------------------------------------------------------------------------------- /docs/API/db_artists-albums.md: -------------------------------------------------------------------------------- 1 | **Get All Albums for an Artist** 2 | ---- 3 | Will retrieve all albums for a given artist name 4 | 5 | * **URL** 6 | 7 | /db/artists-albums 8 | 9 | * **Method:** 10 | 11 | `POST` 12 | 13 | * **JSON Params** 14 | 15 | **Required:** 16 | 17 | `artist` - artist name 18 | 19 | 20 | * **JSON Example** 21 | 22 | ``` 23 | { 24 | 'artist': 'Artist Name' 25 | } 26 | ``` 27 | 28 | * **Success Response:** 29 | 30 | * **Code:** 200
31 | **Content:** `{ albums: ['Album1', 'Album2', 'Album3'] }` 32 | -------------------------------------------------------------------------------- /docs/API/db_artists.md: -------------------------------------------------------------------------------- 1 | **Get Artists** 2 | ---- 3 | Gets all artists 4 | 5 | * **URL** 6 | 7 | /db/artists 8 | 9 | * **Method:** 10 | 11 | `GET` 12 | 13 | * **Success Response:** 14 | 15 | * **Code:** 200
16 | **Content:** List of artists 17 | 18 | ``` 19 | { artists: ['Artist 1', 'Artist 2', 'Artist 3'] } 20 | ``` 21 | -------------------------------------------------------------------------------- /docs/API/db_metadata.md: -------------------------------------------------------------------------------- 1 | **Get Metadata From DB** 2 | ---- 3 | Retrieves albums and artists that match a given string 4 | 5 | * **URL** 6 | 7 | /db/metadata 8 | 9 | * **Method:** 10 | 11 | `POST` 12 | 13 | * **JSON Params** 14 | 15 | **Required:** 16 | 17 | `filepath` - filepath of song 18 | 19 | * **JSON Example** 20 | 21 | ``` 22 | { 23 | 'filepath': '/path/to/file' 24 | } 25 | ``` 26 | 27 | * **Success Response:** 28 | 29 | * **Code:** 200
30 | **Content:** 31 | 32 | ``` 33 | { 34 | "filepath":"/path/to/file.mp3", 35 | "metadata":{ 36 | "artist": "Artist", 37 | "album": "Album", 38 | "track": 1, 39 | "title": "Song Title", 40 | "year": 1990, 41 | "album-art": "hash.jpg", 42 | "hash": "md5 hash" 43 | } 44 | } 45 | ``` 46 | -------------------------------------------------------------------------------- /docs/API/db_recursive-scan.md: -------------------------------------------------------------------------------- 1 | **DB Status** 2 | ---- 3 | Starts a scan 4 | 5 | * **URL** 6 | 7 | /db/recursive-scan 8 | 9 | * **Method:** 10 | 11 | `GET` 12 | 13 | * **Success Response:** 14 | 15 | * **Code:** 200
16 | **Content:** 17 | 18 | **Note** 19 | 20 | Output for this call has not been formatted yet 21 | -------------------------------------------------------------------------------- /docs/API/db_search.md: -------------------------------------------------------------------------------- 1 | **Search DB** 2 | ---- 3 | Retrieves albums and artists that much a given string 4 | 5 | * **URL** 6 | 7 | /db/search 8 | 9 | * **Method:** 10 | 11 | `POST` 12 | 13 | * **JSON Params** 14 | 15 | **Required:** 16 | 17 | `search` - String that will be searched for 18 | 19 | * **JSON Example** 20 | 21 | ``` 22 | { 23 | 'search': 'The Offsp' 24 | } 25 | ``` 26 | 27 | * **Success Response:** 28 | 29 | * **Code:** 200
30 | **Content:** 31 | 32 | ``` 33 | { 34 | "albums":[album1, album2], 35 | "artists":[artist1, artist2, artist3] 36 | } 37 | ``` 38 | -------------------------------------------------------------------------------- /docs/API/db_status.md: -------------------------------------------------------------------------------- 1 | **DB Status** 2 | ---- 3 | Checks if a scan is in progress. 4 | 5 | * **URL** 6 | 7 | /db/status 8 | 9 | * **Method:** 10 | 11 | `GET` 12 | 13 | * **Success Response:** 14 | 15 | * **Code:** 200
16 | **Content:** 17 | 18 | ``` 19 | { 20 | locked: false, 21 | totalFileCount: 150 22 | } 23 | ``` 24 | 25 | `locked` will be true if a scan is in progress. -------------------------------------------------------------------------------- /docs/API/dirparser.md: -------------------------------------------------------------------------------- 1 | **Get Directory Contents** 2 | ---- 3 | Used to make a file browser. Dirparser will only return contents that are music files or other directories. Users will not be able to see any other files 4 | 5 | * **URL** 6 | 7 | /dirparser 8 | 9 | * **Method:** 10 | 11 | `POST` 12 | 13 | * **JSON Params** 14 | 15 | **Required:** 16 | 17 | `dir` - directory to get contents from 18 | 19 | **Optional:** 20 | 21 | `filetypes` - limit filetypes of returned responses. Useful is your platform does not support all filetypes 22 | 23 | * **JSON Example** 24 | 25 | ``` 26 | { 27 | 'dir':'current/directory/', 28 | 'filetypes':['mp3', 'wav', 'flac'] 29 | } 30 | ``` 31 | 32 | * **Success Response:** 33 | 34 | * **Code:** 200
35 | **Content:** 36 | 37 | ``` 38 | { 39 | path: 'current/directory/', 40 | contents: [{ type: 'directory', name: 'folder1'}, { type: 'mp3', name: 'file1.mp3'}] 41 | } 42 | ``` 43 | 44 | 'type' will either be 'directory' or the file extension. 45 | 46 | * **Error Response:** 47 | 48 | * **Code:** 500 NOT FOUND
49 | **Content:** `{ error: 'Not a directory' }` 50 | -------------------------------------------------------------------------------- /docs/API/download.md: -------------------------------------------------------------------------------- 1 | **Download Files** 2 | ---- 3 | Will zip files up and them download the zip file 4 | 5 | * **URL** 6 | 7 | /download 8 | 9 | * **Method:** 10 | 11 | `POST` 12 | 13 | * **Params** 14 | 15 | The download endpoint gets the list of files to download through the POST param `fileArray`. The reason for this is due to the finicky way some browsers handle downloads. 16 | 17 | * **JSON Example** 18 | 19 | ``` 20 | ['path/to/file1.mp3', path/to/file2.flac] 21 | ``` 22 | 23 | * **Success Response:** 24 | 25 | Will download a zip file 26 | -------------------------------------------------------------------------------- /docs/API/jukebox_push-to-client.md: -------------------------------------------------------------------------------- 1 | **Push Message To Jukebox Instance** 2 | ---- 3 | Send a message to a client running in Jukebox Mode 4 | 5 | * **URL** 6 | 7 | /jukebox/push-to-client 8 | 9 | * **Method:** 10 | 11 | `POST` 12 | 13 | * **JSON Params** 14 | 15 | **Required:** 16 | 17 | `code` - This is the code generated when starting Jukebox Mode
18 | `command` - Command to push to client 19 | 20 | **Optional:** 21 | 22 | `file` - filepath for adding files to playlist 23 | 24 | * **JSON Example** 25 | 26 | ``` 27 | { 28 | 'code': '59305', 29 | 'command': 'addSong', 30 | 'file': '/path/to/file.flac' 31 | } 32 | ``` 33 | 34 | * **List Of Commands** 35 | 36 | If the command does not match one of the following, the server will return an error 37 | 38 | - `next` 39 | - `previous` 40 | - `playPause` 41 | - `addSong` 42 | - `getPlaylist` (not currently implemented) 43 | - `removeSong` (not currently implemented) 44 | 45 | Users with Guest Codes will only have access to `addSong` and `getPlaylist` 46 | 47 | 48 | * **Success Response:** 49 | 50 | * **Code:** 200
51 | **Content:** 52 | 53 | ``` 54 | { status: 'done' } 55 | ``` 56 | 57 | * **NOTES:** 58 | 59 | - Additional functions to limit guest access will be added in the future 60 | - Returns a 500 error if the client code could not be found 61 | -------------------------------------------------------------------------------- /docs/API/login.md: -------------------------------------------------------------------------------- 1 | **Login** 2 | ---- 3 | Use this to get a token that can be used to access the rest of the API 4 | 5 | * **URL** 6 | 7 | /login 8 | 9 | * **Method:** 10 | 11 | `POST` 12 | 13 | * **JSON Params** 14 | 15 | **Required:** 16 | 17 | `username`
18 | `password` 19 | 20 | * **JSON Example** 21 | 22 | ``` 23 | { 24 | 'username': 'root', 25 | 'password': 'qwerty' 26 | } 27 | ``` 28 | 29 | * **Success Response:** 30 | 31 | ``` 32 | { 33 | success: true, 34 | message: 'Welcome To mStream', 35 | vpaths: ['path1', 'path2'], 36 | token: 'REALLY LONG STRING' 37 | } 38 | ``` 39 | 40 | * **Error Response:** 41 | 42 | All errors forward to `/login-failed` 43 | -------------------------------------------------------------------------------- /docs/API/ping.md: -------------------------------------------------------------------------------- 1 | **Ping** 2 | ---- 3 | Used to check if the user is logged in. Also used to get the vPath 4 | 5 | * **URL** 6 | 7 | /ping 8 | 9 | * **Method:** 10 | 11 | `GET` 12 | 13 | * **Success Response:** 14 | 15 | ``` 16 | { 17 | vpaths: ['path1', 'path2'], 18 | guest: false 19 | } 20 | ``` 21 | 22 | Returns whether user is a guest or not. Guest accounts don't have write access 23 | 24 | * **Error Response:** 25 | 26 | Forwards to `/login-failed` if not logged in 27 | -------------------------------------------------------------------------------- /docs/API/playlist_delete.md: -------------------------------------------------------------------------------- 1 | **Delete Playlist** 2 | ---- 3 | Delete a playlist 4 | 5 | * **URL** 6 | 7 | /playlist/delete 8 | 9 | * **Method:** 10 | 11 | `POST` 12 | 13 | * **JSON Params** 14 | 15 | **Required:** 16 | 17 | `playlistname` - The name of the playlist
18 | 19 | **Optional:** 20 | 21 | `hide` - Boolean Value - If set the playlist will not be deleted but set to a status of 'hidden'. 22 | 23 | * **JSON Example** 24 | 25 | ``` 26 | { 27 | 'playlistname': 'Best of Bieber', 28 | 'hide': false 29 | } 30 | ``` 31 | 32 | * **Success Response:** 33 | 34 | * **Code:** 200
35 | **Content:** `{success: true}` 36 | -------------------------------------------------------------------------------- /docs/API/playlist_getall.md: -------------------------------------------------------------------------------- 1 | **Get All Playlists** 2 | ---- 3 | Gets all playlists 4 | 5 | * **URL** 6 | 7 | /playlist/getall 8 | 9 | * **Method:** 10 | 11 | `GET` 12 | 13 | * **Success Response:** 14 | 15 | * **Code:** 200
16 | **Content:** List of playlists names 17 | 18 | ``` 19 | [{"name": "Driving Music"}, {"name": "Best Of The 80s"}] 20 | ``` 21 | -------------------------------------------------------------------------------- /docs/API/playlist_load.md: -------------------------------------------------------------------------------- 1 | **Load Playlist** 2 | ---- 3 | Load a playlist 4 | 5 | * **URL** 6 | 7 | /playlist/load 8 | 9 | * **Method:** 10 | 11 | `POST` 12 | 13 | * **Request Params** 14 | 15 | **Required:** 16 | `playlistname` - The name of the playlist 17 | 18 | 19 | * **Success Response:** 20 | 21 | * **Code:** 200
22 | **Content:** `[{filepath: 'path/to/file1.mp3', metadata: ''}, {filepath: 'path/to/file2.flac', metadata: ''}]` 23 | 24 | metadata fields are currently blank. A cache layer needs to be built before it's fast enough to lookup metadata for an entire playlists 25 | -------------------------------------------------------------------------------- /docs/API/playlist_save.md: -------------------------------------------------------------------------------- 1 | **Save Playlist** 2 | ---- 3 | Save a playlist 4 | 5 | * **URL** 6 | 7 | /playlist/save 8 | 9 | * **Method:** 10 | 11 | `POST` 12 | 13 | * **JSON Params** 14 | 15 | **Required:** 16 | 17 | `title` - The name of the playlist
18 | `songs` - Array of filepaths to save. I recommend removing the vPath before saving 19 | 20 | * **JSON Example** 21 | 22 | ``` 23 | { 24 | 'title': 'New Playlist', 25 | 'songs': [ 'path/to/song1.mp3', 'path/to/song2.mp3' ] 26 | } 27 | ``` 28 | 29 | * **Success Response:** 30 | 31 | * **Code:** 200
32 | **Content:** `{success: true}` 33 | -------------------------------------------------------------------------------- /docs/API/shared_get-token-and-playlist.md: -------------------------------------------------------------------------------- 1 | **Get Shared Token and Playlist** 2 | ---- 3 | Retrieves the playlist and access token for a shared playlist. The access token given restricts the user to access only the files in the playlist 4 | 5 | * **URL** 6 | 7 | /shared/get-token-and-playlist 8 | 9 | * **Method:** 10 | 11 | `POST` 12 | 13 | * **JSON Params** 14 | 15 | **Required:** 16 | 17 | `tokenid` - This is the ID needed to get the token and playlist 18 | 19 | * **JSON Example** 20 | 21 | ``` 22 | { 23 | 'tokenid': 'abc-123' 24 | } 25 | ``` 26 | 27 | * **Success Response:** 28 | 29 | * **Code:** 200
30 | **Content:** 31 | 32 | ``` 33 | { 34 | token: 'REALLY LONG STRING', 35 | playlist: ['/path/to/file1.mp3', /path/to/file2.mp3], 36 | vPath: 'RANDOM STRING' 37 | } 38 | ``` 39 | 40 | * **NOTE:** 41 | 42 | The playlist structure may change in the future to add metadata. 43 | -------------------------------------------------------------------------------- /docs/API/shared_make-shared.md: -------------------------------------------------------------------------------- 1 | **Share a Playlist** 2 | ---- 3 | Generates an access token for a shared playlist and saves the token/playlist under a UUID for easy retrieval 4 | 5 | * **URL** 6 | 7 | /shared/make-shared 8 | 9 | * **Method:** 10 | 11 | `POST` 12 | 13 | * **JSON Params** 14 | 15 | **Required:** 16 | 17 | `shareTimeInDays` - Token will expire after this period of time
18 | `playlist` - Playlist that will be shared 19 | 20 | 21 | * **JSON Example** 22 | 23 | ``` 24 | { 25 | 'shareTimeInDays': 14, 26 | 'playlist': ['/path/to/song1.mp3', '/path/to/song2/mp3'] 27 | } 28 | ``` 29 | 30 | * **Success Response:** 31 | 32 | * **Code:** 200
33 | **Content:** 34 | 35 | ``` 36 | { 37 | 'playlist_id': 'UUID_SRING', 38 | 'token': 'TOKEN_STRING' 39 | } 40 | ``` -------------------------------------------------------------------------------- /docs/API/upload.md: -------------------------------------------------------------------------------- 1 | **Upload Files** 2 | ---- 3 | This endpoint can be used to upload files. 4 | 5 | * **URL** 6 | 7 | /upload 8 | 9 | * **Method:** 10 | 11 | `POST` 12 | 13 | * **Headers** 14 | 15 | The directory to upload the files to must be included in the header `data-location`. If this not set, the call will fail 16 | 17 | * **Body** 18 | 19 | Put the files you want to upload in the request body 20 | 21 | * **Success Response:** 22 | 23 | * **Code:** 200
24 | **Content:** 25 | 26 | 27 | 28 | * **Error Response:** 29 | 30 | * **Code:** 500 NOT FOUND
31 | **Content:** `{ error: 'Not a directory' }` 32 | -------------------------------------------------------------------------------- /docs/deploy.md: -------------------------------------------------------------------------------- 1 | # Release Instructions 2 | 3 | Getting github actions to properly work requires a specific set of steps 4 | 5 | - Bump the version number of package.json. Make a commit with the message "vX.X.X" 6 | - Tag the commit `git tag vX.X.X` 7 | - run `git push && git push --tags` -------------------------------------------------------------------------------- /docs/designs/admin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/docs/designs/admin.png -------------------------------------------------------------------------------- /docs/designs/devices.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/docs/designs/devices.png -------------------------------------------------------------------------------- /docs/designs/devices2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/docs/designs/devices2.png -------------------------------------------------------------------------------- /docs/designs/mstream-concept.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/docs/designs/mstream-concept.png -------------------------------------------------------------------------------- /docs/designs/mstream-express-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/docs/designs/mstream-express-2.png -------------------------------------------------------------------------------- /docs/designs/mstream-express.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/docs/designs/mstream-express.png -------------------------------------------------------------------------------- /docs/designs/mstream-icon.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/docs/designs/mstream-icon.jpg -------------------------------------------------------------------------------- /docs/designs/mstream-icon.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/docs/designs/mstream-icon.psd -------------------------------------------------------------------------------- /docs/designs/mstreamV1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/docs/designs/mstreamV1.png -------------------------------------------------------------------------------- /docs/designs/mstreamV2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/docs/designs/mstreamV2.png -------------------------------------------------------------------------------- /docs/designs/mstreamv4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/docs/designs/mstreamv4.png -------------------------------------------------------------------------------- /docs/designs/mstreamv5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/docs/designs/mstreamv5.png -------------------------------------------------------------------------------- /docs/designs/shared.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/docs/designs/shared.png -------------------------------------------------------------------------------- /docs/electron.md: -------------------------------------------------------------------------------- 1 | # Testing and Developing with Electron 2 | 3 | ```bash 4 | # Install Electron 5 | npm install -g electron 6 | 7 | # Boot mStream with Electron 8 | electron ./cli-boot-wrapper.js 9 | ``` 10 | 11 | # Compile with Electron Builder 12 | 13 | All configuration for Electron Builder is stored in package.json 14 | 15 | ```shell 16 | # Install 17 | npm install -g electron-builder 18 | 19 | # Compile 20 | electron-builder 21 | ``` 22 | 23 | ## Modify package.json (optional) 24 | 25 | Remove all dependencies related to the command line (commander). These packages will never be used by mStream Express and can be safely removed to reduce the output size 26 | 27 | ## Cleanup node_modules (optional) 28 | 29 | Modclean can be used to clean out the node_modules folder of useless files. This deletes over 1000 useless files saving space and shortening install time. To install modlcean, run: 30 | 31 | ```shell 32 | # Install modclean 33 | npm install -g modclean 34 | 35 | # Run modclean 36 | modclean 37 | ``` 38 | -------------------------------------------------------------------------------- /docs/install.md: -------------------------------------------------------------------------------- 1 | ## Install on Ubuntu 2 | 3 | **Dependencies** 4 | 5 | * NodeJS and NPM 6 | * git 7 | 8 | [How to Install NodeJS](https://nodejs.org/en/download/package-manager/) 9 | 10 | # Install mStream 11 | 12 | ```shell 13 | git clone https://github.com/IrosTheBeggar/mStream.git 14 | 15 | cd mStream 16 | 17 | # Install dependencies and run 18 | npm run-script wizard 19 | ``` 20 | 21 | # Running mStream as a Background Process 22 | 23 | We will use [PM2](https://pm2.keymetrics.io/) to run mStream as a background process 24 | 25 | ```shell 26 | # Install PM2 27 | npm install -g pm2 28 | 29 | # Run app 30 | pm2 start cli-boot-wrapper.js --name mStream 31 | ``` 32 | 33 | [See the PM2 docs for more information](https://pm2.keymetrics.io/docs/usage/quick-start/) 34 | 35 | # Updating mStream 36 | 37 | To update mStream just pull the changes from git and reboot your server 38 | 39 | ```shell 40 | git pull 41 | npm install --only=prod 42 | # Reboot mStream with PM2 43 | pm2 restart all 44 | ``` 45 | -------------------------------------------------------------------------------- /image-cache/README.md: -------------------------------------------------------------------------------- 1 | Album art is stored here 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mstream", 3 | "version": "5.13.1", 4 | "description": "music streaming server", 5 | "main": "cli-boot-wrapper.js", 6 | "bin": { 7 | "mstream": "cli-boot-wrapper.js" 8 | }, 9 | "engines": { 10 | "node": ">=10.16.0" 11 | }, 12 | "scripts": { 13 | "start": "node cli-boot-wrapper.js", 14 | "pack": "electron-builder --dir", 15 | "dist": "electron-builder", 16 | "wizard": "npm install --only=prod && node cli-boot-wrapper.js" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/IrosTheBeggar/mStream" 21 | }, 22 | "author": { 23 | "name": "Paul Sori", 24 | "email": "paul@mstream.io" 25 | }, 26 | "homepage": "https://mstream.io/", 27 | "license": "GPL-3.0", 28 | "build": { 29 | "appId": "io.mstream.server", 30 | "productName": "mStream Server", 31 | "electronVersion": "29.1.4", 32 | "asar": false, 33 | "files": [ 34 | "**/*", 35 | "!docs/*", 36 | "!dist/*", 37 | "!image-cache/*", 38 | "!save/*", 39 | "!bin/*", 40 | "!.git/*", 41 | "!.vscode/*", 42 | "!package-lock.json" 43 | ], 44 | "mac": { 45 | "files": [ 46 | "bin/syncthing/syncthing-osx" 47 | ], 48 | "category": "public.app-category.music", 49 | "binaries": [ 50 | "frp/mstream-ddns-osx", 51 | "sync/syncthing-osx" 52 | ] 53 | }, 54 | "win": { 55 | "files": [ 56 | "bin/syncthing/syncthing.exe" 57 | ], 58 | "target": [ 59 | { 60 | "target": "nsis", 61 | "arch": [ 62 | "x64" 63 | ] 64 | } 65 | ] 66 | }, 67 | "linux": { 68 | "files": [ 69 | "bin/syncthng/syncthing-linux" 70 | ], 71 | "target": [ 72 | { 73 | "target": "AppImage", 74 | "arch": [ 75 | "x64", 76 | "arm64", 77 | "armv7l" 78 | ] 79 | } 80 | ] 81 | }, 82 | "publish": { 83 | "provider": "github", 84 | "repo": "mStream", 85 | "owner": "IrosTheBeggar" 86 | } 87 | }, 88 | "dependencies": { 89 | "archiver": "^7.0.1", 90 | "axios": "^1.7.9", 91 | "busboy": "^1.6.0", 92 | "commander": "^12.1.0", 93 | "cookie-parser": "^1.4.7", 94 | "electron-updater": "^6.3.9", 95 | "escape-string-regexp": "^4.0.0", 96 | "express": "^4.21.2", 97 | "fast-xml-parser": "^4.5.1", 98 | "ffbinaries": "^1.1.6", 99 | "fluent-ffmpeg": "^2.1.3", 100 | "http-proxy": "^1.18.1", 101 | "jimp": "^0.22.12", 102 | "jimpv1": "npm:jimp@^1.6.0", 103 | "joi": "^17.13.3", 104 | "jsonwebtoken": "^9.0.2", 105 | "lokijs": "^1.5.12", 106 | "m3u8-parser": "^7.2.0", 107 | "make-dir": "^3.1.0", 108 | "mime-types": "^2.1.35", 109 | "mm-v10": "npm:music-metadata@^10.6.4", 110 | "music-metadata": "^7.14.0", 111 | "nanoid": "^3.3.6", 112 | "tree-kill": "^1.2.2", 113 | "winston": "^3.17.0", 114 | "winston-daily-rotate-file": "^5.0.0", 115 | "ws": "^8.18.0" 116 | }, 117 | "devDependencies": { 118 | "electron-builder": "25.1.8" 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /save/README.md: -------------------------------------------------------------------------------- 1 | DB and Config files go here -------------------------------------------------------------------------------- /save/conf/README.md: -------------------------------------------------------------------------------- 1 | mStream will use default.json if no config file is specified -------------------------------------------------------------------------------- /save/db/README.md: -------------------------------------------------------------------------------- 1 | DB files get saved her by default -------------------------------------------------------------------------------- /save/logs/README.md: -------------------------------------------------------------------------------- 1 | Logs Go Here -------------------------------------------------------------------------------- /save/sync/README.md: -------------------------------------------------------------------------------- 1 | Syncthng stuff goes here -------------------------------------------------------------------------------- /src/api/auth.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken'); 2 | const Joi = require('joi'); 3 | const winston = require('winston'); 4 | const auth = require('../util/auth'); 5 | const config = require('../state/config'); 6 | const shared = require('../api/shared'); 7 | const WebError = require('../util/web-error'); 8 | 9 | exports.setup = (mstream) => { 10 | mstream.post('/api/v1/auth/login', async (req, res) => { 11 | try { 12 | const schema = Joi.object({ 13 | username: Joi.string().required(), 14 | password: Joi.string().required() 15 | }); 16 | await schema.validateAsync(req.body); 17 | 18 | if (!config.program.users[req.body.username]) { throw new Error('user not found'); } 19 | 20 | await auth.authenticateUser(config.program.users[req.body.username].password, config.program.users[req.body.username].salt, req.body.password) 21 | 22 | const token = jwt.sign({ username: req.body.username }, config.program.secret); 23 | 24 | res.cookie('x-access-token', token, { 25 | maxAge: 157784630000, // 5 years in ms 26 | sameSite: 'Strict', 27 | }); 28 | 29 | res.json({ 30 | vpaths: config.program.users[req.body.username].vpaths, 31 | token: token 32 | }); 33 | } catch (err) { 34 | winston.warn(`Failed login attempt from ${req.ip}. Username: ${req.body.username}`, { stack: err }); 35 | setTimeout(() => { res.status(401).json({ error: 'Login Failed' }); }, 800); 36 | } 37 | }); 38 | 39 | mstream.use((req, res, next) => { 40 | // Handle No Users 41 | if (Object.keys(config.program.users).length === 0 42 | && !req.path.startsWith('/api/v1/scanner/') 43 | ) { 44 | req.user = { 45 | vpaths: Object.keys(config.program.folders), 46 | username: 'mstream-user', 47 | admin: true 48 | }; 49 | 50 | return next(); 51 | } 52 | 53 | const token = req.body.token || req.query.token || req.headers['x-access-token'] || req.cookies['x-access-token']; 54 | if (!token) { throw new WebError('Authentication Error', 401); } 55 | req.token = token; 56 | 57 | const decoded = jwt.verify(token, config.program.secret); 58 | 59 | if (decoded.scan === true && req.path.startsWith('/api/v1/scanner/')) { 60 | req.scanApproved = true; 61 | return next(); 62 | } 63 | 64 | // handle federation invite tokens 65 | if (decoded.invite && decoded.invite === true) { 66 | // Invite tokens can only be used with one API path 67 | if (req.path === '/federation/invite/exchange') { return next(); } 68 | throw new WebError('Authentication Error', 401); 69 | } 70 | 71 | if (!decoded.username || !config.program.users[decoded.username]) { 72 | throw new WebError('Authentication Error', 401); 73 | } 74 | 75 | req.user = config.program.users[decoded.username]; 76 | req.user.username = decoded.username; 77 | 78 | // Handle Shared Tokens 79 | if (decoded.shareToken && decoded.shareToken === true) { 80 | const playlistItem = shared.lookupPlaylist(decoded.playlistId); 81 | 82 | if ( 83 | req.path !== '/api/v1/download/shared' && 84 | req.path !== '/api/v1/db/metadata' && 85 | req.path.substring(0,11) !== '/album-art/' && 86 | playlistItem.playlist.indexOf(decodeURIComponent(req.path).slice(7)) === -1 87 | ) { 88 | throw new WebError('Authentication Error', 401); 89 | } 90 | 91 | req.sharedPlaylistId = decoded.playlistId; 92 | } 93 | 94 | next(); 95 | }); 96 | } -------------------------------------------------------------------------------- /src/api/download.js: -------------------------------------------------------------------------------- 1 | const archiver = require('archiver'); 2 | const path = require('path'); 3 | const fs = require('fs').promises; 4 | const winston = require('winston'); 5 | const vpath = require('../util/vpath'); 6 | const shared = require('../api/shared'); 7 | const m3u = require('../util/m3u'); 8 | const WebError = require('../util/web-error'); 9 | 10 | exports.setup = (mstream) => { 11 | mstream.post('/api/v1/download/m3u', (req, res) => { 12 | // custom wrap download functions to avoid an error with the archiver module 13 | downloadM3U(req, res).catch(err => { 14 | throw err; 15 | }) 16 | }); 17 | 18 | async function downloadM3U(req, res) { 19 | if (!req.body.path) { throw new WebError('Validation Error', 403); } 20 | const pathInfo = vpath.getVPathInfo(req.body.path, req.user); 21 | const playlistParentDir = path.dirname(pathInfo.fullPath); 22 | const songs = await m3u.readPlaylistSongs(pathInfo.fullPath); 23 | 24 | const archive = archiver('zip'); 25 | archive.on('error', function (err) { 26 | winston.error('Download Error', { stack: err }); 27 | res.status(500).json({ error: err.message }); 28 | }); 29 | 30 | res.attachment(`${path.basename(req.body.path)}.zip`); 31 | archive.pipe(res); 32 | for (let song of songs) { 33 | const songPath = path.join(playlistParentDir, song); 34 | archive.file(songPath, { name: path.basename(song) }); 35 | } 36 | 37 | archive.file(pathInfo.fullPath, { name: path.basename(pathInfo.fullPath) }); 38 | archive.finalize(); 39 | } 40 | 41 | mstream.post('/api/v1/download/directory', (req, res) => { 42 | downloadDir(req, res).catch(err => { 43 | throw err; 44 | }) 45 | }); 46 | 47 | async function downloadDir(req, res) { 48 | if (!req.body.directory) { throw new WebError('Validation Error', 403); } 49 | 50 | const pathInfo = vpath.getVPathInfo(req.body.directory, req.user); 51 | if (!(await fs.stat(pathInfo.fullPath)).isDirectory()) { throw new Error('Not A Directory'); } 52 | 53 | const archive = archiver('zip'); 54 | archive.on('error', (err) => { 55 | winston.error('Download Error', { stack: err }) 56 | res.status(500).json({ error: 'Download Error' }); 57 | }); 58 | 59 | res.attachment('mstream-directory.zip'); 60 | 61 | archive.pipe(res); 62 | 63 | archive.directory(pathInfo.basePath, false); 64 | archive.finalize(); 65 | } 66 | 67 | mstream.get('/api/v1/download/shared', (req, res) => { 68 | if (!req.sharedPlaylistId) { throw new WebError('Missing Playlist Id', 403); } 69 | const fileArray = shared.lookupPlaylist(req.sharedPlaylistId).playlist; 70 | download(req, res, fileArray).catch(err => { 71 | throw err; 72 | }); 73 | }); 74 | 75 | mstream.post('/api/v1/download/zip', (req, res) => { 76 | const fileArray = JSON.parse(req.body.fileArray); 77 | download(req, res, fileArray).catch(err => { 78 | throw err; 79 | }); 80 | }); 81 | 82 | async function download(req, res, fileArray) { 83 | const archive = archiver('zip'); 84 | 85 | archive.on('error', err => { 86 | winston.error('Download Error', { stack: err }) 87 | res.status(500).json({ error: typeof err === 'string' ? err : 'Unknown Error' }); 88 | }); 89 | 90 | res.attachment(`mstream-playlist.zip`); 91 | 92 | //streaming magic 93 | archive.pipe(res); 94 | 95 | for(const file of fileArray) { 96 | try { 97 | const pathInfo = vpath.getVPathInfo(file, req.user); 98 | await fs.access(pathInfo.fullPath); 99 | archive.file(pathInfo.fullPath, { name: path.basename(file) }); 100 | } catch (err) { continue; } 101 | } 102 | 103 | archive.finalize(); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/api/file-explorer.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const fs = require('fs').promises; 3 | const fsOld = require('fs'); 4 | const busboy = require("busboy"); 5 | const Joi = require('joi'); 6 | const mkdirp = require('make-dir'); 7 | const winston = require('winston'); 8 | const fileExplorer = require('../util/file-explorer'); 9 | const vpath = require('../util/vpath'); 10 | const m3u = require('../util/m3u'); 11 | const config = require('../state/config'); 12 | const { joiValidate } = require('../util/validation'); 13 | const WebError = require('../util/web-error'); 14 | 15 | exports.setup = (mstream) => { 16 | mstream.post("/api/v1/file-explorer", async (req, res) => { 17 | const schema = Joi.object({ 18 | directory: Joi.string().allow("").required(), 19 | sort: Joi.boolean().default(true), 20 | pullMetadata: Joi.boolean().default(false) 21 | }); 22 | const { value } = joiValidate(schema, req.body); 23 | 24 | // Convenience functions to get the most useful directory 25 | if (value.directory === "~") { 26 | if (req.user.vpaths.length !== 1) { 27 | value.directory = ""; 28 | } else { 29 | value.directory = `/${req.user.vpaths[0]}`; 30 | } 31 | } 32 | 33 | // Return vpaths if no path is given 34 | if (value.directory === "" || value.directory === "/") { 35 | const directories = []; 36 | for (let dir of req.user.vpaths) { 37 | directories.push({ name: dir }); 38 | } 39 | return res.json({ path: "/", directories: directories, files: [] }); 40 | } 41 | 42 | // Get vPath Info 43 | const pathInfo = vpath.getVPathInfo(value.directory, req.user); 44 | 45 | // Do not allow browsing outside the directory 46 | if (pathInfo.fullPath.substring(0, pathInfo.basePath.length) !== pathInfo.basePath) { 47 | winston.warn(`user '${req.user.username}' attempted to access a directory they don't have access to: ${pathInfo.fullPath}`) 48 | throw new Error('Access to directory not allowed'); 49 | } 50 | 51 | // get directory contents 52 | const folderContents = await fileExplorer.getDirectoryContents(pathInfo.fullPath, config.program.supportedAudioFiles, value.sort, value.pullMetadata, value.directory, req.user); 53 | 54 | // Format directory string for return value 55 | let returnDirectory = path.join(pathInfo.vpath, pathInfo.relativePath); 56 | returnDirectory = returnDirectory.replace(/\\/g, "/"); // Formatting for windows paths 57 | 58 | // Make sure we have a slash at the beginning & end 59 | if (returnDirectory.slice(1) !== "/") { returnDirectory = "/" + returnDirectory; } 60 | if (returnDirectory.slice(-1) !== "/") { returnDirectory += "/"; } 61 | 62 | res.json({ 63 | path: returnDirectory, 64 | files: folderContents.files, 65 | directories: folderContents.directories 66 | }); 67 | }); 68 | 69 | async function recursiveFileScan(directory, fileList, relativePath, vPath) { 70 | for (const file of await fs.readdir(directory)) { 71 | try { 72 | var stat = await fs.stat(path.join(directory, file)); 73 | } catch (e) { continue; } /* Bad file or permission error, ignore and continue */ 74 | 75 | if (stat.isDirectory()) { 76 | await recursiveFileScan(path.join(directory, file), fileList, path.join(relativePath, file), vPath); 77 | } else { 78 | const extension = fileExplorer.getFileType(file).toLowerCase(); 79 | if (config.program.supportedAudioFiles[extension] === true) { 80 | fileList.push(path.join(vPath, path.join(relativePath, file)).replace(/\\/g, "/")); 81 | } 82 | } 83 | } 84 | return fileList; 85 | } 86 | 87 | mstream.post("/api/v1/file-explorer/recursive", async (req, res) => { 88 | const schema = Joi.object({ directory: Joi.string().required() }); 89 | joiValidate(schema, req.body); 90 | 91 | // Get vPath Info 92 | const pathInfo = vpath.getVPathInfo(req.body.directory, req.user); 93 | 94 | // Do not allow browsing outside the directory 95 | if (pathInfo.fullPath.substring(0, pathInfo.basePath.length) !== pathInfo.basePath) { 96 | winston.warn(`user '${req.user.username}' attempted to access a directory they don't have access to: ${pathInfo.fullPath}`) 97 | throw new Error('Access to directory not allowed'); 98 | } 99 | 100 | res.json(await recursiveFileScan(pathInfo.fullPath, [], pathInfo.relativePath, pathInfo.vpath)); 101 | }); 102 | 103 | mstream.post('/api/v1/file-explorer/upload', (req, res) => { 104 | if (config.program.noUpload === true) { throw new WebError('Uploading Disabled'); } 105 | if (!req.headers['data-location']) { throw new WebError('No Location Provided', 403); } 106 | 107 | const pathInfo = vpath.getVPathInfo(decodeURI(req.headers['data-location']), req.user); 108 | mkdirp.sync(pathInfo.fullPath); 109 | 110 | const bb = busboy({ headers: req.headers }); 111 | bb.on('file', (fieldname, file, info) => { 112 | const { filename } = info; 113 | const saveTo = path.join(pathInfo.fullPath, filename); 114 | winston.info(`Uploading from ${req.user.username} to: ${saveTo}`); 115 | file.pipe(fsOld.createWriteStream(saveTo)); 116 | }); 117 | 118 | bb.on('close', () => { res.json({}); }); 119 | req.pipe(bb); 120 | }); 121 | 122 | mstream.post("/api/v1/file-explorer/m3u", async (req, res) => { 123 | const pathInfo = vpath.getVPathInfo(req.body.path, req.user); 124 | 125 | const playlistParentDir = path.dirname(req.body.path); 126 | const songs = await m3u.readPlaylistSongs(pathInfo.fullPath); 127 | res.json({ 128 | files: songs.map((song) => { 129 | return { 130 | type: fileExplorer.getFileType(song), 131 | name: path.basename(song), 132 | path: path.join(playlistParentDir, song).replace(/\\/g, '/') 133 | }; 134 | }) 135 | }); 136 | }); 137 | } -------------------------------------------------------------------------------- /src/api/playlist.js: -------------------------------------------------------------------------------- 1 | const Joi = require('joi'); 2 | const config = require('../state/config'); 3 | const db = require('../db/manager'); 4 | const { joiValidate } = require('../util/validation'); 5 | 6 | exports.setup = (mstream) => { 7 | // TODO: This is a legacy endpoint that should be improved 8 | mstream.get('/api/v1/ping', (req, res) => { 9 | let transcode = false; 10 | if (config.program.transcode && config.program.transcode.enabled) { 11 | transcode = { 12 | defaultCodec: config.program.transcode.defaultCodec, 13 | defaultBitrate: config.program.transcode.defaultBitrate, 14 | defaultAlgorithm: config.program.transcode.algorithm 15 | } 16 | } 17 | 18 | const returnThis = { 19 | vpaths: req.user.vpaths, 20 | playlists: getPlaylists(req.user.username), 21 | transcode, 22 | vpathMetaData: {} 23 | }; 24 | 25 | req.user.vpaths.forEach(p => { 26 | if (config.program.folders[p]) { 27 | returnThis.vpathMetaData[p] = { 28 | type: config.program.folders[p].type 29 | }; 30 | } 31 | }); 32 | 33 | res.json(returnThis); 34 | }); 35 | 36 | mstream.post('/api/v1/playlist/delete', (req, res) => { 37 | const schema = Joi.object({ playlistname: Joi.string().required() }); 38 | joiValidate(schema, req.body); 39 | 40 | if (!db.getPlaylistCollection()) { throw new Error('DB Error'); } 41 | 42 | db.getPlaylistCollection().findAndRemove({ 43 | '$and': [ 44 | { 'user': { '$eq': req.user.username }}, 45 | { 'name': { '$eq': req.body.playlistname }} 46 | ] 47 | }); 48 | 49 | db.saveUserDB(); 50 | res.json({}); 51 | }); 52 | 53 | mstream.post('/api/v1/playlist/add-song', (req, res) => { 54 | const schema = Joi.object({ 55 | song: Joi.string().required(), 56 | playlist: Joi.string().required() 57 | }); 58 | joiValidate(schema, req.body); 59 | 60 | if (!db.getPlaylistCollection()) { throw new Error('No DB'); } 61 | db.getPlaylistCollection().insert({ 62 | name: req.body.playlist, 63 | filepath: req.body.song, 64 | user: req.user.username 65 | }); 66 | 67 | db.saveUserDB(); 68 | res.json({}); 69 | }); 70 | 71 | mstream.post('/api/v1/playlist/remove-song', (req, res) => { 72 | const schema = Joi.object({ lokiid: Joi.number().integer().required() }); 73 | joiValidate(schema, req.body); 74 | 75 | if (!db.getPlaylistCollection()) { throw new Error('No DB'); } 76 | const result = db.getPlaylistCollection().get(req.body.lokiid); 77 | if (result.user !== req.user.username) { 78 | throw new Error(`User ${req.user.username} tried accessing a resource they don't have access to. Playlist Loki ID: ${req.body.lokiid}`); 79 | } 80 | 81 | db.getPlaylistCollection().remove(result); 82 | db.saveUserDB(); 83 | res.json({}); 84 | }); 85 | 86 | mstream.post('/api/v1/playlist/new', (req, res) => { 87 | const schema = Joi.object({ title: Joi.string().required() }); 88 | joiValidate(schema, req.body); 89 | 90 | const results = db.getPlaylistCollection().findOne({ 91 | '$and': [ 92 | { 'user': { '$eq': req.user.username } }, 93 | { 'name': { '$eq': req.body.title } } 94 | ] 95 | }); 96 | 97 | if (results !== null) { 98 | return res.status(400).json({ error: 'Playlist Already Exists' }); 99 | } 100 | 101 | // insert null entry 102 | db.getPlaylistCollection().insert({ 103 | name: req.body.title, 104 | filepath: null, 105 | user: req.user.username, 106 | live: false 107 | }); 108 | 109 | db.saveUserDB(); 110 | res.json({}); 111 | }); 112 | 113 | mstream.post('/api/v1/playlist/save', (req, res) => { 114 | const schema = Joi.object({ 115 | title: Joi.string().required(), 116 | songs: Joi.array().items(Joi.string()), 117 | live: Joi.boolean().optional() 118 | }); 119 | joiValidate(schema, req.body); 120 | 121 | // Delete existing playlist 122 | db.getPlaylistCollection().findAndRemove({ 123 | '$and': [ 124 | { 'user': { '$eq': req.user.username } }, 125 | { 'name': { '$eq': req.body.title } } 126 | ] 127 | }); 128 | 129 | for (const song of req.body.songs) { 130 | db.getPlaylistCollection().insert({ 131 | name: req.body.title, 132 | filepath: song, 133 | user: req.user.username 134 | }); 135 | } 136 | 137 | // insert null entry 138 | db.getPlaylistCollection().insert({ 139 | name: req.body.title, 140 | filepath: null, 141 | user: req.user.username, 142 | live: typeof req.body.live === 'boolean' ? req.body.live : false 143 | }); 144 | 145 | 146 | db.saveUserDB(); 147 | res.json({}); 148 | }); 149 | 150 | mstream.get('/api/v1/playlist/getall', (req, res) => { 151 | res.json(getPlaylists(req.user.username)); 152 | }); 153 | 154 | function getPlaylists(username) { 155 | const playlists = []; 156 | 157 | const results = db.getPlaylistCollection().find({ 'user': { '$eq': username }, 'filepath': { '$eq': null } }); 158 | for (let row of results) { 159 | playlists.push({ name: row.name }); 160 | } 161 | return playlists; 162 | } 163 | } -------------------------------------------------------------------------------- /src/api/remote.js: -------------------------------------------------------------------------------- 1 | const url = require('url'); 2 | const path = require('path'); 3 | const fs = require('fs').promises; 4 | const Joi = require('joi'); 5 | const nanoid = require('nanoid'); 6 | const jwt = require('jsonwebtoken'); 7 | const WebSocketServer = require('ws').Server; 8 | const winston = require('winston'); 9 | const config = require('../state/config'); 10 | const { joiValidate } = require('../util/validation'); 11 | 12 | // list of currently connected clients (users) 13 | const clients = {}; 14 | // Map code to JWT 15 | const codeTokenMap = {}; 16 | const allowedCommands = [ 17 | 'next', 18 | 'previous', 19 | 'playPause', 20 | 'addSong', 21 | 'getPlaylist', 22 | 'removeSong', 23 | ]; 24 | 25 | exports.setupAfterAuth = (mstream, server) => { 26 | const wss = new WebSocketServer({ server: server, verifyClient: (info, cb) => { 27 | try { 28 | let decoded; 29 | if (config.program.users && Object.keys(config.program.users).length !== 0) { 30 | const token = url.parse(info.req.url, true).query.token; 31 | if (!token) { throw new Error('Token Not Found'); } 32 | decoded = jwt.verify(token, config.program.secret); 33 | } 34 | 35 | info.req.code = url.parse(info.req.url, true).query.code; 36 | if (info.req.code in clients) { throw new Error('Code In Use'); } 37 | 38 | info.req.jwt = jwt.sign({ 39 | username: decoded !== undefined ? decoded.username : 'mstream-user', 40 | jukebox: true 41 | }, config.program.secret); 42 | cb(true); 43 | }catch (err) { 44 | winston.error('WS Connection Failed', { stack: err }) 45 | cb(false, 401, 'Unauthorized'); 46 | } 47 | }}); 48 | 49 | wss.on('connection', (connection, req) => { 50 | const code = nanoid.nanoid(8); 51 | winston.info(`Websocket Connection Accepted With Code: ${code}`); 52 | clients[code] = connection; 53 | 54 | if (req.jwt) { codeTokenMap[code] = req.jwt; } 55 | 56 | connection.send(JSON.stringify({ code: code, token: req.jwt ? req.jwt : false })); 57 | 58 | // user sent message 59 | connection.on('message', (message) => { 60 | connection.send(JSON.stringify({ code: code })); 61 | }); 62 | 63 | connection.on('close', (connection) => { 64 | delete clients[code]; 65 | if (codeTokenMap[code]) {delete codeTokenMap[code];} 66 | }); 67 | }); 68 | 69 | 70 | mstream.post('/api/v1/jukebox/push-to-client', (req, res) => { 71 | const schema = Joi.object({ 72 | code: Joi.string().required(), 73 | command: Joi.string().required(), 74 | file: Joi.string().optional() 75 | }); 76 | joiValidate(schema, req.body); 77 | 78 | if (!(req.body.code in clients)) { 79 | throw new Error('Code Not Found'); 80 | } 81 | 82 | if (allowedCommands.indexOf(req.body.command) === -1) { 83 | throw new Error('Command Not Recognized'); 84 | } 85 | 86 | // Push commands to client 87 | clients[req.body.code].send(JSON.stringify({ command: req.body.command, file: req.body.file ? req.body.file : '' })); 88 | 89 | // Send confirmation back to user 90 | res.json({ }); 91 | }); 92 | } 93 | 94 | // This part is run before the login code 95 | exports.setupBeforeAuth = (mstream) => { 96 | mstream.post('/api/v1/jukebox/does-code-exist', (req, res) => { 97 | const clientCode = req.body.code; 98 | 99 | // Check that code exists 100 | if (!(clientCode in clients) || !(clientCode in codeTokenMap)) { 101 | return res.json({ status: false }); 102 | } 103 | 104 | res.json({ status: true, token: codeTokenMap[clientCode] }); 105 | }); 106 | 107 | mstream.get('/remote/:remoteId', async (req, res) => { 108 | const clientCode = req.params.remoteId; 109 | if (!(clientCode in clients) || !(clientCode in codeTokenMap)) { 110 | throw new Error('Token Not Found'); 111 | } 112 | 113 | let sharePage = await fs.readFile(path.join(config.program.webAppDirectory, 'remote/index.html'), 'utf-8'); 114 | sharePage = sharePage.replace(/\.\.\//g, '../../'); 115 | sharePage = sharePage.replace( 116 | '', 117 | `` 118 | ); 119 | res.send(sharePage); 120 | }); 121 | } 122 | -------------------------------------------------------------------------------- /src/api/scanner.js: -------------------------------------------------------------------------------- 1 | const winston = require('winston'); 2 | const db = require('../db/manager'); 3 | const config = require('../state/config'); 4 | 5 | exports.setup = (mstream) => { 6 | mstream.all('/api/v1/scanner/*', (req, res, next) => { 7 | if (req.scanApproved !== true) { return res.status(403).json({ error: 'Access Denied' }); } 8 | next(); 9 | }); 10 | 11 | mstream.post('/api/v1/scanner/get-file', (req, res) => { 12 | const dbObj = { '$and': [ 13 | { 'filepath': { '$eq': req.body.filepath } }, 14 | { 'vpath': { '$eq': req.body.vpath } } 15 | ]}; 16 | const dbFileInfo = db.getFileCollection().findOne(dbObj); 17 | 18 | // return empty response if nothing was found 19 | if (!dbFileInfo) { 20 | return res.json({}); 21 | } 22 | // if the file was edited, remove it from the DB 23 | // TODO: we need a way to handle metadata (like ratings) for modified files 24 | else if (req.body.modTime !== dbFileInfo.modified) { 25 | db.getFileCollection().findAndRemove(dbObj); 26 | return res.json({}); 27 | } 28 | // update the record with the new scan ID 29 | // This lets us clear out old files wit ha bulk delete at the end of the scan 30 | else { 31 | dbFileInfo.sID = req.body.scanId; 32 | db.getFileCollection().update(dbFileInfo); 33 | } 34 | 35 | res.json(dbFileInfo); 36 | }); 37 | 38 | mstream.post('/api/v1/scanner/finish-scan', (req, res) => { 39 | db.getFileCollection().findAndRemove({ '$and': [ 40 | { 'vpath': { '$eq': req.body.vpath } }, 41 | { 'sID': { '$ne': req.body.scanId } } 42 | ]}); 43 | 44 | db.saveFilesDB(); 45 | res.json({}); 46 | }); 47 | 48 | let saveCounter = 0; 49 | mstream.post('/api/v1/scanner/add-file', (req, res) => { 50 | db.getFileCollection().insert(req.body); 51 | res.json({}); 52 | 53 | saveCounter++; 54 | if(saveCounter > config.program.scanOptions.saveInterval) { 55 | saveCounter = 0; 56 | db.saveFilesDB(); 57 | } 58 | }); 59 | } -------------------------------------------------------------------------------- /src/api/scrobbler.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | const Joi = require('joi'); 3 | const axios = require('axios'); 4 | const config = require('../state/config'); 5 | const scribble = require('../state/lastfm'); 6 | const db = require('../db/manager'); 7 | const { joiValidate } = require('../util/validation'); 8 | const { getVPathInfo } = require('../util/vpath'); 9 | 10 | const Scrobbler = new scribble(); 11 | 12 | exports.setup = (mstream) => { 13 | Scrobbler.setKeys(config.program.lastFM.apiKey, config.program.lastFM.apiSecret) 14 | 15 | for (const user in config.program.users) { 16 | if (!config.program.users.hasOwnProperty(user)) { continue; } 17 | if (!config.program.users[user]['lastfm-user'] || !config.program.users[user]['lastfm-password']) { continue; } 18 | // TODO: Test Auth and alert user if it doesn't work 19 | Scrobbler.addUser(config.program.users[user]['lastfm-user'], config.program.users[user]['lastfm-password']); 20 | } 21 | 22 | mstream.post('/api/v1/lastfm/scrobble-by-metadata', (req, res) => { 23 | const schema = Joi.object({ 24 | artist: Joi.string().optional().allow(''), 25 | album: Joi.string().optional().allow(''), 26 | track: Joi.string().required(), 27 | }); 28 | joiValidate(schema, req.body); 29 | 30 | // TODO: update last-played field in DB 31 | if (!req.user['lastfm-user'] || !req.user['lastfm-password']) { 32 | return res.json({ scrobble: false }); 33 | } 34 | 35 | Scrobbler.Scrobble( 36 | req.body, 37 | req.user['lastfm-user'], 38 | (post_return_data) => { res.json({}); } 39 | ); 40 | }); 41 | 42 | mstream.post('/api/v1/lastfm/scrobble-by-filepath', (req, res) => { 43 | const schema = Joi.object({ 44 | filePath: Joi.string().required(), 45 | }); 46 | joiValidate(schema, req.body); 47 | 48 | // lookup metadata 49 | const pathInfo = getVPathInfo(req.body.filePath, req.user); 50 | 51 | const dbObj = { '$and': [ 52 | { 'filepath': { '$eq': pathInfo.relativePath } }, 53 | { 'vpath': { '$eq': pathInfo.vpath } } 54 | ]}; 55 | const dbFileInfo = db.getFileCollection().findOne(dbObj); 56 | 57 | if (!dbFileInfo) { 58 | return res.json({ scrobble: false }); 59 | } 60 | 61 | // log play 62 | const result = db.getUserMetadataCollection().findOne({ '$and':[{ 'hash': dbFileInfo.hash}, { 'user': req.user.username }] }); 63 | 64 | if (!result) { 65 | db.getUserMetadataCollection().insert({ 66 | user: req.user.username, 67 | hash: dbFileInfo.hash, 68 | pc: 1, 69 | lp: Date.now() 70 | }); 71 | } else { 72 | result.pc = result.pc && typeof result.pc === 'number' 73 | ? result.pc + 1 : 1; 74 | result.lp = Date.now(); 75 | 76 | db.getUserMetadataCollection().update(result); 77 | } 78 | 79 | db.saveUserDB(); 80 | res.json({}); 81 | 82 | if (req.user['lastfm-user'] && req.user['lastfm-password']) { 83 | // scrobble on last fm 84 | Scrobbler.Scrobble( 85 | { 86 | artist: dbFileInfo.artist, 87 | album: dbFileInfo.album, 88 | track: dbFileInfo.title 89 | }, 90 | req.user['lastfm-user'], 91 | (post_return_data) => {} 92 | ); 93 | } 94 | }); 95 | 96 | mstream.post('/api/v1/lastfm/test-login', async (req, res) => { 97 | const schema = Joi.object({ 98 | username: Joi.string().required(), 99 | password: Joi.string().required() 100 | }); 101 | joiValidate(schema, req.body); 102 | 103 | const token = crypto.createHash('md5').update(req.body.username + crypto.createHash('md5').update(req.body.password, 'utf8').digest("hex"), 'utf8').digest("hex"); 104 | const cryptoString = `api_key${config.program.apiKey}authToken${token}methodauth.getMobileSessionusername${req.body.username}${config.program.apiSecret}`; 105 | const hash = crypto.createHash('md5').update(cryptoString, 'utf8').digest("hex"); 106 | 107 | await axios({ 108 | method: 'GET', 109 | url: `http://ws.audioscrobbler.com/2.0/?method=auth.getMobileSession&username=${req.body.username}&authToken=${token}&api_key=${apiKey1}&api_sig=${hash}` 110 | }); 111 | res.json({}); 112 | }); 113 | } 114 | 115 | exports.reset = () => { 116 | Scrobbler.reset(); 117 | } 118 | -------------------------------------------------------------------------------- /src/api/shared.js: -------------------------------------------------------------------------------- 1 | const winston = require('winston'); 2 | const nanoId = require('nanoid'); 3 | const jwt = require('jsonwebtoken'); 4 | const path = require('path'); 5 | const fs = require('fs').promises; 6 | const Joi = require('joi'); 7 | const config = require('../state/config'); 8 | const db = require('../db/manager'); 9 | const { joiValidate } = require('../util/validation'); 10 | const WebError = require('../util/web-error'); 11 | 12 | function lookupShared(playlistId) { 13 | const playlistItem = db.getShareCollection().findOne({ 'playlistId': playlistId }); 14 | if (!playlistItem) { throw new WebError('Playlist Not Found'); } 15 | 16 | // make sure the token is still good 17 | jwt.verify(playlistItem.token, config.program.secret); 18 | return { 19 | token: playlistItem.token, 20 | playlist: playlistItem.playlist 21 | }; 22 | } 23 | 24 | exports.lookupPlaylist = (playlistId) => { 25 | return lookupShared(playlistId); 26 | } 27 | 28 | exports.setupBeforeSecurity = async (mstream) => { 29 | mstream.get('/shared/:playlistId', async (req, res) => { 30 | // don't end this with a slash. otherwise relative URLs don't work 31 | if (req.path.endsWith('/')) { 32 | const matchEnd = req.path.match(/(\/)+$/g); 33 | const queryString = req.url.match(/(\?.*)/g) === null ? '' : req.url.match(/(\?.*)/g); 34 | // redirect to a more sane URL 35 | return res.redirect(301, req.path.slice(0, (matchEnd[0].length)*-1) + queryString[0]); 36 | } 37 | 38 | if (!req.params.playlistId) { throw new WebError('Validation Error', 403); } 39 | let sharePage = await fs.readFile(path.join(config.program.webAppDirectory, 'shared/index.html'), 'utf-8'); 40 | sharePage = sharePage.replace( 41 | '', 42 | `` 43 | ); 44 | res.send(sharePage); 45 | }); 46 | 47 | mstream.get('/api/v1/shared/:playlistId', (req, res) => { 48 | if (!req.params.playlistId) { throw new WebError('Validation Error', 403); } 49 | res.json(lookupShared(req.params.playlistId)); 50 | }); 51 | } 52 | 53 | exports.setupAfterSecurity = async (mstream) => { 54 | mstream.post('/api/v1/share', (req, res) => { 55 | const schema = Joi.object({ 56 | playlist: Joi.array().items(Joi.string()).required(), 57 | time: Joi.number().integer().positive().optional() 58 | }); 59 | joiValidate(schema, req.body); 60 | 61 | // Setup Token Data 62 | const playlistId = nanoId.nanoid(10); 63 | 64 | const tokenData = { 65 | playlistId: playlistId, 66 | shareToken: true, 67 | username: req.user.username 68 | }; 69 | 70 | const jwtOptions = {}; 71 | if (req.body.time) { jwtOptions.expiresIn = `${req.body.time}d`; } 72 | const token = jwt.sign(tokenData, config.program.secret, jwtOptions) 73 | 74 | const sharedItem = { 75 | playlistId: playlistId, 76 | playlist: req.body.playlist, 77 | user: req.user.username, 78 | expires: req.body.time ? jwt.verify(token, config.program.secret).exp : null, 79 | token: token 80 | }; 81 | 82 | db.getShareCollection().insert(sharedItem); 83 | db.saveShareDB(); 84 | res.json(sharedItem); 85 | }); 86 | } 87 | -------------------------------------------------------------------------------- /src/api/transcode.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const ffbinaries = require("ffbinaries"); 3 | const ffmpeg = require("fluent-ffmpeg"); 4 | const winston = require('winston'); 5 | const vpath = require('../util/vpath'); 6 | const config = require('../state/config'); 7 | const { Readable } = require('stream'); 8 | 9 | const platform = ffbinaries.detectPlatform(); 10 | 11 | const codecMap = { 12 | 'mp3': { codec: 'libmp3lame', contentType: 'audio/mpeg' }, 13 | 'opus': { codec: 'libopus', contentType: 'audio/ogg' }, 14 | 'aac': { codec: 'aac', contentType: 'audio/aac' } 15 | }; 16 | 17 | const algoSet = new Set(['buffer', 'stream']); 18 | const bitrateSet = new Set(['64k', '128k', '192k', '96k']); 19 | 20 | exports.getTransAlgos = () => { 21 | return Array.from(algoSet); 22 | } 23 | 24 | exports.getTransBitrates = () => { 25 | return Array.from(bitrateSet); 26 | } 27 | 28 | exports.getTransCodecs = () => { 29 | return Object.keys(codecMap); 30 | } 31 | 32 | function initHeaders(res, audioTypeId, contentLength) { 33 | const contentType = codecMap[audioTypeId].contentType; 34 | return res.header({ 35 | 'Accept-Ranges': 'bytes', 36 | 'Content-Type': contentType, 37 | 'Content-Length': contentLength 38 | }); 39 | } 40 | 41 | let lockInit = false; 42 | let isDownloading = false; 43 | 44 | function init() { 45 | return new Promise((resolve, reject) => { 46 | // if (lockInit === true) { resolve(); } 47 | if (isDownloading === true) { reject('Download In Progress'); } 48 | isDownloading = true; 49 | winston.info('Checking ffmpeg...'); 50 | ffbinaries.downloadFiles( 51 | ["ffmpeg", "ffprobe"], 52 | { platform: platform, quiet: true, destination: config.program.transcode.ffmpegDirectory }, 53 | (err, data) => { 54 | isDownloading = false; 55 | if (err) { return reject(err); } 56 | 57 | try { 58 | winston.info('FFmpeg OK!'); 59 | const ffmpegPath = path.join(config.program.transcode.ffmpegDirectory, ffbinaries.getBinaryFilename("ffmpeg", platform)); 60 | const ffprobePath = path.join(config.program.transcode.ffmpegDirectory, ffbinaries.getBinaryFilename("ffprobe", platform)); 61 | ffmpeg.setFfmpegPath(ffmpegPath); 62 | ffmpeg.setFfprobePath(ffprobePath); 63 | lockInit = true; 64 | resolve(); 65 | }catch (err) { 66 | reject(err); 67 | } 68 | } 69 | ); 70 | }); 71 | } 72 | 73 | exports.reset = () => { 74 | lockInit = false; 75 | } 76 | 77 | exports.isEnabled = () => { 78 | if (lockInit === true && config.program.transcode.enabled === true) { 79 | return true; 80 | } 81 | 82 | return false; 83 | } 84 | 85 | exports.isDownloaded = () => { 86 | return lockInit; 87 | } 88 | 89 | exports.downloadedFFmpeg = async () => { 90 | await init(); 91 | } 92 | 93 | const transCache = {}; 94 | function ffmpegIt(pathInfo, codec, bitrate) { 95 | return ffmpeg(pathInfo.fullPath) 96 | .noVideo() 97 | .format(codec) 98 | .audioCodec(codecMap[codec].codec) 99 | .audioBitrate(bitrate) 100 | .on('end', () => { 101 | winston.info('FFmpeg: file has been converted successfully'); 102 | }) 103 | .on('error', err => { 104 | winston.error('Transcoding Error!', { stack: err }); 105 | winston.error(pathInfo.fullPath); 106 | }); 107 | } 108 | 109 | exports.setup = async mstream => { 110 | if (config.program.transcode.enabled === true) { 111 | init().catch(err => { 112 | winston.error('Failed to download FFmpeg', { stack: err }) 113 | }); 114 | } 115 | 116 | mstream.all("/transcode/*", (req, res) => { 117 | if (!config.program.transcode || config.program.transcode.enabled !== true) { 118 | return res.status(500).json({ error: 'transcoding disabled' }); 119 | } 120 | 121 | if (lockInit !== true) { 122 | return res.status(500).json({ error: 'transcoding disabled' }); 123 | } 124 | 125 | const codec = codecMap[req.query.codec] ? req.query.codec : config.program.transcode.defaultCodec; 126 | const algo = algoSet.has(req.query.algo) ? req.query.algo : config.program.transcode.algorithm; 127 | const bitrate = bitrateSet.has(req.query.bitrate) ? req.query.bitrate : config.program.transcode.defaultBitrate; 128 | 129 | const pathInfo = vpath.getVPathInfo(req.params[0], req.user); 130 | 131 | // Stream audio data 132 | if (req.method === 'GET') { 133 | 134 | // check cache 135 | if (transCache[`${pathInfo.fullPath}|${bitrate}|${codec}`]) { 136 | const t = transCache[`${pathInfo.fullPath}|${bitrate}|${codec}`].deref(); 137 | if (t!== undefined) { 138 | initHeaders(res, codec, t.contentLength); 139 | Readable.from(t.bufs).pipe(res); 140 | return; 141 | } 142 | } 143 | 144 | if (algo === 'stream') { 145 | return ffmpegIt(pathInfo, codec, bitrate).pipe(res); 146 | } 147 | 148 | const bufs = []; 149 | let contentLength = 0; 150 | const ffstream = ffmpegIt(pathInfo, codec, bitrate).pipe(); 151 | 152 | ffstream.on('data', (chunk) => { 153 | bufs.push(chunk); 154 | contentLength += chunk.length; 155 | }); 156 | 157 | ffstream.on('end', (chunk) => { 158 | // const contentLength = bufs.reduce((sum, buf) => { 159 | // return sum + buf.length; 160 | // }, 0); 161 | initHeaders(res, codec, contentLength); 162 | 163 | transCache[`${pathInfo.fullPath}|${bitrate}|${codec}`] = new WeakRef({ 164 | contentLength, bufs 165 | }); 166 | Readable.from(bufs).pipe(res); 167 | }); 168 | 169 | // } else if (req.method === 'HEAD') { 170 | // // The HEAD request should return the same headers as the GET request, but not the body 171 | // initHeaders(res, codec, pathInfo.fullPath).sendStatus(200); 172 | } else { 173 | res.sendStatus(405); // Method not allowed 174 | } 175 | }); 176 | }; 177 | -------------------------------------------------------------------------------- /src/db/image-compress-manager.js: -------------------------------------------------------------------------------- 1 | const child = require('child_process'); 2 | const path = require('path'); 3 | const winston = require('winston'); 4 | const config = require('../state/config'); 5 | 6 | let runningTask; 7 | 8 | exports.run = () => { 9 | if (runningTask !== undefined) { 10 | return false; 11 | } 12 | 13 | const jsonLoad = { 14 | albumArtDirectory: config.program.storage.albumArtDirectory, 15 | }; 16 | 17 | const forkedScan = child.fork(path.join(__dirname, './image-compress-script.js'), [JSON.stringify(jsonLoad)], { silent: true }); 18 | winston.info(`Image Compress Script Started`); 19 | runningTask = forkedScan; 20 | 21 | forkedScan.stdout.on('data', (data) => { 22 | winston.info(`Image Compress Message: ${data}`); 23 | }); 24 | 25 | forkedScan.stderr.on('data', (data) => { 26 | winston.error(`Image Compress Error: ${data}`); 27 | }); 28 | 29 | forkedScan.on('close', (code) => { 30 | winston.info(`Image compress script completed with code ${code}`); 31 | runningTask = undefined; 32 | }); 33 | 34 | return true; 35 | } -------------------------------------------------------------------------------- /src/db/image-compress-script.js: -------------------------------------------------------------------------------- 1 | const Jimp = require('jimp'); 2 | const Joi = require('joi'); 3 | const fs = require('fs').promises; 4 | const path = require('path'); 5 | const mime = require('mime-types') 6 | 7 | try { 8 | var loadJson = JSON.parse(process.argv[process.argv.length - 1], 'utf8'); 9 | } catch (error) { 10 | console.error(`Warning: failed to parse JSON input`); 11 | process.exit(1); 12 | } 13 | 14 | // Validate input 15 | const schema = Joi.object({ 16 | albumArtDirectory: Joi.string().required(), 17 | }); 18 | 19 | const { error, value } = schema.validate(loadJson); 20 | if (error) { 21 | console.error(`Invalid JSON Input`); 22 | console.log(error); 23 | process.exit(1); 24 | } 25 | 26 | run(); 27 | 28 | async function run() { 29 | try { 30 | var files = await fs.readdir(loadJson.albumArtDirectory); 31 | } catch(error) { 32 | console.log(error); 33 | process.exit(1); 34 | } 35 | 36 | for (const file of files) { 37 | try { 38 | const filepath = path.join(loadJson.albumArtDirectory, file); 39 | const stat = await fs.stat(filepath); 40 | if (stat.isDirectory()) { continue; } 41 | const mimeType = mime.lookup(path.extname(file)); 42 | if (!mimeType.startsWith('image')) { continue; } 43 | if (file.startsWith('zs-') || file.startsWith('zl-') || file.startsWith('zm-')) { continue; } 44 | 45 | const img = await Jimp.read(filepath); 46 | await img.scaleToFit(256, 256).write(path.join(loadJson.albumArtDirectory, 'zl-' + file)); 47 | await img.scaleToFit(92, 92).write(path.join(loadJson.albumArtDirectory, 'zs-' + file)); 48 | } catch (error) { 49 | console.log('error on file: ' + filepath); 50 | console.error(error); 51 | } 52 | } 53 | } -------------------------------------------------------------------------------- /src/db/manager.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const loki = require('lokijs'); 3 | const winston = require('winston'); 4 | const config = require('../state/config'); 5 | 6 | const userDataDbName = 'user-data.loki-v1.db'; 7 | const filesDbName = 'files.loki-v3.db'; 8 | const shareDbName = 'shared.loki-v1.db'; 9 | 10 | // Loki Collections 11 | let filesDB; 12 | let userDataDb; 13 | let shareDB; 14 | 15 | let fileCollection; 16 | let playlistCollection; 17 | let userMetadataCollection; 18 | let shareCollection; 19 | 20 | // Timer for clearing shared playlists 21 | let clearShared; 22 | 23 | exports.saveUserDB = () => { 24 | userDataDb.saveDatabase(err => { 25 | if (err) { winston.error('User DB Save Error', { stack: err }); } 26 | }); 27 | } 28 | 29 | exports.saveFilesDB = () => { 30 | filesDB.saveDatabase(err => { 31 | if (err) { winston.error('Files DB Save Error', { stack: err }); } 32 | winston.info('Metadata DB Saved') 33 | }); 34 | } 35 | 36 | exports.saveShareDB = () => { 37 | shareDB.saveDatabase(err => { 38 | if (err) { winston.error('Share DB Save Error', { stack: err }); } 39 | }); 40 | } 41 | 42 | exports.getFileDbName = () => { 43 | return filesDbName; 44 | } 45 | 46 | exports.getFileCollection = () => { 47 | return fileCollection; 48 | } 49 | 50 | exports.getPlaylistCollection = () => { 51 | return playlistCollection; 52 | } 53 | 54 | exports.getUserMetadataCollection = () => { 55 | return userMetadataCollection; 56 | } 57 | 58 | exports.getShareCollection = () => { 59 | return shareCollection; 60 | } 61 | 62 | exports.initLoki = () => { 63 | shareDB = new loki(path.join(config.program.storage.dbDirectory, shareDbName)); 64 | filesDB = new loki(path.join(config.program.storage.dbDirectory, filesDbName)); 65 | userDataDb = new loki(path.join(config.program.storage.dbDirectory, userDataDbName)); 66 | 67 | filesDB.loadDatabase({}, err => { 68 | if (err) { 69 | winston.error('Files DB Load Error', { stack: err }); 70 | return; 71 | } 72 | 73 | // Get files collection 74 | fileCollection = filesDB.getCollection('files'); 75 | if (!fileCollection) { 76 | fileCollection = filesDB.addCollection("files"); 77 | } 78 | }); 79 | 80 | userDataDb.loadDatabase({}, err => { 81 | if (err) { 82 | winston.error('Playlists DB Load Error', { stack: err }); 83 | return; 84 | } 85 | 86 | // Initialize playlists collection 87 | playlistCollection = userDataDb.getCollection('playlists'); 88 | if (!playlistCollection) { 89 | playlistCollection = userDataDb.addCollection("playlists"); 90 | } 91 | 92 | // Initialize user metadata collection (for song ratings, playback stats, etc) 93 | userMetadataCollection = userDataDb.getCollection('user-metadata'); 94 | if (!userMetadataCollection) { 95 | userMetadataCollection = userDataDb.addCollection("user-metadata"); 96 | } 97 | }); 98 | 99 | shareDB.loadDatabase({}, err => { 100 | shareCollection = shareDB.getCollection('playlists'); 101 | if (shareCollection === null) { 102 | shareCollection = shareDB.addCollection("playlists"); 103 | } 104 | }); 105 | 106 | if (clearShared) { 107 | clearInterval(clearShared); 108 | clearShared = undefined; 109 | } 110 | 111 | if (config.program.db.clearSharedInterval) { 112 | clearShared = setInterval(() => { 113 | try { 114 | this.getShareCollection().findAndRemove({ 'expires': { '$lt': Math.floor(Date.now() / 1000) } }); 115 | this.saveShareDB(); 116 | winston.info('Successfully cleared shared playlists'); 117 | }catch (err) { 118 | winston.error('Failed to clear expired saved playlists', { stack: err }) 119 | } 120 | }, config.program.db.clearSharedInterval * 60 * 60 * 1000); 121 | } 122 | } -------------------------------------------------------------------------------- /src/db/task-queue.js: -------------------------------------------------------------------------------- 1 | const child = require('child_process'); 2 | const path = require('path'); 3 | const winston = require('winston'); 4 | const nanoid = require('nanoid'); 5 | const jwt = require('jsonwebtoken'); 6 | const config = require('../state/config'); 7 | 8 | const taskQueue = []; 9 | const runningTasks = new Set(); 10 | const vpathLimiter = new Set(); 11 | let scanIntervalTimer = null; // This gets set after the server boots 12 | 13 | function addScanTask(vpath) { 14 | const scanObj = { task: 'scan', vpath: vpath, id: nanoid.nanoid(8) }; 15 | if (runningTasks.size < config.program.scanOptions.maxConcurrentTasks) { 16 | runScan(scanObj); 17 | } else { 18 | taskQueue.push(scanObj); 19 | } 20 | } 21 | 22 | function scanAll() { 23 | Object.keys(config.program.folders).forEach((vpath) => { 24 | addScanTask(vpath); 25 | }); 26 | } 27 | 28 | function nextTask() { 29 | if ( 30 | taskQueue.length > 0 31 | && runningTasks.size < config.program.scanOptions.maxConcurrentTasks 32 | && !vpathLimiter.has(taskQueue[taskQueue.length - 1].vpath)) 33 | { 34 | runScan(taskQueue.pop()); 35 | } 36 | } 37 | 38 | function runScan(scanObj) { 39 | const jsonLoad = { 40 | directory: config.program.folders[scanObj.vpath].root, 41 | vpath: scanObj.vpath, 42 | port: config.program.port, 43 | token: jwt.sign({ scan: true }, config.program.secret), 44 | albumArtDirectory: config.program.storage.albumArtDirectory, 45 | skipImg: config.program.scanOptions.skipImg, 46 | pause: config.program.scanOptions.pause, 47 | supportedFiles: config.program.supportedAudioFiles, 48 | scanId: scanObj.id, 49 | isHttps: config.getIsHttps(), 50 | compressImage: config.program.scanOptions.compressImage 51 | }; 52 | 53 | winston.info('Using new file scanner: ' + config.program.scanOptions.newScan); 54 | const scanFile = config.program.scanOptions.newScan ? 'scanner.mjs' : 'scanner.js'; 55 | const forkedScan = child.fork(path.join(__dirname, `./${scanFile}`), [JSON.stringify(jsonLoad)], { silent: true }); 56 | winston.info(`File scan started on ${jsonLoad.directory}`); 57 | runningTasks.add(forkedScan); 58 | vpathLimiter.add(scanObj.vpath); 59 | 60 | forkedScan.stdout.on('data', (data) => { 61 | winston.info(`File scan message: ${data}`); 62 | }); 63 | 64 | forkedScan.stderr.on('data', (data) => { 65 | winston.error(`File scan error: ${data}`); 66 | }); 67 | 68 | forkedScan.on('close', (code) => { 69 | winston.info(`File scan completed with code ${code}`); 70 | runningTasks.delete(forkedScan); 71 | vpathLimiter.delete(scanObj.vpath); 72 | nextTask(); 73 | }); 74 | } 75 | 76 | exports.scanVPath = (vPath) => { 77 | addScanTask(vPath); 78 | } 79 | 80 | exports.scanAll = () => { 81 | scanAll(); 82 | } 83 | 84 | exports.isScanning = () => { 85 | return runningTasks.size > 0 ? true : false; 86 | } 87 | 88 | exports.getAdminStats = () => { 89 | return { 90 | taskQueue, 91 | vpaths: [...vpathLimiter] 92 | }; 93 | } 94 | 95 | exports.runAfterBoot = () => { 96 | setTimeout(() => { 97 | // This only gets run once after boot. Will not be run on server restart b/c scanIntervalTimer is already set 98 | if (config.program.scanOptions.scanInterval > 0 && scanIntervalTimer === null) { 99 | scanAll(); 100 | scanIntervalTimer = setInterval(() => scanAll(), config.program.scanOptions.scanInterval * 60 * 60 * 1000); 101 | } 102 | }, config.program.scanOptions.bootScanDelay * 1000); 103 | } 104 | 105 | exports.resetScanInterval = () => { 106 | if (scanIntervalTimer) { clearInterval(scanIntervalTimer); } 107 | if (config.program.scanOptions.scanInterval > 0) { 108 | scanIntervalTimer = setInterval(() => scanAll(), config.program.scanOptions.scanInterval * 60 * 60 * 1000); 109 | } 110 | } -------------------------------------------------------------------------------- /src/logger.js: -------------------------------------------------------------------------------- 1 | const winston = require('winston'); 2 | require('winston-daily-rotate-file'); 3 | const os = require('os'); 4 | 5 | let fileTransport; 6 | 7 | const myFormat = winston.format.printf(info => { 8 | let msg = `${info.timestamp} ${info.level}: ${info.message}`; 9 | if (!info.stack) { return msg; } 10 | 11 | const stackStr = typeof info.stack === 'string' ? 12 | { stack: info.stack } : 13 | JSON.parse(JSON.stringify(info.stack, Object.getOwnPropertyNames(info.stack))); 14 | 15 | return msg += os.EOL + stackStr.stack; 16 | }); 17 | 18 | winston.configure({ 19 | transports: [ 20 | new winston.transports.Console({ 21 | format: winston.format.combine( 22 | winston.format.colorize(), 23 | winston.format.timestamp(), 24 | myFormat 25 | ) 26 | }) 27 | ], 28 | exitOnError: false 29 | }); 30 | 31 | // 32 | const addFileLogger = (filepath) => { 33 | if (fileTransport) { 34 | this.reset(); 35 | } 36 | 37 | fileTransport = new (winston.transports.DailyRotateFile)({ 38 | filename: 'mstream-%DATE%', 39 | dirname: filepath, 40 | extension: '.log', 41 | datePattern: 'YYYY-MM-DD-HH', 42 | maxSize: '20m', 43 | maxFiles: '14d', 44 | format: winston.format.combine( 45 | winston.format.timestamp(), 46 | winston.format.json() 47 | ), 48 | }); 49 | 50 | winston.add(fileTransport); 51 | } 52 | 53 | const reset = () => { 54 | if (fileTransport) { 55 | winston.remove(fileTransport); 56 | } 57 | 58 | fileTransport = undefined; 59 | } 60 | 61 | module.exports = { reset, addFileLogger }; 62 | -------------------------------------------------------------------------------- /src/state/config.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs").promises; 2 | const path = require('path'); 3 | const Joi = require('joi'); 4 | const winston = require('winston'); 5 | const { getTransAlgos, getTransCodecs, getTransBitrates } = require('../api/transcode'); 6 | 7 | const storageJoi = Joi.object({ 8 | albumArtDirectory: Joi.string().default(path.join(__dirname, '../../image-cache')), 9 | dbDirectory: Joi.string().default(path.join(__dirname, '../../save/db')), 10 | logsDirectory: Joi.string().default(path.join(__dirname, '../../save/logs')), 11 | syncConfigDirectory: Joi.string().default(path.join(__dirname, '../../save/sync')), 12 | }); 13 | 14 | const scanOptions = Joi.object({ 15 | skipImg: Joi.boolean().default(false), 16 | scanInterval: Joi.number().min(0).default(24), 17 | saveInterval: Joi.number().default(250), 18 | pause: Joi.number().min(0).default(0), 19 | bootScanDelay: Joi.number().default(3), 20 | maxConcurrentTasks: Joi.number().integer().min(1).default(1), 21 | compressImage: Joi.boolean().default(true), 22 | newScan: Joi.boolean().default(false) 23 | }); 24 | 25 | const dbOptions = Joi.object({ 26 | clearSharedInterval: Joi.number().integer().min(0).default(24) 27 | }); 28 | 29 | const transcodeOptions = Joi.object({ 30 | algorithm: Joi.string().valid(...getTransAlgos()).default('stream'), 31 | enabled: Joi.boolean().default(false), 32 | ffmpegDirectory: Joi.string().default(path.join(__dirname, '../../bin/ffmpeg')), 33 | defaultCodec: Joi.string().valid(...getTransCodecs()).default('opus'), 34 | defaultBitrate: Joi.string().valid(...getTransBitrates()).default('96k') 35 | }); 36 | 37 | const rpnOptions = Joi.object({ 38 | iniFile: Joi.string().default(path.join(__dirname, `../../bin/rpn/frps.ini`)), 39 | apiUrl: Joi.string().default('https://api.mstream.io'), 40 | email: Joi.string().allow('').optional(), 41 | password: Joi.string().allow('').optional(), 42 | token: Joi.string().optional(), 43 | url: Joi.string().optional() 44 | }); 45 | 46 | const lastFMOptions = Joi.object({ 47 | apiKey: Joi.string().default('25627de528b6603d6471cd331ac819e0'), 48 | apiSecret: Joi.string().default('a9df934fc504174d4cb68853d9feb143') 49 | }); 50 | 51 | const federationOptions = Joi.object({ 52 | enabled: Joi.boolean().default(false), 53 | folder: Joi.string().optional(), 54 | federateUsersMode: Joi.boolean().default(false), 55 | }); 56 | 57 | const schema = Joi.object({ 58 | address: Joi.string().ip({ cidr: 'forbidden' }).default('::'), 59 | port: Joi.number().default(3000), 60 | supportedAudioFiles: Joi.object().pattern( 61 | Joi.string(), Joi.boolean() 62 | ).default({ 63 | "mp3": true, "flac": true, "wav": true, 64 | "ogg": true, "aac": true, "m4a": true, "m4b": true, 65 | "opus": true, "m3u": false 66 | }), 67 | lastFM: lastFMOptions.default(lastFMOptions.validate({}).value), 68 | scanOptions: scanOptions.default(scanOptions.validate({}).value), 69 | noUpload: Joi.boolean().default(false), 70 | writeLogs: Joi.boolean().default(false), 71 | lockAdmin: Joi.boolean().default(false), 72 | storage: storageJoi.default(storageJoi.validate({}).value), 73 | webAppDirectory: Joi.string().default(path.join(__dirname, '../../webapp')), 74 | rpn: rpnOptions.default(rpnOptions.validate({}).value), 75 | transcode: transcodeOptions.default(transcodeOptions.validate({}).value), 76 | secret: Joi.string().optional(), 77 | maxRequestSize: Joi.string().pattern(/[0-9]+(KB|MB)/i).default('1MB'), 78 | db: dbOptions.default(dbOptions.validate({}).value), 79 | folders: Joi.object().pattern( 80 | Joi.string(), 81 | Joi.object({ 82 | root: Joi.string().required(), 83 | type: Joi.string().valid('music', 'audio-books').default('music'), 84 | }) 85 | ).default({}), 86 | users: Joi.object().pattern( 87 | Joi.string(), 88 | Joi.object({ 89 | password: Joi.string().required(), 90 | admin: Joi.boolean().default(false), 91 | salt: Joi.string().required(), 92 | vpaths: Joi.array().items(Joi.string()), 93 | 'lastfm-user': Joi.string().optional(), 94 | 'lastfm-password': Joi.string().optional(), 95 | }) 96 | ).default({}), 97 | ssl: Joi.object({ 98 | key: Joi.string().allow('').optional(), 99 | cert: Joi.string().allow('').optional() 100 | }).optional(), 101 | federation: federationOptions.default(federationOptions.validate({}).value), 102 | }); 103 | 104 | exports.asyncRandom = (numBytes) => { 105 | return new Promise((resolve, reject) => { 106 | require('crypto').randomBytes(numBytes, (err, salt) => { 107 | if (err) { return reject('Failed to generate random bytes'); } 108 | resolve(salt.toString('base64')); 109 | }); 110 | }); 111 | } 112 | 113 | exports.setup = async configFile => { 114 | // Create config if none exists 115 | try { 116 | await fs.access(configFile); 117 | } catch(err) { 118 | winston.info('Config File does not exist. Attempting to create file'); 119 | await fs.writeFile(configFile, JSON.stringify({}), 'utf8'); 120 | } 121 | 122 | const program = JSON.parse(await fs.readFile(configFile, 'utf8')); 123 | exports.configFile = configFile; 124 | 125 | // Verify paths are real 126 | for (let folder in program.folders) { 127 | if (!(await fs.stat(program.folders[folder].root)).isDirectory()) { 128 | throw new Error('Path does not exist: ' + program.folders[folder].root); 129 | } 130 | } 131 | 132 | // Setup Secret for JWT 133 | if (!program.secret) { 134 | winston.info('Config file does not have secret. Generating a secret and saving'); 135 | program.secret = await this.asyncRandom(128); 136 | await fs.writeFile(configFile, JSON.stringify(program, null, 2), 'utf8'); 137 | } 138 | 139 | exports.program = await schema.validateAsync(program, { allowUnknown: true }); 140 | } 141 | 142 | exports.getDefaults = () => { 143 | const { value, error } = schema.validate({}); 144 | return value; 145 | } 146 | 147 | exports.testValidation = async (validateThis) => { 148 | await schema.validateAsync(validateThis, { allowUnknown: true }); 149 | } 150 | 151 | let isHttps = false; 152 | exports.getIsHttps = () => { 153 | return isHttps; 154 | } 155 | 156 | exports.setIsHttps = (isIt) => { 157 | isHttps = isIt; 158 | } 159 | -------------------------------------------------------------------------------- /src/state/kill-list.js: -------------------------------------------------------------------------------- 1 | const killThese = []; 2 | 3 | process.on('exit', code => { 4 | // Kill them all 5 | killThese.forEach(func => { 6 | if (typeof func === 'function') { 7 | try { 8 | func(); 9 | }catch (err) { 10 | console.log('Error: Failed to run kill function'); 11 | } 12 | } 13 | }); 14 | }); 15 | 16 | exports.addToKillQueue = (func) => { 17 | killThese.push(func); 18 | } 19 | -------------------------------------------------------------------------------- /src/unused/README.md: -------------------------------------------------------------------------------- 1 | Unused modules from v4 that need to be ported to v5 -------------------------------------------------------------------------------- /src/unused/ddns.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | const os = require('os'); 3 | const winston = require('winston'); 4 | const fs = require('fs'); 5 | const path = require('path'); 6 | const { spawn } = require('child_process'); 7 | const killQueue = require('../src/state/kill-list'); 8 | const eol = os.EOL; 9 | 10 | var spawnedTunnel; 11 | const apiEndpoint = 'https://api.mstream.io'; 12 | const platform = os.platform(); 13 | const osMap = { 14 | "win32": "rpn-win.exe", 15 | "darwin": "rpn-osx", 16 | "linux": "rpn-linux", 17 | "android": "rpn-android64" 18 | }; 19 | 20 | killQueue.addToKillQueue( 21 | () => { 22 | // kill all workers 23 | if(spawnedTunnel) { 24 | spawnedTunnel.stdin.pause(); 25 | spawnedTunnel.kill(); 26 | } 27 | } 28 | ); 29 | 30 | exports.setup = async (program) => { 31 | if(spawnedTunnel || !program.ddns || !program.ddns.email || !program.ddns.password) { 32 | return; 33 | } 34 | 35 | login(program); 36 | } 37 | 38 | async function login(program) { 39 | var info; 40 | try { 41 | // login 42 | const loginRes = await axios({ 43 | method: 'post', 44 | url: apiEndpoint + '/login', 45 | headers: { 'accept': 'application/json' }, 46 | responseType: 'json', 47 | data: { 48 | email: program.ddns.email, 49 | password: program.ddns.password 50 | } 51 | }); 52 | 53 | // pull in config options 54 | const configRes = await axios({ 55 | method: 'get', 56 | url: apiEndpoint + '/account/info', 57 | headers: { 'x-access-token': loginRes.data.token, 'accept': 'application/json' }, 58 | responseType: 'json' 59 | }); 60 | info = configRes.data; 61 | } catch (err) { 62 | winston.error('Login to Auto DNS Failed'); 63 | winston.error(err.message); 64 | return; 65 | } 66 | 67 | // write config file for FRP 68 | try{ 69 | const iniString = `[common]${eol}server_addr = ${info.ddnsAddress}${eol}server_port = ${info.ddnsPort}${eol}token = ${info.ddnsPassword}${eol}${eol}[web]${eol}type = http${eol}local_ip = 127.0.0.1${eol}custom_domains = ${info.subdomain}.${info.domain}${eol}local_port = ${program.port}`; 70 | fs.writeFileSync(program.ddns.iniFile, iniString); 71 | } catch(err) { 72 | winston.error('Failed to write FRP ini'); 73 | winston.error(err.message); 74 | return; 75 | } 76 | 77 | // Boot it 78 | bootReverseProxy(program, info); 79 | } 80 | 81 | function bootReverseProxy(program, info) { 82 | if(spawnedTunnel) { 83 | winston.warn('Auto DNS: Tunnel already setup'); 84 | // return; 85 | } 86 | 87 | try { 88 | spawnedTunnel = spawn(path.join(__dirname, `../bin/rpn/${osMap[platform]}`), ['-c', program.ddns.iniFile], { 89 | // shell: true, 90 | // cwd: path.join(__dirname, `../bin/rpn`), 91 | }); 92 | 93 | spawnedTunnel.stdout.on('data', (data) => { 94 | // console.log(`stdout: ${data}`); 95 | }); 96 | 97 | spawnedTunnel.stderr.on('data', (data) => { 98 | // console.log(`stderr: ${data}`); 99 | }); 100 | 101 | spawnedTunnel.on('close', (code) => { 102 | winston.info('Auto DNS: Tunnel Closed. Attempting to reboot'); 103 | setTimeout(() => { 104 | winston.info('Auto DNS: Rebooting Tunnel'); 105 | // delete spawnedTunnel; 106 | bootReverseProxy(program, info); 107 | }, 4000); 108 | }); 109 | 110 | winston.info('Auto DNS: Secure Tunnel Established'); 111 | winston.info(`Access Your Server At: https://${info.subdomain}.${info.domain}`); 112 | }catch (err) { 113 | winston.error(`Failed to boot FRP`); 114 | winston.error(err.message); 115 | return; 116 | } 117 | } -------------------------------------------------------------------------------- /src/util/async-error.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const Layer = require('express/lib/router/layer'); 3 | 4 | const noop = () => {}; 5 | 6 | Object.defineProperty(Layer.prototype, "handle", { 7 | enumerable: true, 8 | get: function() { return this.__handle; }, 9 | set: function(fn) { 10 | if (isAsync(fn)) { 11 | fn = wrapAsync(fn); 12 | } 13 | 14 | this.__handle = fn; 15 | } 16 | }); 17 | 18 | function isAsync(fn) { 19 | const type = Object.toString.call(fn.constructor); 20 | return type.indexOf('AsyncFunction') !== -1; 21 | }; 22 | 23 | function wrapAsync(fn) { 24 | return (req, res, next = noop) => { 25 | fn(req, res, next) 26 | .catch((err) => { 27 | next(err); 28 | }); 29 | } 30 | }; -------------------------------------------------------------------------------- /src/util/auth.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | 3 | const HASH_BYTES = 32; 4 | const SALT_BYTES = 16; 5 | const ITERATIONS = 15000; 6 | const ENCODING = 'base64'; 7 | const ALGORITHM = 'sha512'; 8 | 9 | exports.hashPassword = password => { 10 | return new Promise((resolve, reject) => { 11 | crypto.randomBytes(SALT_BYTES, (err, salt) => { 12 | if (err) { return reject('Failed to hash password'); } 13 | crypto.pbkdf2(password, salt.toString(ENCODING), ITERATIONS, HASH_BYTES, ALGORITHM, (err, hash) => { 14 | if (err) { return reject('Failed to hash password'); } 15 | resolve({ salt: salt.toString(ENCODING), hashPassword: hash.toString(ENCODING) }); 16 | }); 17 | }); 18 | }); 19 | } 20 | 21 | exports.authenticateUser = (password, salt, givenPassword) => { 22 | return new Promise((resolve, reject) => { 23 | crypto.pbkdf2(givenPassword, salt, ITERATIONS, HASH_BYTES, ALGORITHM, (err, verifyHash) => { 24 | if (err) { return reject('Unknown Authentication Error'); } 25 | if (verifyHash.toString(ENCODING) !== password) { 26 | return reject('Authentication Error: Passwords do not match'); 27 | } 28 | resolve(); 29 | }); 30 | }); 31 | } -------------------------------------------------------------------------------- /src/util/file-explorer.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs").promises; 2 | const path = require("path"); 3 | const dbApi = require('../api/db'); 4 | 5 | exports.getFileType = (pathString) => { 6 | return path.extname(pathString).substr(1); 7 | } 8 | 9 | exports.getDirectoryContents = async (directory, fileTypeFilter, sort, pm, metaDir, user) => { 10 | const rt = { directories: [], files: [] }; 11 | for (const file of await fs.readdir(directory)) { 12 | try { 13 | var stat = await fs.stat(path.join(directory, file)); 14 | } catch (e) { continue; } /* Bad file or permission error, ignore and continue */ 15 | 16 | // Handle Directory 17 | if (stat.isDirectory()) { 18 | rt.directories.push({ name: file }); 19 | continue; 20 | } 21 | 22 | // Handle Files 23 | const extension = this.getFileType(file).toLowerCase(); 24 | if (fileTypeFilter && extension in fileTypeFilter) { 25 | const fileInfo = { 26 | type: extension, 27 | name: file 28 | }; 29 | 30 | if (pm) { 31 | fileInfo.metadata = dbApi.pullMetaData(path.join(metaDir, file).replace(/\\/g, '/'), user); 32 | } 33 | 34 | rt.files.push(fileInfo); 35 | } 36 | } 37 | 38 | if (sort && sort === true) { 39 | // Sort it because we can't rely on the OS returning it pre-sorted 40 | rt.directories.sort((a, b) => { return a.name.localeCompare(b.name); }); 41 | rt.files.sort((a, b) => { return a.name.localeCompare(b.name); }); 42 | } 43 | 44 | return rt; 45 | } 46 | -------------------------------------------------------------------------------- /src/util/m3u.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs").promises; 2 | const m3u8Parser = require('m3u8-parser'); 3 | 4 | exports.readPlaylistSongs = async (filePath) => { 5 | const fileContents = (await fs.readFile(filePath)).toString(); 6 | 7 | const parser = new m3u8Parser.Parser(); 8 | parser.push(fileContents); 9 | parser.end(); 10 | 11 | let items = parser.manifest.segments.map(segment => { return segment.uri; }); 12 | if (items.length === 0) { 13 | items = fileContents.split(/\r?\n/).filter(Boolean); 14 | } 15 | 16 | return items.map(item => { return item.replace(/\\/g, "/"); }); 17 | } -------------------------------------------------------------------------------- /src/util/ssl-test.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | try { 4 | var loadJson = JSON.parse(process.argv[process.argv.length - 1], 'utf8'); 5 | } catch (error) { 6 | console.error(`Warning: failed to parse JSON input`); 7 | process.exit(1); 8 | } 9 | 10 | // check if files exist 11 | if (!fs.existsSync(loadJson.cert) || !fs.existsSync(loadJson.key)) { 12 | process.exit(1); 13 | } 14 | 15 | try { 16 | require('https').createServer({ 17 | key: fs.readFileSync(loadJson.key), 18 | cert: fs.readFileSync(loadJson.cert) 19 | }); 20 | } catch (error) { 21 | process.exit(1); 22 | } -------------------------------------------------------------------------------- /src/util/validation.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | require('joi'); 3 | 4 | const joiValidate = (joiSchema, validateThis, throwErr) => { 5 | const { error, value } = joiSchema.validate(validateThis); 6 | 7 | // Defaults to throwing an error 8 | if (error !== undefined && throwErr !== false) { 9 | throw error; 10 | } 11 | 12 | return { error, value }; 13 | } 14 | 15 | module.exports = { joiValidate }; -------------------------------------------------------------------------------- /src/util/vpath.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const config = require('../state/config'); 3 | 4 | exports.getVPathInfo = (url, user) => { 5 | if (!config.program) { throw new Error('Not Configured'); } 6 | 7 | // remove leading slashes 8 | if (url.charAt(0) === '/') { 9 | url = url.substr(1); 10 | } 11 | 12 | // Get vpath from url 13 | const vpath = url.split('/').shift(); 14 | // Verify user has access to this vpath 15 | if (user && !user.vpaths.includes(vpath)) { 16 | throw new Error(`User does not have access to path ${vpath}`); 17 | } 18 | 19 | const baseDir = config.program.folders[vpath].root; 20 | return { 21 | vpath: vpath, 22 | basePath: baseDir, 23 | relativePath: path.relative(vpath, url), 24 | fullPath: path.join(baseDir, path.relative(vpath, url)) 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /src/util/web-error.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | class WebError extends Error { 4 | constructor (message, code) { 5 | super(message) 6 | Error.captureStackTrace(this, this.constructor); 7 | 8 | this.name = this.constructor.name 9 | 10 | if(!Number.isInteger(code) || code < 400 || code > 599) { 11 | code = 500; 12 | }; 13 | this.status = code; 14 | } 15 | } 16 | 17 | module.exports = WebError; -------------------------------------------------------------------------------- /webapp/admin/index.css: -------------------------------------------------------------------------------- 1 | .collection-item { 2 | display: flow-root; 3 | } 4 | 5 | .collection-item:hover { 6 | background-color: #EEE; 7 | cursor: pointer; 8 | } 9 | 10 | .collection-item svg { 11 | float: left; 12 | } 13 | 14 | .collection-item div { 15 | vertical-align: middle; 16 | height: 32.4px; 17 | float: left; 18 | } 19 | 20 | .pad-checkbox { 21 | padding-bottom: 20px; 22 | } 23 | 24 | a { 25 | cursor: pointer; 26 | } 27 | 28 | .logo-row { 29 | max-width: 500px; 30 | } 31 | 32 | .logo-row-mstream { 33 | max-width: 800px; 34 | } 35 | 36 | .content-switcher { 37 | padding-bottom: 60px; 38 | } 39 | 40 | #syncthing-iframe { 41 | width: 100%; 42 | margin-top: 10px; 43 | height: calc(100vh - 65px); 44 | } 45 | 46 | #lol { 47 | padding-bottom: 0px !important; 48 | } 49 | 50 | .tabs .tab a { 51 | font-weight: bold; 52 | } 53 | 54 | .tabs .indicator { 55 | height: 3px; 56 | background-color: #ee6e73; 57 | } 58 | 59 | .big-container { 60 | margin: 0 auto; 61 | max-width: 1600px; 62 | } 63 | 64 | .flow-root{ 65 | display: flow-root; 66 | } 67 | 68 | #select-win-drive { 69 | max-width: 55px; 70 | margin-right: 10px; 71 | cursor: pointer; 72 | max-height: 40px; 73 | } 74 | 75 | .flex { 76 | display: flex; 77 | } -------------------------------------------------------------------------------- /webapp/alpha/api.js: -------------------------------------------------------------------------------- 1 | const MSTREAMAPI = (() => { 2 | let mstreamModule = {}; 3 | 4 | mstreamModule.listOfServers = []; 5 | mstreamModule.currentServer = { 6 | host: "", 7 | username: "", 8 | token: "", 9 | vpaths: [] 10 | }; 11 | 12 | async function req(type, url, dataObject) { 13 | const res = await fetch(url, { 14 | method: type, 15 | headers: { 16 | 'Content-Type': 'application/json', 17 | 'x-access-token': MSTREAMAPI.currentServer.token 18 | // 'Content-Type': 'application/x-www-form-urlencoded', 19 | }, 20 | body: dataObject ? JSON.stringify(dataObject) : undefined 21 | }); 22 | 23 | if (res.ok !== true) { 24 | throw new Error(res); 25 | } 26 | 27 | return await res.json(); 28 | } 29 | 30 | mstreamModule.dirparser = (directory) => { 31 | return req('POST', mstreamModule.currentServer.host + 'api/v1/file-explorer', { directory: directory }); 32 | } 33 | 34 | mstreamModule.loadFileplaylist = (path) => { 35 | return req('POST', mstreamModule.currentServer.host + 'api/v1/file-explorer/m3u', { path }); 36 | } 37 | 38 | mstreamModule.recursiveScan = (directory) => { 39 | return req('POST', mstreamModule.currentServer.host + 'api/v1/file-explorer/recursive', { directory: directory }); 40 | } 41 | 42 | mstreamModule.savePlaylist = (title, songs, live) => { 43 | const postData = { title: title, songs: songs }; 44 | if (live !== undefined) { 45 | postData.live = live; 46 | } 47 | return req('POST', mstreamModule.currentServer.host + 'api/v1/playlist/save', postData); 48 | } 49 | 50 | mstreamModule.newPlaylist = (title) => { 51 | return req('POST', mstreamModule.currentServer.host + 'api/v1/playlist/new', { title: title }); 52 | } 53 | 54 | mstreamModule.deletePlaylist = (playlistname) => { 55 | return req('POST', mstreamModule.currentServer.host + 'api/v1/playlist/delete', { playlistname: playlistname }); 56 | } 57 | 58 | mstreamModule.removePlaylistSong = (lokiId) => { 59 | return req('POST', mstreamModule.currentServer.host + 'api/v1/playlist/remove-song', { lokiid: lokiId }); 60 | } 61 | 62 | mstreamModule.loadPlaylist = (playlistname) => { 63 | return req('POST', mstreamModule.currentServer.host + 'api/v1/playlist/load', { playlistname: playlistname }); 64 | } 65 | 66 | mstreamModule.getAllPlaylists = () => { 67 | return req('GET', mstreamModule.currentServer.host + 'api/v1/playlist/getall', false); 68 | } 69 | 70 | mstreamModule.addToPlaylist = (playlist, song) => { 71 | return req('POST', mstreamModule.currentServer.host + 'api/v1/playlist/add-song', { playlist: playlist, song: song }); 72 | } 73 | 74 | mstreamModule.search = (postObject) => { 75 | return req('POST', mstreamModule.currentServer.host + 'api/v1/db/search', postObject); 76 | } 77 | 78 | mstreamModule.artists = (postObject) => { 79 | return req('POST', mstreamModule.currentServer.host + 'api/v1/db/artists', postObject); 80 | } 81 | 82 | mstreamModule.albums = (postObject) => { 83 | return req('POST', mstreamModule.currentServer.host + 'api/v1/db/albums', postObject); 84 | } 85 | 86 | mstreamModule.artistAlbums = (postObject) => { 87 | return req('POST', mstreamModule.currentServer.host + "api/v1/db/artists-albums", postObject); 88 | } 89 | 90 | mstreamModule.albumSongs = (postObject) => { 91 | return req('POST', mstreamModule.currentServer.host + "api/v1/db/album-songs", postObject); 92 | } 93 | 94 | mstreamModule.dbStatus = () => { 95 | return req('GET', mstreamModule.currentServer.host + "api/v1/db/status", false); 96 | } 97 | 98 | mstreamModule.makeShared = (playlist, shareTimeInDays) => { 99 | return req('POST', mstreamModule.currentServer.host + "api/v1/share", { time: shareTimeInDays, playlist: playlist }); 100 | } 101 | 102 | mstreamModule.rateSong = (filepath, rating) => { 103 | return req('POST', mstreamModule.currentServer.host + "api/v1/db/rate-song", { filepath: filepath, rating: rating }); 104 | } 105 | 106 | mstreamModule.getRated = (postObject) => { 107 | return req('POST', mstreamModule.currentServer.host + "api/v1/db/rated", postObject); 108 | } 109 | 110 | mstreamModule.getRecentlyAdded = (limit, ignoreVPaths) => { 111 | return req('POST', mstreamModule.currentServer.host + "api/v1/db/recent/added", { limit: limit, ignoreVPaths }); 112 | } 113 | 114 | mstreamModule.getRecentlyPlayed = (limit, ignoreVPaths) => { 115 | return req('POST', mstreamModule.currentServer.host + "api/v1/db/stats/recently-played", { limit: limit, ignoreVPaths }); 116 | } 117 | 118 | mstreamModule.getMostPlayed = (limit, ignoreVPaths) => { 119 | return req('POST', mstreamModule.currentServer.host + "api/v1/db/stats/most-played", { limit: limit, ignoreVPaths }); 120 | } 121 | 122 | mstreamModule.lookupMetadata = (filepath) => { 123 | return req('POST', mstreamModule.currentServer.host + "api/v1/db/metadata", { filepath: filepath }); 124 | } 125 | 126 | mstreamModule.getRandomSong = (postObject) => { 127 | return req('POST', mstreamModule.currentServer.host + "api/v1/db/random-songs", postObject); 128 | } 129 | 130 | // Scrobble 131 | mstreamModule.scrobbleByMetadata = (artist, album, trackName) => { 132 | return req('POST', mstreamModule.currentServer.host + "api/v1/lastfm/scrobble-by-metadata", { artist: artist, album: album, track: trackName }); 133 | } 134 | 135 | mstreamModule.scrobbleByFilePath = (filePath) => { 136 | return req('POST', mstreamModule.currentServer.host + "api/v1/lastfm/scrobble-by-filepath", { filePath }); 137 | } 138 | 139 | // LOGIN 140 | mstreamModule.login = (username, password, url) => { 141 | return req('POST', url ? url + "api/v1/auth/login" : "api/v1/auth/login", { username: username, password: password }); 142 | } 143 | 144 | mstreamModule.ping = () => { 145 | return req('GET', mstreamModule.currentServer.host + "api/v1/ping", false); 146 | } 147 | 148 | mstreamModule.logout = () => { 149 | localStorage.removeItem('token'); 150 | Cookies.remove('x-access-token'); 151 | document.location.assign(window.location.href + (window.location.href.slice(-1) === '/' ? '' : '/') + 'login'); 152 | } 153 | 154 | return mstreamModule; 155 | })(); 156 | -------------------------------------------------------------------------------- /webapp/alpha/spa.js: -------------------------------------------------------------------------------- 1 | document.getElementById("sidenav-cover").addEventListener("click", () => { 2 | toggleSideMenu(); 3 | }); 4 | 5 | function toggleSideMenu() { 6 | document.getElementById("sidenav-cover").classList.toggle("click-through"); 7 | 8 | // Handles initial state rendered on page load 9 | if (!document.getElementById("sidenav-cover").classList.contains("fade-in") && !document.getElementById("sidenav-cover").classList.contains("fade-out")) { 10 | document.getElementById("sidenav-cover").classList.toggle("fade-in"); 11 | } else { 12 | document.getElementById("sidenav-cover").classList.toggle("fade-in"); 13 | document.getElementById("sidenav-cover").classList.toggle("fade-out"); 14 | } 15 | 16 | // Handles initial state rendered on page load 17 | if (!document.getElementById("sidenav").classList.contains("menu-in") && !document.getElementById("sidenav").classList.contains("menu-out")) { 18 | document.getElementById("sidenav").classList.toggle("menu-out"); 19 | } else { 20 | document.getElementById("sidenav").classList.toggle("menu-in"); 21 | document.getElementById("sidenav").classList.toggle("menu-out"); 22 | } 23 | 24 | document.getElementById("sidenav-button").classList.toggle('active'); 25 | } 26 | 27 | function closeSideMenu() { 28 | if (document.getElementById("sidenav").classList.contains("menu-out")) { 29 | toggleSideMenu(); 30 | } 31 | } 32 | 33 | document.documentElement.style.setProperty('--vh', `${window.innerHeight/100}px`); 34 | window.addEventListener("resize", () => { 35 | document.documentElement.style.setProperty('--vh', `${window.innerHeight/100}px`); 36 | }); 37 | 38 | 39 | function changeView(fn, el){ 40 | const elements = document.querySelectorAll('.side-nav-item'); // or: 41 | elements.forEach(elm => { 42 | elm.classList.remove("select") 43 | }); 44 | 45 | el.classList.add("select"); 46 | 47 | // close nav on mobile 48 | closeSideMenu(); 49 | fn(); 50 | } 51 | 52 | function toggleThing(el, bool) { 53 | document.querySelectorAll('.m-tab').forEach(elm => { 54 | elm.classList.remove("selected-tab") 55 | }); 56 | 57 | el.classList.add("selected-tab"); 58 | 59 | if (bool === false) { 60 | document.getElementById('browser').classList.add('hide-on-small-only'); 61 | }else { 62 | document.getElementById('browser').classList.remove('hide-on-small-only'); 63 | } 64 | } -------------------------------------------------------------------------------- /webapp/assets/css/lazy-load-polyfill.css: -------------------------------------------------------------------------------- 1 | img[data-lazy-src]{will-change:contents} 2 | -------------------------------------------------------------------------------- /webapp/assets/css/modal.css: -------------------------------------------------------------------------------- 1 | /* https://github.com/AddMoreScripts/hystModal */ 2 | .hystmodal__opened,.hystmodal__shadow{position:fixed;right:0;left:0;overflow:hidden}.hystmodal__shadow{border:none;display:block;width:100%;top:0;bottom:0;pointer-events:none;z-index:5000;opacity:0;transition:opacity .15s ease;background-color:#000}.hystmodal__shadow--show{pointer-events:auto;opacity:.6}.hystmodal{position:fixed;top:0;bottom:0;right:0;left:0;overflow:hidden;overflow-y:auto;-webkit-overflow-scrolling:touch;opacity:1;pointer-events:none;display:flex;flex-flow:column nowrap;justify-content:flex-start;z-index:5000;visibility:hidden}.hystmodal--active{opacity:1}.hystmodal--active,.hystmodal--moved{pointer-events:auto;visibility:visible}.hystmodal__wrap{flex-shrink:0;flex-grow:0;width:100%;min-height:100%;margin:auto;display:flex;flex-flow:column nowrap;align-items:center;justify-content:center}.hystmodal__window{margin:50px 0;box-sizing:border-box;flex-shrink:0;flex-grow:0;width:600px;max-width:100%;overflow:visible;transition:transform .2s ease 0s,opacity .2s ease 0s;transform:scale(.9);opacity:0}.hystmodal--active .hystmodal__window{transform:scale(1);opacity:1}.hystmodal__close{position:absolute;z-index:10;top:0;right:-40px;display:block;width:30px;height:30px;background-color:transparent;background-position:50%;background-repeat:no-repeat;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23fff' stroke='%23fff' stroke-linecap='square' stroke-miterlimit='50' stroke-width='2' d='M22 2L2 22'/%3E%3Cpath fill='none' stroke='%23fff' stroke-linecap='square' stroke-miterlimit='50' stroke-width='2' d='M2 2l20 20'/%3E%3C/svg%3E");background-size:100% 100%;border:none;font-size:0;cursor:pointer;outline:none}.hystmodal__close:focus{outline:2px dotted #afb3b9;outline-offset:2px}@media (max-width:767px){.hystmodal__close{top:10px;right:10px;width:24px;height:24px;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23fff' stroke='%23111' stroke-linecap='square' stroke-miterlimit='50' stroke-width='2' d='M22 2L2 22'/%3E%3Cpath fill='none' stroke='%23111' stroke-linecap='square' stroke-miterlimit='50' stroke-width='2' d='M2 2l20 20'/%3E%3C/svg%3E")}.hystmodal__window{margin:0}} 3 | 4 | .hystmodal__window{ 5 | position: relative; 6 | overflow: visible; 7 | border-radius: 4px; 8 | padding: 30px 30px; 9 | } 10 | 11 | .hystmodal--active { 12 | z-index: 5001; 13 | } 14 | 15 | .hystmodal__wrap { 16 | z-index: 5001; 17 | } 18 | 19 | .hystmodal--active .hystmodal__window { 20 | z-index: 5002; 21 | } -------------------------------------------------------------------------------- /webapp/assets/css/spa.css: -------------------------------------------------------------------------------- 1 | /* Responsive layout */ 2 | html { 3 | overflow: hidden; /* removes all scroll bars */ 4 | height: 100%; 5 | } 6 | 7 | body { 8 | height: 100%; 9 | width: 100vw; 10 | 11 | margin: 0; 12 | display: flex; 13 | flex-direction: row; 14 | flex-wrap: wrap; 15 | align-content: start; 16 | 17 | flex: 1; 18 | font-family: 'Open Sans Light', sans-serif; 19 | } 20 | 21 | #sidenav { 22 | background-color: #333; 23 | overflow-y: auto; 24 | user-select: none; 25 | color: #fff; 26 | 27 | display: flex; 28 | flex-direction: column; 29 | } 30 | 31 | #sidenav-cover { 32 | background-color: black; 33 | opacity: 0; 34 | position: fixed; 35 | width: 100vw; 36 | height: 100vh; 37 | cursor: pointer; 38 | } 39 | 40 | #content { 41 | height: 100vh; 42 | /* css has variables now. Find this in spa.js */ 43 | height: calc(var(--vh, 1vh) * 100); 44 | background-color: #f2f2f2; 45 | overflow-y: auto; 46 | flex-grow: 1; 47 | flex-shrink: 0; 48 | flex-basis: inherit; /* wtf is this magic? */ 49 | } 50 | 51 | .click-through { 52 | pointer-events: none; 53 | } 54 | 55 | .fade-in { 56 | animation: fadeIn ease 400ms forwards; 57 | } 58 | 59 | .fade-out { 60 | animation: fadeOut ease 400ms forwards; 61 | } 62 | 63 | .menu-in { 64 | animation: menuOut ease 400ms forwards; 65 | } 66 | 67 | .menu-out { 68 | animation: menuIn ease 400ms forwards; 69 | } 70 | 71 | @media screen and (max-width: 768px) { 72 | #sidenav { 73 | position: fixed; 74 | height: 100%; 75 | width: 220px; 76 | z-index: 100000; 77 | 78 | left: -220px; 79 | } 80 | 81 | #sidenav-cover { 82 | z-index: 100; 83 | } 84 | 85 | #content { 86 | width: 100%; 87 | flex-basis: auto; 88 | } 89 | } 90 | 91 | @keyframes fadeIn { 92 | 0% { 93 | opacity:0; 94 | } 95 | 100% { 96 | opacity:.3; 97 | } 98 | } 99 | 100 | @keyframes fadeOut { 101 | 0% { 102 | opacity:0.3; 103 | } 104 | 100% { 105 | opacity:0; 106 | } 107 | } 108 | 109 | @keyframes menuIn { 110 | 0% { 111 | left: -220px; 112 | } 113 | 100% { 114 | left: 0px; 115 | } 116 | } 117 | 118 | @keyframes menuOut { 119 | 0% { 120 | left: 0px; 121 | } 122 | 100% { 123 | left: -220px; 124 | } 125 | } 126 | 127 | @media screen and (min-width: 768px) { 128 | #sidenav { 129 | height: 100vh; 130 | height: calc(var(--vh, 1vh) * 100); 131 | overflow-y: auto; 132 | flex-grow: 0; /* do not grow - initial value: 0 */ 133 | flex-shrink: 0; /* do not shrink - initial value: 1 */ 134 | flex-basis: 200px; /* width/height - initial value: auto */ 135 | } 136 | 137 | #sidenav-cover { 138 | visibility: hidden; 139 | } 140 | 141 | .fixed-action-btn { 142 | visibility: hidden; 143 | } 144 | } 145 | 146 | /* Animated menu button */ 147 | /* Shamelessly stolen from: https://codepen.io/ainalem/pen/LJYRxz*/ 148 | .ham { 149 | cursor: pointer; 150 | -webkit-tap-highlight-color: transparent; 151 | transition: transform 400ms; 152 | -moz-user-select: none; 153 | -webkit-user-select: none; 154 | -ms-user-select: none; 155 | user-select: none; 156 | } 157 | .hamRotate.active { 158 | transform: rotate(45deg); 159 | } 160 | .hamRotate180.active { 161 | transform: rotate(180deg); 162 | } 163 | .line { 164 | fill:none; 165 | transition: stroke-dasharray 400ms, stroke-dashoffset 400ms; 166 | stroke:#fff; 167 | stroke-width:5.5; 168 | stroke-linecap:round; 169 | } 170 | .ham5 .top { 171 | stroke-dasharray: 40 82; 172 | } 173 | .ham5 .bottom { 174 | stroke-dasharray: 40 82; 175 | } 176 | .ham5.active .top { 177 | stroke-dasharray: 14 82; 178 | stroke-dashoffset: -72px; 179 | } 180 | .ham5.active .bottom { 181 | stroke-dasharray: 14 82; 182 | stroke-dashoffset: -72px; 183 | } 184 | 185 | /* Sidenav Stuff */ 186 | #sidenav svg { 187 | margin-right: 10px; 188 | margin-left: 10px; 189 | } 190 | 191 | .side-nav-header { 192 | width: 100%; 193 | min-height: 40px; 194 | display: flex; 195 | align-items: center; 196 | font-size: 16px; 197 | padding-left: 10px; 198 | color: #EEE; 199 | } 200 | 201 | .side-nav-item { 202 | font-family: 'Jura', sans-serif; 203 | font-weight: bold; 204 | font-size: 16px; 205 | cursor: pointer; 206 | width: 100%; 207 | min-height: 38px; 208 | 209 | display: flex; 210 | align-items: center; 211 | color: #DDD; 212 | } 213 | 214 | .side-nav-item:not(.select):hover { 215 | color: #FFF; 216 | } 217 | 218 | .side-nav-spacer { 219 | width: 100%; 220 | display: flex; 221 | flex-grow: 1; 222 | } 223 | 224 | .select { 225 | color: #FFF; 226 | background-color: #505061; 227 | } 228 | -------------------------------------------------------------------------------- /webapp/assets/css/spinner.css: -------------------------------------------------------------------------------- 1 | .spinner { 2 | -webkit-animation: rotator 1.5s linear infinite; 3 | animation: rotator 1.5s linear infinite; 4 | } 5 | 6 | @-webkit-keyframes rotator { 7 | 0% { 8 | -webkit-transform: rotate(0deg); 9 | transform: rotate(0deg); 10 | } 11 | 100% { 12 | -webkit-transform: rotate(270deg); 13 | transform: rotate(270deg); 14 | } 15 | } 16 | 17 | @keyframes rotator { 18 | 0% { 19 | -webkit-transform: rotate(0deg); 20 | transform: rotate(0deg); 21 | } 22 | 100% { 23 | -webkit-transform: rotate(270deg); 24 | transform: rotate(270deg); 25 | } 26 | } 27 | .spinner-path { 28 | stroke-dasharray: 257; 29 | stroke-dashoffset: 0; 30 | -webkit-transform-origin: center; 31 | transform-origin: center; 32 | -webkit-animation: spinner-dash 1.5s ease-in-out infinite, spinner-colors 6s ease-in-out infinite; 33 | animation: spinner-dash 1.5s ease-in-out infinite, spinner-colors 6s ease-in-out infinite; 34 | } 35 | 36 | @-webkit-keyframes spinner-colors { 37 | 0% { 38 | stroke: #4285F4; 39 | } 40 | 25% { 41 | stroke: #DE3E35; 42 | } 43 | 50% { 44 | stroke: #F7C223; 45 | } 46 | 75% { 47 | stroke: #1B9A59; 48 | } 49 | 100% { 50 | stroke: #4285F4; 51 | } 52 | } 53 | 54 | @keyframes spinner-colors { 55 | 0% { 56 | stroke: #4285F4; 57 | } 58 | 25% { 59 | stroke: #DE3E35; 60 | } 61 | 50% { 62 | stroke: #F7C223; 63 | } 64 | 75% { 65 | stroke: #1B9A59; 66 | } 67 | 100% { 68 | stroke: #4285F4; 69 | } 70 | } 71 | @-webkit-keyframes spinner-dash { 72 | 0% { 73 | stroke-dashoffset: 257; 74 | } 75 | 50% { 76 | stroke-dashoffset: 64.25; 77 | -webkit-transform: rotate(135deg); 78 | transform: rotate(135deg); 79 | } 80 | 100% { 81 | stroke-dashoffset: 257; 82 | -webkit-transform: rotate(450deg); 83 | transform: rotate(450deg); 84 | } 85 | } 86 | @keyframes spinner-dash { 87 | 0% { 88 | stroke-dashoffset: 257; 89 | } 90 | 50% { 91 | stroke-dashoffset: 64.25; 92 | -webkit-transform: rotate(135deg); 93 | transform: rotate(135deg); 94 | } 95 | 100% { 96 | stroke-dashoffset: 257; 97 | -webkit-transform: rotate(450deg); 98 | transform: rotate(450deg); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /webapp/assets/css/waves.css: -------------------------------------------------------------------------------- 1 | .ripple { 2 | position: absolute; 3 | background: #fff; 4 | border-radius: 50%; 5 | width: 5px; 6 | height: 5px; 7 | animation: rippleEffect .88s 1; 8 | opacity: 0; 9 | } 10 | 11 | @keyframes rippleEffect { 12 | 0% { 13 | transform: scale(1); 14 | opacity: 0.4; 15 | } 16 | 100% { 17 | transform: scale(100); 18 | opacity: 0; 19 | } 20 | } 21 | 22 | .waves { 23 | transition: 0.2s; 24 | overflow:hidden; 25 | position: relative; 26 | } -------------------------------------------------------------------------------- /webapp/assets/fav/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/webapp/assets/fav/android-chrome-192x192.png -------------------------------------------------------------------------------- /webapp/assets/fav/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/webapp/assets/fav/android-chrome-512x512.png -------------------------------------------------------------------------------- /webapp/assets/fav/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/webapp/assets/fav/apple-touch-icon.png -------------------------------------------------------------------------------- /webapp/assets/fav/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #da532c 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /webapp/assets/fav/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/webapp/assets/fav/favicon-16x16.png -------------------------------------------------------------------------------- /webapp/assets/fav/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/webapp/assets/fav/favicon-32x32.png -------------------------------------------------------------------------------- /webapp/assets/fav/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/webapp/assets/fav/favicon.ico -------------------------------------------------------------------------------- /webapp/assets/fav/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/webapp/assets/fav/mstile-150x150.png -------------------------------------------------------------------------------- /webapp/assets/fav/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 46 | 55 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /webapp/assets/fav/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "short_name": "", 4 | "icons": [ 5 | { 6 | "src": "/fav/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/fav/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /webapp/assets/fonts/jura.css: -------------------------------------------------------------------------------- 1 | /* cyrillic-ext */ 2 | @font-face { 3 | font-family: 'Jura'; 4 | font-style: normal; 5 | font-weight: 400; 6 | src: local('Jura Regular'), local('Jura-Regular'), url(jura/z7NbdRfiaC4VXclJUQZA3JzsTQ.woff2) format('woff2'); 7 | unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; 8 | } 9 | /* cyrillic */ 10 | @font-face { 11 | font-family: 'Jura'; 12 | font-style: normal; 13 | font-weight: 400; 14 | src: local('Jura Regular'), local('Jura-Regular'), url(jura/z7NbdRfiaC4VXcBJUQZA3JzsTQ.woff2) format('woff2'); 15 | unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; 16 | } 17 | /* greek-ext */ 18 | @font-face { 19 | font-family: 'Jura'; 20 | font-style: normal; 21 | font-weight: 400; 22 | src: local('Jura Regular'), local('Jura-Regular'), url(jura/z7NbdRfiaC4VXchJUQZA3JzsTQ.woff2) format('woff2'); 23 | unicode-range: U+1F00-1FFF; 24 | } 25 | /* greek */ 26 | @font-face { 27 | font-family: 'Jura'; 28 | font-style: normal; 29 | font-weight: 400; 30 | src: local('Jura Regular'), local('Jura-Regular'), url(jura/z7NbdRfiaC4VXcdJUQZA3JzsTQ.woff2) format('woff2'); 31 | unicode-range: U+0370-03FF; 32 | } 33 | /* vietnamese */ 34 | @font-face { 35 | font-family: 'Jura'; 36 | font-style: normal; 37 | font-weight: 400; 38 | src: local('Jura Regular'), local('Jura-Regular'), url(jura/z7NbdRfiaC4VXctJUQZA3JzsTQ.woff2) format('woff2'); 39 | unicode-range: U+0102-0103, U+0110-0111, U+1EA0-1EF9, U+20AB; 40 | } 41 | /* latin-ext */ 42 | @font-face { 43 | font-family: 'Jura'; 44 | font-style: normal; 45 | font-weight: 400; 46 | src: local('Jura Regular'), local('Jura-Regular'), url(jura/z7NbdRfiaC4VXcpJUQZA3JzsTQ.woff2) format('woff2'); 47 | unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; 48 | } 49 | /* latin */ 50 | @font-face { 51 | font-family: 'Jura'; 52 | font-style: normal; 53 | font-weight: 400; 54 | src: local('Jura Regular'), local('Jura-Regular'), url(jura/z7NbdRfiaC4VXcRJUQZA3Jw.woff2) format('woff2'); 55 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 56 | } 57 | -------------------------------------------------------------------------------- /webapp/assets/fonts/jura/z7NbdRfiaC4VXcBJUQZA3JzsTQ.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/webapp/assets/fonts/jura/z7NbdRfiaC4VXcBJUQZA3JzsTQ.woff2 -------------------------------------------------------------------------------- /webapp/assets/fonts/jura/z7NbdRfiaC4VXcRJUQZA3Jw.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/webapp/assets/fonts/jura/z7NbdRfiaC4VXcRJUQZA3Jw.woff2 -------------------------------------------------------------------------------- /webapp/assets/fonts/jura/z7NbdRfiaC4VXcdJUQZA3JzsTQ.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/webapp/assets/fonts/jura/z7NbdRfiaC4VXcdJUQZA3JzsTQ.woff2 -------------------------------------------------------------------------------- /webapp/assets/fonts/jura/z7NbdRfiaC4VXchJUQZA3JzsTQ.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/webapp/assets/fonts/jura/z7NbdRfiaC4VXchJUQZA3JzsTQ.woff2 -------------------------------------------------------------------------------- /webapp/assets/fonts/jura/z7NbdRfiaC4VXclJUQZA3JzsTQ.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/webapp/assets/fonts/jura/z7NbdRfiaC4VXclJUQZA3JzsTQ.woff2 -------------------------------------------------------------------------------- /webapp/assets/fonts/jura/z7NbdRfiaC4VXcpJUQZA3JzsTQ.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/webapp/assets/fonts/jura/z7NbdRfiaC4VXcpJUQZA3JzsTQ.woff2 -------------------------------------------------------------------------------- /webapp/assets/fonts/jura/z7NbdRfiaC4VXctJUQZA3JzsTQ.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/webapp/assets/fonts/jura/z7NbdRfiaC4VXctJUQZA3JzsTQ.woff2 -------------------------------------------------------------------------------- /webapp/assets/img/app-store-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/webapp/assets/img/app-store-logo.png -------------------------------------------------------------------------------- /webapp/assets/img/default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/webapp/assets/img/default.png -------------------------------------------------------------------------------- /webapp/assets/img/drag-handle.svg: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /webapp/assets/img/folder.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /webapp/assets/img/mstream-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | image/svg+xml -------------------------------------------------------------------------------- /webapp/assets/img/mstream-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/webapp/assets/img/mstream-logo.png -------------------------------------------------------------------------------- /webapp/assets/img/mstream-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 10 | 11 | 16 | 18 | 20 | 24 | 28 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /webapp/assets/img/music-note.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /webapp/assets/img/next-white.svg: -------------------------------------------------------------------------------- 1 |  2 | -------------------------------------------------------------------------------- /webapp/assets/img/pause-white.svg: -------------------------------------------------------------------------------- 1 |  2 | -------------------------------------------------------------------------------- /webapp/assets/img/play-store-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/webapp/assets/img/play-store-logo.png -------------------------------------------------------------------------------- /webapp/assets/img/play-white.svg: -------------------------------------------------------------------------------- 1 |  2 | -------------------------------------------------------------------------------- /webapp/assets/img/previous-white.svg: -------------------------------------------------------------------------------- 1 |  2 | -------------------------------------------------------------------------------- /webapp/assets/img/spinner.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /webapp/assets/img/star.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /webapp/assets/img/struckaxiom.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/webapp/assets/img/struckaxiom.png -------------------------------------------------------------------------------- /webapp/assets/img/struckaxiom_@2X.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/webapp/assets/img/struckaxiom_@2X.png -------------------------------------------------------------------------------- /webapp/assets/img/volume-mute.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /webapp/assets/img/volume.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /webapp/assets/js/api.js: -------------------------------------------------------------------------------- 1 | const API = (() => { 2 | const module = {}; 3 | 4 | // initialize with a default server 5 | module.servers = [{ 6 | name: "default", 7 | url: '..', // This is some hacky bullshit to get relative URLs working 8 | token: localStorage.getItem('token') 9 | }]; 10 | 11 | module.selectedServer = 0; 12 | 13 | module.name = () => { 14 | return module.servers[module.selectedServer].name; 15 | } 16 | 17 | module.token = () => { 18 | return module.servers[module.selectedServer].token; 19 | } 20 | 21 | module.url = () => { 22 | return module.servers[module.selectedServer].url; 23 | } 24 | 25 | module.logout = () => { 26 | localStorage.removeItem('token'); 27 | Cookies.remove('x-access-token'); 28 | document.location.assign(window.location.href.replace('/admin', '') + (window.location.href.slice(-1) === '/' ? '' : '/') + 'login'); 29 | } 30 | 31 | module.goToPlayer = () => { 32 | window.location.assign(window.location.href.replace('/admin', '')); 33 | } 34 | 35 | module.axios = axios.create({ 36 | headers: { 'x-access-token': module.token() } 37 | }); 38 | 39 | return module; 40 | })(); -------------------------------------------------------------------------------- /webapp/assets/js/lib/cookie.min.js: -------------------------------------------------------------------------------- 1 | /*! js-cookie v2.2.1 | MIT */ 2 | /* https://github.com/js-cookie/js-cookie */ 3 | 4 | !function(a){var b;if("function"==typeof define&&define.amd&&(define(a),b=!0),"object"==typeof exports&&(module.exports=a(),b=!0),!b){var c=window.Cookies,d=window.Cookies=a();d.noConflict=function(){return window.Cookies=c,d}}}(function(){function a(){for(var a=0,b={};a>(-2*g&6)):0)e=f.indexOf(e);return i}var f="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";d.prototype=new Error,d.prototype.name="InvalidCharacterError",b.exports="undefined"!=typeof window&&window.atob&&window.atob.bind(window)||e},{}],2:[function(a,b,c){function d(a){return decodeURIComponent(e(a).replace(/(.)/g,function(a,b){var c=b.charCodeAt(0).toString(16).toUpperCase();return c.length<2&&(c="0"+c),"%"+c}))}var e=a("./atob");b.exports=function(a){var b=a.replace(/-/g,"+").replace(/_/g,"/");switch(b.length%4){case 0:break;case 2:b+="==";break;case 3:b+="=";break;default:throw"Illegal base64url string!"}try{return d(b)}catch(c){return e(b)}}},{"./atob":1}],3:[function(a,b,c){"use strict";function d(a){this.message=a}var e=a("./base64_url_decode");d.prototype=new Error,d.prototype.name="InvalidTokenError",b.exports=function(a,b){if("string"!=typeof a)throw new d("Invalid token specified");b=b||{};var c=b.header===!0?0:1;try{return JSON.parse(e(a.split(".")[c]))}catch(f){throw new d("Invalid token specified: "+f.message)}},b.exports.InvalidTokenError=d},{"./base64_url_decode":2}],4:[function(a,b,c){(function(b){var c=a("./lib/index");"function"==typeof b.window.define&&b.window.define.amd?b.window.define("jwt_decode",function(){return c}):b.window&&(b.window.jwt_decode=c)}).call(this,"undefined"!=typeof global?global:"undefined"!=typeof self?self:"undefined"!=typeof window?window:{})},{"./lib/index":3}]},{},[4]); -------------------------------------------------------------------------------- /webapp/assets/js/lib/lazy-load-polyfill.js: -------------------------------------------------------------------------------- 1 | // https://github.com/mfranzke/loading-attribute-polyfill 2 | // v2.0.1 3 | !function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e||self).loadingAttributePolyfill=t()}(this,function(){var e,t="loading"in HTMLImageElement.prototype,r="loading"in HTMLIFrameElement.prototype,o="onscroll"in window;function a(e){var t,r,o=[];"picture"===e.parentNode.tagName.toLowerCase()&&((r=(t=e.parentNode).querySelector("source[data-lazy-remove]"))&&t.removeChild(r),o=Array.prototype.slice.call(e.parentNode.querySelectorAll("source"))),o.push(e),o.forEach(function(e){e.hasAttribute("data-lazy-srcset")&&(e.setAttribute("srcset",e.getAttribute("data-lazy-srcset")),e.removeAttribute("data-lazy-srcset"))}),e.setAttribute("src",e.getAttribute("data-lazy-src")),e.removeAttribute("data-lazy-src")}function n(a){var n=document.createElement("div");for(n.innerHTML=function(a){var n=a.textContent||a.innerHTML,i="data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 "+((n.match(/width=['"](\d+)['"]/)||!1)[1]||1)+" "+((n.match(/height=['"](\d+)['"]/)||!1)[1]||1)+"%27%3E%3C/svg%3E";return(/\n=o(e)+n+e.offsetHeight},a=function(e,t,n){return(t===window?window.pageXOffset:s(t))>=s(e)+n+e.offsetWidth},c=function(e,t,n){return!(i(e,t,n)||l(e,t,n)||r(e,t,n)||a(e,t,n))},u=function(e,t){var n=new e(t),o=new CustomEvent("LazyLoad::Initialized",{detail:{instance:n}});window.dispatchEvent(o)},d=function(e,t){return e.getAttribute("data-"+t)},h=function(e,t,n){return e.setAttribute("data-"+t,n)},f=function(e,t){var n=e.parentElement;if("PICTURE"===n.tagName)for(var o=0;o0;)e.splice(o.pop(),1)},_startScrollHandler:function(){this._isHandlingScroll||(this._isHandlingScroll=!0,this._settings.container.addEventListener("scroll",this._boundHandleScroll))},_stopScrollHandler:function(){this._isHandlingScroll&&(this._isHandlingScroll=!1,this._settings.container.removeEventListener("scroll",this._boundHandleScroll))},handleScroll:function(){var e=this._settings.throttle;if(0!==e){var t=Date.now(),n=e-(t-this._previousLoopTime);n<=0||n>e?(this._loopTimeout&&(clearTimeout(this._loopTimeout),this._loopTimeout=null),this._previousLoopTime=t,this._loopThroughElements()):this._loopTimeout||(this._loopTimeout=setTimeout(function(){this._previousLoopTime=Date.now(),this._loopTimeout=null,this._loopThroughElements()}.bind(this),n))}else this._loopThroughElements()},update:function(){this._elements=Array.prototype.slice.call(this._queryOriginNode.querySelectorAll(this._settings.elements_selector)),this._purgeElements(),this._loopThroughElements(),this._startScrollHandler()},destroy:function(){window.removeEventListener("resize",this._boundHandleScroll),this._loopTimeout&&(clearTimeout(this._loopTimeout),this._loopTimeout=null),this._stopScrollHandler(),this._elements=null,this._queryOriginNode=null,this._settings=null}};var w=window.lazyLoadOptions;return w&&function(e,t){var n=t.length;if(n)for(var o=0;o { 2 | const mstreamModule = {}; 3 | 4 | new Vue({ 5 | el: '#mstream-player', 6 | data: { 7 | playerStats: MSTREAMPLAYER.playerStats, 8 | playlist: MSTREAMPLAYER.playlist, 9 | positionCache: MSTREAMPLAYER.positionCache, 10 | meta: MSTREAMPLAYER.playerStats.metadata, 11 | lastVol: 100, 12 | }, 13 | computed: { 14 | currentTime: function() { 15 | if (!this.playerStats.duration) { return ''; } 16 | 17 | const minutes = Math.floor(this.playerStats.currentTime / 60); 18 | const secondsToCalc = Math.floor(this.playerStats.currentTime % 60) + ''; 19 | const currentText = minutes + ':' + (secondsToCalc.length < 2 ? '0' + secondsToCalc : secondsToCalc); 20 | return currentText; 21 | }, 22 | durationTime: function() { 23 | if (!this.playerStats.duration) { return '0:00'; } 24 | 25 | const minutes = Math.floor(this.playerStats.duration / 60); 26 | const secondsToCalc = Math.floor(this.playerStats.duration % 60) + ''; 27 | const currentText = minutes + ':' + (secondsToCalc.length < 2 ? '0' + secondsToCalc : secondsToCalc); 28 | return currentText; 29 | }, 30 | widthcss: function () { 31 | if (this.playerStats.duration === 0) { 32 | return "width:0"; 33 | } 34 | 35 | const percentage = ((this.playerStats.currentTime / this.playerStats.duration) * 100); 36 | return `width:${percentage}%`; 37 | }, 38 | volWidthCss: function () { 39 | return `width: ${this.playerStats.volume}%`; 40 | }, 41 | albumArtPath: function () { 42 | if (!this.meta['album-art']) { 43 | return '../assets/img/default.png'; 44 | } 45 | return `../album-art/${this.meta['album-art']}?compress=l&token=${MSTREAMPLAYER.getCurrentSong().authToken}`; 46 | } 47 | }, 48 | methods: { 49 | changeVol: function(event) { 50 | const rect = this.$refs.volumeWrapper.getBoundingClientRect(); 51 | const x = event.clientX - rect.left; //x position within the element. 52 | let percentage = (x / rect.width) * 100; 53 | if (percentage > 100) { percentage = 100; } // It's possible to 'drag' the progress bar to get over 100 percent 54 | MSTREAMPLAYER.changeVolume(percentage); 55 | }, 56 | seekTo: function(event) { 57 | const rect = this.$refs.progressWrapper.getBoundingClientRect(); 58 | const x = event.clientX - rect.left; //x position within the element. 59 | const percentage = (x / rect.width) * 100; 60 | MSTREAMPLAYER.seekByPercentage(percentage); 61 | }, 62 | downloadPlaylist: function() { 63 | const link = document.createElement("a"); 64 | link.download = ''; 65 | link.href = `../api/v1/download/shared?token=${sharedPlaylist.token}`; 66 | link.dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true, view: window})); 67 | }, 68 | playPause: function() { 69 | MSTREAMPLAYER.playPause(); 70 | }, 71 | previousSong: function() { 72 | MSTREAMPLAYER.previousSong(); 73 | }, 74 | nextSong: function() { 75 | MSTREAMPLAYER.nextSong(); 76 | }, 77 | toggleRepeat: function () { 78 | MSTREAMPLAYER.toggleRepeat(); 79 | }, 80 | toggleShuffle: function () { 81 | MSTREAMPLAYER.toggleShuffle(); 82 | }, 83 | toggleAutoDJ: function () { 84 | MSTREAMPLAYER.toggleAutoDJ(); 85 | }, 86 | toggleMute: function () { 87 | if (this.playerStats.volume === 0) { 88 | MSTREAMPLAYER.changeVolume(this.lastVol); 89 | } else { 90 | this.lastVol = this.playerStats.volume; 91 | MSTREAMPLAYER.changeVolume(0); 92 | } 93 | } 94 | } 95 | }); 96 | 97 | Vue.component('playlist-item', { 98 | // We need the positionCache to track the currently playing song 99 | data: function () { 100 | return { 101 | positionCache: MSTREAMPLAYER.positionCache, 102 | } 103 | }, 104 | template: ` 105 |
  • 106 |
    {{ comtext }}
    107 | 108 | 109 | 110 |
  • `, 111 | props: ['index', 'song'], 112 | methods: { 113 | goToSong: function () { 114 | MSTREAMPLAYER.goToSongAtPosition(this.index); 115 | }, 116 | // removeSong: function (event) { 117 | // MSTREAMPLAYER.removeSongAtPosition(this.index, false); 118 | // }, 119 | downloadSong: function () { 120 | const link = document.createElement("a"); 121 | link.download = ''; 122 | link.href = this.song.url; 123 | link.dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true, view: window})); 124 | } 125 | }, 126 | computed: { 127 | comtext: function () { 128 | let returnThis = this.song.metadata.title ? this.song.metadata.title : this.song.filepath.split('/').pop(); 129 | if (this.song.metadata.artist) { 130 | returnThis = this.song.metadata.artist + ' - ' + returnThis; 131 | } 132 | 133 | return returnThis; 134 | }, 135 | songError: function () { 136 | return this.song.error; 137 | } 138 | } 139 | }); 140 | 141 | // Change spacebar behavior to Play/Pause 142 | window.addEventListener("keydown", (event) => { 143 | // Use default behavior if user is in a form 144 | const element = event.target.tagName.toLowerCase(); 145 | if (element === 'input' || element === 'textarea') { 146 | return; 147 | } 148 | 149 | // Check the key 150 | switch (event.key) { 151 | case " ": //SpaceBar 152 | event.preventDefault(); 153 | MSTREAMPLAYER.playPause(); 154 | break; 155 | } 156 | }, false); 157 | 158 | return mstreamModule; 159 | })() 160 | -------------------------------------------------------------------------------- /webapp/assets/js/spa.js: -------------------------------------------------------------------------------- 1 | // css variable 2 | document.documentElement.style.setProperty('--vh', `${window.innerHeight/100}px`); 3 | window.addEventListener("resize", () => { 4 | document.documentElement.style.setProperty('--vh', `${window.innerHeight/100}px`); 5 | }); 6 | 7 | // document.getElementById("sidenav-button").addEventListener("click", () => { 8 | // toggleSideMenu(); 9 | // }); 10 | 11 | document.getElementById("sidenav-cover").addEventListener("click", () => { 12 | toggleSideMenu(); 13 | }); 14 | 15 | function toggleSideMenu() { 16 | document.getElementById("sidenav-cover").classList.toggle("click-through"); 17 | 18 | // Handles initial state rendered on page load 19 | if (!document.getElementById("sidenav-cover").classList.contains("fade-in") && !document.getElementById("sidenav-cover").classList.contains("fade-out")) { 20 | document.getElementById("sidenav-cover").classList.toggle("fade-in"); 21 | } else { 22 | document.getElementById("sidenav-cover").classList.toggle("fade-in"); 23 | document.getElementById("sidenav-cover").classList.toggle("fade-out"); 24 | } 25 | 26 | // Handles initial state rendered on page load 27 | if (!document.getElementById("sidenav").classList.contains("menu-in") && !document.getElementById("sidenav").classList.contains("menu-out")) { 28 | document.getElementById("sidenav").classList.toggle("menu-out"); 29 | } else { 30 | document.getElementById("sidenav").classList.toggle("menu-in"); 31 | document.getElementById("sidenav").classList.toggle("menu-out"); 32 | } 33 | 34 | document.getElementById("sidenav-button").classList.toggle('active'); 35 | } 36 | 37 | function closeSideMenu() { 38 | if (document.getElementById("sidenav").classList.contains("menu-out")) { 39 | toggleSideMenu(); 40 | } 41 | } -------------------------------------------------------------------------------- /webapp/assets/js/t.js: -------------------------------------------------------------------------------- 1 | var VIZ = (() => { 2 | let vizModule = {}; 3 | 4 | var visualizer = null; 5 | var audioContext = new AudioContext(); 6 | var vizSettings = { 7 | width: 800, 8 | height: 600, 9 | pixelRatio: window.devicePixelRatio || 1, 10 | textureRatio: 1 11 | } 12 | var cycleInterval = null; 13 | var presets = {}; 14 | var presetKeys = []; 15 | var presetIndexHist = []; 16 | var presetIndex = 0; 17 | var presetCycle = true; 18 | var presetCycleLength = 15000; 19 | var presetRandom = true; 20 | 21 | var isInit = false; 22 | 23 | var renderSource = null; 24 | function startRenderer(source) { 25 | if(source) { 26 | renderSource = source; 27 | } 28 | if(isInit === true && renderSource) { 29 | visualizer.connectAudio(renderSource); 30 | 31 | requestAnimationFrame(() => startRenderer()); 32 | visualizer.render(); 33 | } 34 | } 35 | 36 | function connectAudio(sourceNode) { 37 | audioContext.resume(); 38 | var gainNode = audioContext.createGain(); 39 | var biquadFilter = audioContext.createBiquadFilter(); 40 | 41 | gainNode.gain.value = 1.25; 42 | sourceNode.connect(gainNode); 43 | gainNode.connect(biquadFilter) 44 | startRenderer(biquadFilter); 45 | // startRenderer(sourceNode); 46 | } 47 | function nextPreset(blendTime = 5.7) { 48 | presetIndexHist.push(presetIndex); 49 | var numPresets = presetKeys.length; 50 | if (presetRandom) { 51 | presetIndex = Math.floor(Math.random() * presetKeys.length); 52 | } else { 53 | presetIndex = (presetIndex + 1) % numPresets; 54 | } 55 | visualizer.loadPreset(presets[presetKeys[presetIndex]], blendTime); 56 | document.getElementById('presetSelect').value = presetIndex; 57 | } 58 | function prevPreset(blendTime = 5.7) { 59 | var numPresets = presetKeys.length; 60 | if (presetIndexHist.length > 0) { 61 | presetIndex = presetIndexHist.pop(); 62 | } else { 63 | presetIndex = ((presetIndex - 1) + numPresets) % numPresets; 64 | } 65 | visualizer.loadPreset(presets[presetKeys[presetIndex]], blendTime); 66 | document.getElementById('presetSelect').value = presetIndex; 67 | } 68 | function restartCycleInterval() { 69 | if (cycleInterval) { 70 | clearInterval(cycleInterval); 71 | cycleInterval = null; 72 | } 73 | if (presetCycle) { 74 | cycleInterval = setInterval(() => nextPreset(2.7), presetCycleLength); 75 | } 76 | } 77 | 78 | // NOTE: These controls are not accessible to the user currently 79 | // $('#presetSelect').change((evt) => { 80 | // presetIndexHist.push(presetIndex); 81 | // presetIndex = parseInt($('#presetSelect').val()); 82 | // visualizer.loadPreset(presets[presetKeys[presetIndex]], 5.7); 83 | // }); 84 | // $('#presetCycle').change(() => { 85 | // presetCycle = $('#presetCycle').is(':checked'); 86 | // restartCycleInterval(); 87 | // }); 88 | // $('#presetCycleLength').change((evt) => { 89 | // presetCycleLength = parseInt($('#presetCycleLength').val() * 1000); 90 | // restartCycleInterval(); 91 | // }); 92 | 93 | vizModule.connect = function (audioNode) { 94 | connectAudio(audioNode) 95 | } 96 | 97 | vizModule.get = function () { 98 | return audioContext; 99 | } 100 | 101 | vizModule.updateSize = function () { 102 | var canvas = document.getElementById('viz-canvas'); 103 | vizSettings.width = canvas.clientWidth; 104 | vizSettings.height = canvas.clientHeight; 105 | canvas.width = vizSettings.width; 106 | canvas.height = vizSettings.height; 107 | 108 | visualizer.setRendererSize(vizSettings.width, vizSettings.height) 109 | } 110 | 111 | function reportWindowSize() { 112 | if (!document.getElementById("viz-canvas").clientWidth || !isInit) { 113 | return; 114 | } 115 | vizModule.updateSize(); 116 | } 117 | window.onresize = reportWindowSize; 118 | 119 | vizModule.toggleDom = () => { 120 | document.getElementById('main-overlay').classList.toggle('hide-fade'); 121 | document.getElementById('main-overlay').classList.toggle('show-fade'); 122 | VIZ.initPlayer(); 123 | } 124 | 125 | vizModule.initPlayer = function () { 126 | if(isInit === true) { 127 | return false; 128 | } 129 | isInit = true; 130 | 131 | var canvas = document.getElementById('viz-canvas'); 132 | // audioContext = new AudioContext(); 133 | presets = {}; 134 | if (window.butterchurnPresets) { 135 | Object.assign(presets, butterchurnPresets.getPresets()); 136 | } 137 | if (window.butterchurnPresetsExtra) { 138 | Object.assign(presets, butterchurnPresetsExtra.getPresets()); 139 | } 140 | 141 | presetKeys = Object.keys(presets); 142 | 143 | presetIndex = Math.floor(Math.random() * presetKeys.length); 144 | var presetSelect = document.getElementById('presetSelect'); 145 | for (var i = 0; i < presetKeys.length; i++) { 146 | var opt = document.createElement('option'); 147 | opt.innerHTML = presetKeys[i].substring(0,60) + (presetKeys[i].length > 60 ? '...' : ''); 148 | opt.value = i; 149 | presetSelect.appendChild(opt); 150 | } 151 | 152 | vizSettings.width = document.getElementById("viz-canvas").clientWidth ? document.getElementById("viz-canvas").clientWidth : 800; 153 | vizSettings.height = document.getElementById("viz-canvas").clientHeight ? document.getElementById("viz-canvas").clientHeight : 600; 154 | canvas.width = vizSettings.width; 155 | canvas.height = vizSettings.height; 156 | 157 | visualizer = butterchurn.default.createVisualizer(audioContext, canvas, vizSettings); 158 | nextPreset(0); 159 | cycleInterval = setInterval(() => nextPreset(2.7), presetCycleLength); 160 | startRenderer(); 161 | } 162 | 163 | return vizModule; 164 | })(); 165 | -------------------------------------------------------------------------------- /webapp/assets/js/waves.js: -------------------------------------------------------------------------------- 1 | /* Based On: https://codepen.io/TrevorWelch/pen/NwERXE */ 2 | const WAVES = (() => { 3 | const module = {}; 4 | 5 | module.attachRipples = () => { 6 | const rippleElements = document.getElementsByClassName("my-waves"); 7 | while(rippleElements.length > 0){ 8 | rippleElements[0].addEventListener('click', function(e) { 9 | let X = e.pageX - this.offsetLeft; 10 | let Y = e.pageY - this.offsetTop; 11 | let rippleDiv = document.createElement("div"); 12 | rippleDiv.classList.add('ripple'); 13 | rippleDiv.setAttribute("style","top:"+Y+"px; left:"+X+"px;"); 14 | let customColor = this.getAttribute('ripple-color'); 15 | if (customColor) rippleDiv.style.background = customColor; 16 | this.appendChild(rippleDiv); 17 | setTimeout(() => { 18 | rippleDiv.parentElement.removeChild(rippleDiv); 19 | }, 1100); 20 | }); 21 | 22 | rippleElements[0].classList.add('waves'); 23 | rippleElements[0].classList.remove('my-waves'); 24 | } 25 | } 26 | 27 | module.attachRipples(); 28 | 29 | return module; 30 | })(); 31 | -------------------------------------------------------------------------------- /webapp/build/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/webapp/build/icon.icns -------------------------------------------------------------------------------- /webapp/build/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/webapp/build/icon.png -------------------------------------------------------------------------------- /webapp/build/mstream-logo-cut.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/webapp/build/mstream-logo-cut.icns -------------------------------------------------------------------------------- /webapp/build/mstream-logo-cut.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/webapp/build/mstream-logo-cut.ico -------------------------------------------------------------------------------- /webapp/build/tray-icon-osx.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/webapp/build/tray-icon-osx.png -------------------------------------------------------------------------------- /webapp/build/tray-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/webapp/build/tray-icon.png -------------------------------------------------------------------------------- /webapp/build/tray-icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IrosTheBeggar/mStream/7d40b034ede98b162f40a78dbf6b04e01cb5a5fb/webapp/build/tray-icon@2x.png -------------------------------------------------------------------------------- /webapp/index.js: -------------------------------------------------------------------------------- 1 | const { app, BrowserWindow } = require('electron') 2 | 3 | function createWindow () { 4 | const win = new BrowserWindow({ 5 | autoHideMenuBar: true, 6 | backgroundColor: '#1e2228', 7 | width: 1200, 8 | height: 800 9 | }) 10 | win.loadFile('./index.html') 11 | } 12 | 13 | app.whenReady().then(() => { 14 | createWindow() 15 | 16 | app.on('activate', () => { 17 | if (BrowserWindow.getAllWindows().length === 0) { 18 | createWindow() 19 | } 20 | }) 21 | }) 22 | 23 | app.on('window-all-closed', () => { 24 | if (process.platform !== 'darwin') { 25 | app.quit() 26 | } 27 | }) -------------------------------------------------------------------------------- /webapp/login/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Login 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 54 | 55 | 56 | 57 |
    58 |
    59 |
    60 |
    61 | 62 | 94 |
    95 |
    96 |
    97 |
    98 |
    99 |
    100 |
    101 | 102 | 103 |
    104 |
    105 |
    106 |
    107 |
    108 |
    109 | 110 | 111 |
    112 |
    113 |
    114 |
    115 |
    116 |
    117 | 120 |
    121 |
    122 |
    123 |
    124 | 125 | -------------------------------------------------------------------------------- /webapp/login/index.js: -------------------------------------------------------------------------------- 1 | document.getElementById("login").addEventListener("submit", async e => { 2 | e.preventDefault(); 3 | 4 | // Lock Button 5 | document.getElementById("form-submit").disabled = true; 6 | 7 | try { 8 | const res = await axios({ 9 | method: 'POST', 10 | url: `${API.url()}/api/v1/auth/login`, 11 | data: { 12 | username: document.getElementById('email').value, 13 | password: document.getElementById('password').value 14 | } 15 | }); 16 | 17 | localStorage.setItem("token", res.data.token); 18 | 19 | window.location.assign(window.location.href.replace('/login', '')); 20 | 21 | iziToast.success({ 22 | title: 'Login Success!', 23 | position: 'topCenter', 24 | timeout: 3500 25 | }); 26 | } catch (err) { 27 | iziToast.error({ 28 | title: 'Login Failed', 29 | position: 'topCenter', 30 | timeout: 3500 31 | }); 32 | } 33 | 34 | document.getElementById("form-submit").disabled = false; 35 | }); -------------------------------------------------------------------------------- /webapp/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mstream-desktop-app", 3 | "version": "5.9.4", 4 | "description": "mStream Desktop Player", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "electron .", 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "engines": { 11 | "node": ">=10.0.0" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/IrosTheBeggar/mStream" 16 | }, 17 | "author": { 18 | "name": "Paul Sori", 19 | "email": "paul@mstream.io" 20 | }, 21 | "homepage": "https://mstream.io/", 22 | "license": "GPL-3.0", 23 | "build": { 24 | "appId": "io.mstream.desktop", 25 | "productName": "mStream Desktop", 26 | "electronVersion": "16.0.2", 27 | "files": [ 28 | "**/*", 29 | "!admin/*", 30 | "!login/*", 31 | "!shared/*", 32 | "!package-lock.json" 33 | ], 34 | "mac": { 35 | "category": "public.app-category.music" 36 | }, 37 | "win": { 38 | "target": [ 39 | { 40 | "target": "nsis", 41 | "arch": [ 42 | "x64" 43 | ] 44 | } 45 | ] 46 | }, 47 | "linux": { 48 | "target": [ 49 | { 50 | "target": "AppImage", 51 | "arch": [ 52 | "x64", 53 | "arm64", 54 | "armv7l" 55 | ] 56 | } 57 | ] 58 | } 59 | }, 60 | "devDependencies": { 61 | "electron-builder": "22.14.5" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /webapp/qr/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | QR Tool 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 34 | 35 | 36 | 37 |
    38 |
    39 |
    40 |
    41 |
    42 |
    43 |
    44 |
    45 |
    46 |
    47 |
    48 | 49 | 50 |
    51 |
    52 | 53 | 54 |
    55 |
    56 | 57 | 58 |
    59 |
    60 |
    61 |
    62 | 65 |
    66 |
    67 |
    68 |
    69 | 70 |
    71 |
    72 | 73 | 108 | 109 | 110 | -------------------------------------------------------------------------------- /webapp/remote/index.css: -------------------------------------------------------------------------------- 1 | body, html { 2 | height: 100%; } 3 | 4 | .browser{ 5 | list-style: none; 6 | padding: 0; 7 | overflow-y: scroll; 8 | height: calc(100% - 40px); 9 | } 10 | 11 | .browser-item{ 12 | height:auto; 13 | border-bottom:solid 1px #b4b4b4; 14 | 15 | cursor: pointer; 16 | width: 100%; 17 | background: white; 18 | color: #252525; 19 | font-size: 10pt; 20 | text-shadow: 0 1px white; 21 | font-weight: 300; 22 | overflow: hidden; 23 | padding: 10px; 24 | } 25 | 26 | .browser-item:hover{ 27 | background-color: #F5F5F5; 28 | } 29 | 30 | .login-form{ 31 | padding-top: 20px; 32 | } 33 | 34 | .back-button{ 35 | position: relative; 36 | float: left; 37 | padding: 5px; 38 | } 39 | 40 | .filepath{ 41 | position: relative; 42 | float: left; 43 | padding-top: 10px; 44 | } 45 | 46 | .pointer { 47 | cursor: pointer; 48 | } 49 | 50 | .login-overlay { 51 | position: fixed; 52 | padding: 0; 53 | margin: 0; 54 | 55 | top: 0; 56 | left: 0; 57 | 58 | width: 100%; 59 | height: 100%; 60 | background: rgba(255, 255, 255, 0.9); 61 | z-index: 9; 62 | } 63 | 64 | .fade-enter-active, 65 | .fade-leave-active { 66 | transition: opacity .5s 67 | } 68 | 69 | .fade-enter, 70 | .fade-leave-to { 71 | opacity: 0 72 | } -------------------------------------------------------------------------------- /webapp/remote/index.js: -------------------------------------------------------------------------------- 1 | const MSTREAMAPI = (() => { 2 | const mstreamModule = {}; 3 | 4 | mstreamModule.listOfServers = []; 5 | mstreamModule.currentServer = { 6 | host: "", 7 | username: "", 8 | password: "", 9 | token: "", 10 | vPath: "" 11 | } 12 | 13 | mstreamModule.currentProperties = { 14 | currentList: false 15 | // Can be anything in the title array 16 | } 17 | var currentListTypes = [ 18 | 'filebrowser', 19 | 'albums', 20 | 'artists', 21 | 'search', 22 | 'playlists' 23 | ]; 24 | 25 | mstreamModule.dataList = []; 26 | 27 | 28 | function clearAndSetDataList(type) { 29 | if (!(type in currentListTypes) || type !== false) { 30 | // TODO: Throw Error 31 | } 32 | 33 | mstreamModule.currentProperties.currentList = type; 34 | 35 | while (mstreamModule.dataList.length > 0) { 36 | mstreamModule.dataList.pop(); 37 | } 38 | } 39 | 40 | mstreamModule.fileExplorerArray = [ 41 | { name: '/', position: 0 } 42 | ]; 43 | 44 | async function getDirectoryContents() { 45 | // Construct the directory string 46 | var directoryString = ""; 47 | for (var i = 0; i < mstreamModule.fileExplorerArray.length; i++) { 48 | // Ignore root directory 49 | if (mstreamModule.fileExplorerArray[i].name !== '/') { 50 | directoryString += mstreamModule.fileExplorerArray[i].name + "/"; 51 | } 52 | } 53 | 54 | 55 | // Send out AJAX request to start building the DB 56 | const res = await axios({ 57 | method: 'POST', 58 | url: `/api/v1/file-explorer`, 59 | headers: { 'x-access-token': remoteProperties.token }, 60 | data: { directory: directoryString } 61 | }); 62 | 63 | clearAndSetDataList('filebrowser'); 64 | 65 | res.data.files.forEach(f => { 66 | mstreamModule.dataList.push({ 67 | type: "file", 68 | path: res.data.path + f.name, 69 | name: f.name, 70 | artist: false, 71 | title: false 72 | }); 73 | }); 74 | 75 | res.data.directories.forEach(d => { 76 | mstreamModule.dataList.push({ 77 | type: 'directory', 78 | path: res.data.path + d.name, 79 | name: d.name, 80 | artist: false, 81 | title: false 82 | }); 83 | }); 84 | } 85 | 86 | 87 | mstreamModule.getCurrentDirectoryContents = function () { 88 | getDirectoryContents(); 89 | } 90 | 91 | mstreamModule.goToNextDirectory = function (folder, currentScrollPosition = 0) { 92 | if (currentScrollPosition != 0) { 93 | // TODO: Save Scroll Position 94 | } 95 | 96 | mstreamModule.fileExplorerArray.push({ name: folder, position: 0 }); 97 | getDirectoryContents(); 98 | } 99 | 100 | mstreamModule.goBackDirectory = function () { 101 | // Make sure it's not the root directory 102 | if (mstreamModule.dataList[mstreamModule.dataList.length - 1].name === '/') { 103 | return false; 104 | } 105 | 106 | mstreamModule.fileExplorerArray.pop(); 107 | getDirectoryContents(); 108 | } 109 | 110 | // Return an object that is assigned to Module 111 | return mstreamModule; 112 | })(); 113 | --------------------------------------------------------------------------------