├── .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 | ||
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 | [
](https://apps.apple.com/us/app/mstream-player/id1605378892)
39 |
40 | [
](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 |
79 |
80 |
81 |
Folders
82 |
83 |
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 |
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 |
--------------------------------------------------------------------------------
/webapp/assets/img/folder.svg:
--------------------------------------------------------------------------------
1 |
9 |
--------------------------------------------------------------------------------
/webapp/assets/img/mstream-icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/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 |
35 |
--------------------------------------------------------------------------------
/webapp/assets/img/music-note.svg:
--------------------------------------------------------------------------------
1 |
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 |
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 |
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 |
40 |
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 |
--------------------------------------------------------------------------------