diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e37dd1e --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +APP_PATH=/path/to/spotify-ripper-web/ \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2eea525 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..146835f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,57 @@ +FROM node:10.15.1-alpine AS base + + +###### +# spotify-ripper-docker +###### + +FROM base AS srd + +WORKDIR /spotify-ripper-docker +COPY ./spotify-ripper-docker ./ + + +###### +# spotify-ripper-web-frontend +###### + +FROM base AS srwf + +WORKDIR /spotify-ripper-web-frontend + +COPY ./spotify-ripper-web-frontend/package.json ./ +COPY ./spotify-ripper-web-frontend/yarn.lock ./ +RUN yarn install + +COPY ./spotify-ripper-web-frontend . +RUN yarn run build + + +###### +# spotify-ripper-web-backend +###### + +FROM base AS srwb + +WORKDIR /spotify-ripper-web-backend + +COPY ./spotify-ripper-web-backend/package*.json ./ +RUN npm install + +COPY ./spotify-ripper-web-backend ./ + + +###### +# composed +###### + +FROM base AS release + +COPY --from=srd /spotify-ripper-docker /spotify-ripper-docker +COPY --from=srwf /spotify-ripper-web-frontend /spotify-ripper-web-frontend +COPY --from=srwb /spotify-ripper-web-backend /spotify-ripper-web-backend + +EXPOSE 3000 + +COPY ./entrypoint.sh /entrypoint.sh +ENTRYPOINT [ "sh", "entrypoint.sh" ] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..976386b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 hedwiggggg + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7bb674f --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# spotify-ripper-web +## Introduction + +This repository aimed to create an easy-to-use web interface for the spotify-ripper. Archiving this goal I had the following point in the back of my head: + +- Parallel downloads of multiple users + +So I decided on the following project structure: + +spotify-ripper-docker: +- This is just a dockerized version of the spotify-ripper, including some adjustments to better handle the standard data streams and bring them to the web interface. + +spotify-ripper-web-backend: +- This is a node.js application which communicates with the frontend via websockets. +- It creates and starts new containers and forwards the standard data streams to the UI via Websocket. + +spotify-ripper-web-frontend: +- This is a vue.js spa, communicating with the node.js backend; Here you can also finally download the music. + +## Things that aren't so beautiful: + +- The project actually consists of three projects, which would probably have been much more intelligent to separate. +- No TDD; not even the vue.js project +- Implemented the spotify-ripper as a zip file; this would also be a separate project... +- Not as much use of environment vars as I should have; there are some things hardcoded, like the websocket port +- Not the worst and ugliest code; but alsonot the cleanest one.. + +## Installation + +The installation should be quite simple by dockerization. + +### Prequesites + +1. Installing docker and docker-compose +2. rename `.env.example` to `.env` and change the path according tr environment. +3. add your `spotify_appkey.key` to the `spotify-ripper-docker` folder +4. (adjust `docker-compose.yml` to mount your docker.sock) + +### Run the application + +To do this you can simply run `docker-compose up --build` (Depending on how you configured Docker, sudo may be required) + +FYI: This whole repo is optimized and tested on Ubuntu 18.04. + +If all went well, it should look something like this: (remember that port 3000 is in the container and mapped outside by your adjustments in the `docker-compose.yml`) + +![Console](/screenshots/screenshot_1.png "Console") + +Then you should be able to access it by `http://localhost:/web/` + +## Screenshots + +![spotify-ripper web-ui](/screenshots/screenshot_2.png "spotify-ripper web-ui") +![spotify-ripper web-ui](/screenshots/screenshot_3.png "spotify-ripper web-ui") +![spotify-ripper web-ui](/screenshots/screenshot_4.png "spotify-ripper web-ui") +![spotify-ripper web-ui](/screenshots/screenshot_5.png "spotify-ripper web-ui") diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..16c2e6b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +version: "3.7" + +services: + spotify-ripper-web: + build: ./ + + restart: unless-stopped + + ports: + - "80:3000" + - "81:3300" + + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./ripped_music:/ripped_music + + environment: + APP_PATH: ${APP_PATH} \ No newline at end of file diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..16c00e5 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,23 @@ +#!/bin/sh + +pid_node=0 + +term_handler() { + if [ $pid_node -ne 0 ]; then + kill -SIGTERM "$pid_node" + wait "$pid_node" + fi + + exit 130; +} + +trap term_handler INT TERM + +node ./spotify-ripper-web-backend/app.js & +pid_node="$!" + +while true +do + sleep 1 + +done diff --git a/ripped_music/.gitignore b/ripped_music/.gitignore new file mode 100644 index 0000000..c96a04f --- /dev/null +++ b/ripped_music/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/screenshots/screenshot_1.png b/screenshots/screenshot_1.png new file mode 100644 index 0000000..71515d5 Binary files /dev/null and b/screenshots/screenshot_1.png differ diff --git a/screenshots/screenshot_2.png b/screenshots/screenshot_2.png new file mode 100644 index 0000000..6831555 Binary files /dev/null and b/screenshots/screenshot_2.png differ diff --git a/screenshots/screenshot_4.png b/screenshots/screenshot_4.png new file mode 100644 index 0000000..37a370b Binary files /dev/null and b/screenshots/screenshot_4.png differ diff --git a/screenshots/screenshot_5.png b/screenshots/screenshot_5.png new file mode 100644 index 0000000..16612dd Binary files /dev/null and b/screenshots/screenshot_5.png differ diff --git a/spotify-ripper-docker/.dockerignore b/spotify-ripper-docker/.dockerignore new file mode 100644 index 0000000..42061c0 --- /dev/null +++ b/spotify-ripper-docker/.dockerignore @@ -0,0 +1 @@ +README.md \ No newline at end of file diff --git a/spotify-ripper-docker/.gitignore b/spotify-ripper-docker/.gitignore new file mode 100644 index 0000000..896c969 --- /dev/null +++ b/spotify-ripper-docker/.gitignore @@ -0,0 +1 @@ +spotify_appkey.key diff --git a/spotify-ripper-docker/Dockerfile b/spotify-ripper-docker/Dockerfile new file mode 100644 index 0000000..7d1ebfb --- /dev/null +++ b/spotify-ripper-docker/Dockerfile @@ -0,0 +1,43 @@ +FROM debian:stretch-slim + +ENV LANG C.UTF-8 + +ARG DEBIAN_FRONTEND=noninteractive + +# Install packages +RUN apt-get update -qy && apt-get upgrade -qy +RUN apt-get install nano wget lame build-essential libffi-dev python-pip python-dev python3-dev python3-pip libffi-dev -y + +WORKDIR /dependencies + +# Download libspotify & compile it +COPY ./dependencies/libspotify-12.1.51-Linux-x86_64-release.tar.gz ./libspotify-12.1.51-Linux-x86_64-release.tar.gz +RUN tar xvf libspotify-12.1.51-Linux-x86_64-release.tar.gz && \ + rm -f libspotify-12.1.51-Linux-x86_64-release.tar.gz && \ + cd libspotify-12.1.51-Linux-x86_64-release && \ + make install prefix=/usr/local + +# Install required tools for support for AAC (M4A container) +COPY ./dependencies/libfdk-aac-dev_0.1.4-2+b1_amd64.deb ./libfdk-aac-dev_0.1.4-2+b1_amd64.deb +COPY ./dependencies/libfdk-aac1_0.1.4-2+b1_amd64.deb ./libfdk-aac1_0.1.4-2+b1_amd64.deb +RUN apt-get install pkg-config automake autoconf -y && \ + dpkg -i libfdk-aac1_0.1.4-2+b1_amd64.deb && dpkg -i libfdk-aac-dev_0.1.4-2+b1_amd64.deb + +# Compile libfdk-aac encoder +COPY ./dependencies/v0.6.2.tar.gz ./v0.6.2.tar.gz +RUN tar xvf v0.6.2.tar.gz && \ + rm -f v0.6.2.tar.gz && cd fdkaac-0.6.2 && \ + autoreconf -i && ./configure && make install + +# Install a fork of spotify-ripper +COPY ./dependencies/spotify-ripper-morgaroth-2.9.6.tar.gz ./spotify-ripper-morgaroth-2.9.6.tar.gz +RUN pip3 install spotify-ripper-morgaroth-2.9.6.tar.gz + +WORKDIR / + +# Link our download location to /data in the container +VOLUME ["/ripped_music"] + +# Copy needed files for spotify-ripper +COPY ./spotify_appkey.key /root/.spotify-ripper/spotify_appkey.key +COPY ./config.ini /root/.spotify-ripper/config.ini diff --git a/spotify-ripper-docker/README.md b/spotify-ripper-docker/README.md new file mode 100644 index 0000000..78089b6 --- /dev/null +++ b/spotify-ripper-docker/README.md @@ -0,0 +1,30 @@ +> A Debian Stretch (slim) image with python, pip, libspotify and spotify-ripper + +## Prereqs +1. Install Docker and make sure the Docker daemon is running. +2. Place your libspotify appkey in the directory you're going to build from +3. Modify the config if needed + +## Installation +1. `git clone https://github.com/thibmaek/spotify-ripper-docker` +2. Add username & password in the `config.ini` file in this repo +3. `docker build -t spotify-ripper .` +4. `docker run -itd -v /home/user/download:/data --name spotify-ripper spotify-ripper` + +## Ripping +Default config will use the liblamemp3 encoder to rip to a MP3 container at 320kbps. +You can also rip to M4A (AAC) using the libfdk-aac encoder which is compiled into this image. +The spotify-ripper-morgaroth pip package embedded in this image will however 'monkey-patch' an M4A bug which makes it really incompatible with iTunes and causes iTunes to hang or crash when adding new files to the library. Use with caution! + +Once the container is running, use docker exec to start ripping: + +```bash +# You can add --remove-offline-cache to remove the libspotify offline cache and save disk space +# and avoid Docker storage conflicts. + +# Running this will run the output in the current terminal window +docker exec spotify-ripper spotify-ripper spotify:album:… --remove-offline-cache + +# Running this will run it detached in the background so you don't need to keep a terminal open +docker exec -d spotify-ripper spotify-ripper spotify:album:… +``` diff --git a/spotify-ripper-docker/config.ini b/spotify-ripper-docker/config.ini new file mode 100644 index 0000000..c0ba830 --- /dev/null +++ b/spotify-ripper-docker/config.ini @@ -0,0 +1,10 @@ +[main] +bitrate = 320 +cbr = True +cover_file_and_embed = album_cover.jpg +directory = /ripped_music +format = {album}/{disc_num}{track_num:2} - {artist} - {track_name}.{ext} +grouping = {label} +normalize = True +quality = 320 +partial_check = strict \ No newline at end of file diff --git a/spotify-ripper-docker/dependencies/libfdk-aac-dev_0.1.4-2+b1_amd64.deb b/spotify-ripper-docker/dependencies/libfdk-aac-dev_0.1.4-2+b1_amd64.deb new file mode 100644 index 0000000..c2051ec Binary files /dev/null and b/spotify-ripper-docker/dependencies/libfdk-aac-dev_0.1.4-2+b1_amd64.deb differ diff --git a/spotify-ripper-docker/dependencies/libfdk-aac1_0.1.4-2+b1_amd64.deb b/spotify-ripper-docker/dependencies/libfdk-aac1_0.1.4-2+b1_amd64.deb new file mode 100644 index 0000000..3c7e537 Binary files /dev/null and b/spotify-ripper-docker/dependencies/libfdk-aac1_0.1.4-2+b1_amd64.deb differ diff --git a/spotify-ripper-docker/dependencies/libspotify-12.1.51-Linux-x86_64-release.tar.gz b/spotify-ripper-docker/dependencies/libspotify-12.1.51-Linux-x86_64-release.tar.gz new file mode 100644 index 0000000..38079b9 Binary files /dev/null and b/spotify-ripper-docker/dependencies/libspotify-12.1.51-Linux-x86_64-release.tar.gz differ diff --git a/spotify-ripper-docker/dependencies/spotify-ripper-morgaroth-2.9.6.tar.gz b/spotify-ripper-docker/dependencies/spotify-ripper-morgaroth-2.9.6.tar.gz new file mode 100644 index 0000000..cdfda9c Binary files /dev/null and b/spotify-ripper-docker/dependencies/spotify-ripper-morgaroth-2.9.6.tar.gz differ diff --git a/spotify-ripper-docker/dependencies/v0.6.2.tar.gz b/spotify-ripper-docker/dependencies/v0.6.2.tar.gz new file mode 100644 index 0000000..0877993 Binary files /dev/null and b/spotify-ripper-docker/dependencies/v0.6.2.tar.gz differ diff --git a/spotify-ripper-web-backend/.dockerignore b/spotify-ripper-web-backend/.dockerignore new file mode 100644 index 0000000..b512c09 --- /dev/null +++ b/spotify-ripper-web-backend/.dockerignore @@ -0,0 +1 @@ +node_modules \ No newline at end of file diff --git a/spotify-ripper-web-backend/.gitignore b/spotify-ripper-web-backend/.gitignore new file mode 100644 index 0000000..b512c09 --- /dev/null +++ b/spotify-ripper-web-backend/.gitignore @@ -0,0 +1 @@ +node_modules \ No newline at end of file diff --git a/spotify-ripper-web-backend/app.js b/spotify-ripper-web-backend/app.js new file mode 100644 index 0000000..d9dd63d --- /dev/null +++ b/spotify-ripper-web-backend/app.js @@ -0,0 +1,139 @@ +const express = require("express"); +const app = express(); + +const WebSocket = require("ws"); + +const dirTree = require("directory-tree"); +const rimraf = require("rimraf"); + +const Writable = require("stream").Writable; + +const Docker = require("dockerode"); +const docker = new Docker({ socketPath: "/var/run/docker.sock" }); + +const clients = require("./socket/clients.js"); +const packetHandler = require("./socket/packet-handler.js"); + +const buildImage = new Promise((resolve, reject) => { + const logger = new Writable(); + + logger._write = function write(doc, encoding, next) { + const StringDecoder = require("string_decoder").StringDecoder; + const decoder = new StringDecoder("utf8"); + const result = decoder.write(doc); + const parsedResult = JSON.parse(result); + const streamData = parsedResult.stream || ""; + + const lines = streamData.split("\n").filter(line => { + return line ? true : false; + }); + + for (const line of lines) { + console.log(line); + } + + next(); + }; + + docker.buildImage( + { + context: process.cwd() + "/../spotify-ripper-docker", + src: ["Dockerfile", "config.ini", "spotify_appkey.key", "dependencies"] + }, + { t: "spotify-ripper" }, + function(err, stream) { + if (err) reject(err); + + stream.pipe(logger); + stream.on("end", function() { + logger.end(); + resolve(); + }); + } + ); +}); + +const startWebServer = new Promise((resolve, reject) => { + try { + app.use(function(req, res, next) { + res.header("Access-Control-Allow-Origin", "*"); + res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept"); + next(); + }); + + app.use("/web", express.static(process.cwd() + "/../spotify-ripper-web-frontend/dist")); + app.use("/music", express.static(process.cwd() + "/../ripped_music")); + + app.listen(3000, "0.0.0.0", function () { + resolve(); + }); + } catch (error) { + reject(error); + } +}); + +const startWebSocketServer = new Promise((resolve, reject) => { + try { + const wss = new WebSocket.Server({ host: "0.0.0.0", port: 3300 }); + + wss.on("connection", (ws) => { + clients.add(ws, { container: null }); + console.log("Client connected to the Server."); + + const rippedMusic = dirTree(`/ripped_music`, { + extensions: /\.(mp3|jpg)$/, + attributes: ["birthtimeMs"] + }); + + const sessionFolders = rippedMusic.children; + const TWENTY_FOUR_HOURS_IN_MS = 24 * 60 * 60 * 1000; + const NOW_IN_MS = new Date().getTime(); + + for (const folder of sessionFolders) { + const deltaCreation = NOW_IN_MS - folder.birthtimeMs; + + if (deltaCreation >= TWENTY_FOUR_HOURS_IN_MS) { + rimraf(folder.path, () => { console.log(`Successfully deleted ${ folder.path }`); }); + } + } + + ws.on("message", (packet) => { + packetHandler.handle(ws, packet); + }); + + ws.on("close", () => { + console.log("Client disconnected from the Server."); + + if (clients.get(ws) && clients.get(ws).container !== null) { + clients.get(ws).container.stop(); + } + }) + }); + + resolve(); + } catch (error) { + reject(error); + } +}); + +buildImage +.then(startWebServer) +.then(startWebSocketServer) +.then(() => { + console.log(); + console.log(); + console.log("==========================================================="); + console.log("Spotify Ripper Webinterface started; Listening on port 3000"); + console.log("==========================================================="); + console.log(); +}) +.catch((error) => { + console.log(); + console.log(); + console.log("============================================"); + console.log("Failed to start Spotify Ripper Webinterface!"); + console.log("============================================"); + console.log(); + console.log("ERROR", error); + console.log(); +}) \ No newline at end of file diff --git a/spotify-ripper-web-backend/package-lock.json b/spotify-ripper-web-backend/package-lock.json new file mode 100644 index 0000000..fa81152 --- /dev/null +++ b/spotify-ripper-web-backend/package-lock.json @@ -0,0 +1,716 @@ +{ + "name": "spotify-ripper-web", + "version": "1.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "JSONStream": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.2.tgz", + "integrity": "sha1-wQI3G27Dp887hHygDCC7D85Mbeo=", + "requires": { + "jsonparse": "^1.2.0", + "through": ">=2.2.7 <3" + } + }, + "accepts": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.5.tgz", + "integrity": "sha1-63d99gEXI6OxTopywIBcjoZ0a9I=", + "requires": { + "mime-types": "~2.1.18", + "negotiator": "0.6.1" + } + }, + "array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" + }, + "async-limiter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.0.tgz", + "integrity": "sha512-jp/uFnooOiO+L211eZOoSyzpOITMXx1rBITauYykG3BRYPu8h0UcxsPNB04RR5vo4Tyz3+ay17tR6JVf9qzYWg==" + }, + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" + }, + "bl": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.2.tgz", + "integrity": "sha512-e8tQYnZodmebYDWGH7KMRvtzKXaJHx3BbilrgZCfvyLUYdKpK1t5PSPmpkny/SgiTSCnjfLW7v5rlONXVFkQEA==", + "requires": { + "readable-stream": "^2.3.5", + "safe-buffer": "^5.1.1" + } + }, + "body-parser": { + "version": "1.18.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.18.3.tgz", + "integrity": "sha1-WykhmP/dVTs6DyDe0FkrlWlVyLQ=", + "requires": { + "bytes": "3.0.0", + "content-type": "~1.0.4", + "debug": "2.6.9", + "depd": "~1.1.2", + "http-errors": "~1.6.3", + "iconv-lite": "0.4.23", + "on-finished": "~2.3.0", + "qs": "6.5.2", + "raw-body": "2.3.3", + "type-is": "~1.6.16" + } + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "buffer-alloc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz", + "integrity": "sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==", + "requires": { + "buffer-alloc-unsafe": "^1.1.0", + "buffer-fill": "^1.0.0" + } + }, + "buffer-alloc-unsafe": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz", + "integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==" + }, + "buffer-fill": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz", + "integrity": "sha1-+PeLdniYiO858gXNY39o5wISKyw=" + }, + "buffer-from": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", + "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==" + }, + "bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=" + }, + "chownr": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.1.tgz", + "integrity": "sha512-j38EvO5+LHX84jlo6h4UzmOwi0UgW61WRyPtJz4qaadK5eY3BTS5TY/S1Stc3Uk2lIM6TPevAlULiEJwie860g==" + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" + }, + "concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "requires": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "content-disposition": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz", + "integrity": "sha1-DPaLud318r55YcOoUXjLhdunjLQ=" + }, + "content-type": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", + "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==" + }, + "cookie": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz", + "integrity": "sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s=" + }, + "cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" + }, + "core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=" + }, + "destroy": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", + "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" + }, + "directory-tree": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/directory-tree/-/directory-tree-2.2.1.tgz", + "integrity": "sha512-AWgnCDEKC2/oSAA/0Ae3RhXnMkvOZtNAVQu7wF2/qXF5Xm8LhjWnEXLV4Yza45SmWkNPvNlo4zWPhZjcVd4T5w==" + }, + "docker-modem": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-1.0.8.tgz", + "integrity": "sha512-YQ2x9HUkJBxjPpppcLe34ucS9dRKkXq89dl1EZJU4DWJXkZHfjKVbOtfbi04RLC6Rgs7sfJGqS+s/ACKsOHKEw==", + "requires": { + "JSONStream": "1.3.2", + "debug": "^3.2.6", + "readable-stream": "~1.0.26-4", + "split-ca": "^1.0.0" + }, + "dependencies": { + "debug": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", + "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", + "requires": { + "ms": "^2.1.1" + } + }, + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=" + }, + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==" + }, + "readable-stream": { + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", + "integrity": "sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=", + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=" + } + } + }, + "dockerode": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-2.5.8.tgz", + "integrity": "sha512-+7iOUYBeDTScmOmQqpUYQaE7F4vvIt6+gIZNHWhqAQEI887tiPFB9OvXI/HzQYqfUNvukMK+9myLW63oTJPZpw==", + "requires": { + "concat-stream": "~1.6.2", + "docker-modem": "^1.0.8", + "tar-fs": "~1.16.3" + } + }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" + }, + "emitter-component": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/emitter-component/-/emitter-component-1.1.1.tgz", + "integrity": "sha1-Bl4tvtaVm/RwZ57avq95gdEAOrY=" + }, + "encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=" + }, + "end-of-stream": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.1.tgz", + "integrity": "sha512-1MkrZNvWTKCaigbn+W15elq2BB/L22nqrSY5DKlo3X6+vclJm8Bb5djXJBmEX6fS3+zCh/F4VBK5Z2KxJt4s2Q==", + "requires": { + "once": "^1.4.0" + } + }, + "escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" + }, + "etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=" + }, + "express": { + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/express/-/express-4.16.4.tgz", + "integrity": "sha512-j12Uuyb4FMrd/qQAm6uCHAkPtO8FDTRJZBDd5D2KOL2eLaz1yUNdUB/NOIyq0iU4q4cFarsUCrnFDPBcnksuOg==", + "requires": { + "accepts": "~1.3.5", + "array-flatten": "1.1.1", + "body-parser": "1.18.3", + "content-disposition": "0.5.2", + "content-type": "~1.0.4", + "cookie": "0.3.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "~1.1.2", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.1.1", + "fresh": "0.5.2", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "~2.3.0", + "parseurl": "~1.3.2", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.4", + "qs": "6.5.2", + "range-parser": "~1.2.0", + "safe-buffer": "5.1.2", + "send": "0.16.2", + "serve-static": "1.13.2", + "setprototypeof": "1.1.0", + "statuses": "~1.4.0", + "type-is": "~1.6.16", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + } + }, + "finalhandler": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.1.tgz", + "integrity": "sha512-Y1GUDo39ez4aHAw7MysnUD5JzYX+WaIj8I57kO3aEPT1fFRL4sr7mjei97FgnwhAyyzRYmQZaTHb2+9uZ1dPtg==", + "requires": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.2", + "statuses": "~1.4.0", + "unpipe": "~1.0.0" + } + }, + "forwarded": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz", + "integrity": "sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ=" + }, + "fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=" + }, + "fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" + }, + "glob": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz", + "integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "http-errors": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", + "integrity": "sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0=", + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.0", + "statuses": ">= 1.4.0 < 2" + } + }, + "iconv-lite": { + "version": "0.4.23", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.23.tgz", + "integrity": "sha512-neyTUVFtahjf0mB3dZT77u+8O0QB89jFdnBkd5P1JgYPbPaia3gXXOVL2fq8VyU2gMMD7SaN7QukTB/pmXYvDA==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" + }, + "ipaddr.js": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.8.0.tgz", + "integrity": "sha1-6qM9bd16zo9/b+DJygRA5wZzix4=" + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + }, + "jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=" + }, + "media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" + }, + "merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=" + }, + "methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=" + }, + "mime": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.4.1.tgz", + "integrity": "sha512-KI1+qOZu5DcW6wayYHSzR/tXKCDC5Om4s1z2QJjDULzLcmf3DvzS7oluY4HCTrc+9FiKmWUgeNLg7W3uIQvxtQ==" + }, + "mime-db": { + "version": "1.37.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.37.0.tgz", + "integrity": "sha512-R3C4db6bgQhlIhPU48fUtdVmKnflq+hRdad7IyKhtFj06VPNVdk2RhiYL3UjQIlso8L+YxAtFkobT0VK+S/ybg==" + }, + "mime-types": { + "version": "2.1.21", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.21.tgz", + "integrity": "sha512-3iL6DbwpyLzjR3xHSFNFeb9Nz/M8WDkX33t1GFQnFOllWk8pOrh/LSrB5OXlnlW5P9LH73X6loW/eogc+F5lJg==", + "requires": { + "mime-db": "~1.37.0" + } + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=" + }, + "mkdirp": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "requires": { + "minimist": "0.0.8" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + }, + "negotiator": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.1.tgz", + "integrity": "sha1-KzJxhOiZIQEXeyhWP7XnECrNDKk=" + }, + "on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", + "requires": { + "ee-first": "1.1.1" + } + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "requires": { + "wrappy": "1" + } + }, + "parseurl": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.2.tgz", + "integrity": "sha1-/CidTtiZMRlGDBViUyYs3I3mW/M=" + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" + }, + "path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=" + }, + "process-nextick-args": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz", + "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==" + }, + "proxy-addr": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.4.tgz", + "integrity": "sha512-5erio2h9jp5CHGwcybmxmVqHmnCBZeewlfJ0pex+UW7Qny7OOZXTtH56TGNyBizkgiOwhJtMKrVzDTeKcySZwA==", + "requires": { + "forwarded": "~0.1.2", + "ipaddr.js": "1.8.0" + } + }, + "pump": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-1.0.3.tgz", + "integrity": "sha512-8k0JupWme55+9tCVE+FS5ULT3K6AbgqrGa58lTT49RpyfwwcGedHqaC5LlQNdEAumn/wFsu6aPwkuPMioy8kqw==", + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "qs": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", + "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==" + }, + "range-parser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz", + "integrity": "sha1-9JvmtIeJTdxA3MlKMi9hEJLgDV4=" + }, + "raw-body": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.3.3.tgz", + "integrity": "sha512-9esiElv1BrZoI3rCDuOuKCBRbuApGGaDPQfjSflGxdy4oyzqghxu6klEkkVIvBje+FF0BX9coEv8KqW6X/7njw==", + "requires": { + "bytes": "3.0.0", + "http-errors": "1.6.3", + "iconv-lite": "0.4.23", + "unpipe": "1.0.0" + } + }, + "readable-stream": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "requires": { + "glob": "^7.1.3" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "send": { + "version": "0.16.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.16.2.tgz", + "integrity": "sha512-E64YFPUssFHEFBvpbbjr44NCLtI1AohxQ8ZSiJjQLskAdKuriYEP6VyGEsRDH8ScozGpkaX1BGvhanqCwkcEZw==", + "requires": { + "debug": "2.6.9", + "depd": "~1.1.2", + "destroy": "~1.0.4", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "~1.6.2", + "mime": "1.4.1", + "ms": "2.0.0", + "on-finished": "~2.3.0", + "range-parser": "~1.2.0", + "statuses": "~1.4.0" + } + }, + "serve-static": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.13.2.tgz", + "integrity": "sha512-p/tdJrO4U387R9oMjb1oj7qSMaMfmOyd4j9hOFoxZe2baQszgHcSWjuya/CiT5kgZZKRudHNOA0pYXOl8rQ5nw==", + "requires": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.2", + "send": "0.16.2" + } + }, + "setprototypeof": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", + "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==" + }, + "split-ca": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/split-ca/-/split-ca-1.0.1.tgz", + "integrity": "sha1-bIOv82kvphJW4M0ZfgXp3hV2kaY=" + }, + "statuses": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.4.0.tgz", + "integrity": "sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew==" + }, + "stream": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stream/-/stream-0.0.2.tgz", + "integrity": "sha1-f1Nj8Ff2WSxVlfALyAon9c7B8O8=", + "requires": { + "emitter-component": "^1.1.1" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "requires": { + "safe-buffer": "~5.1.0" + } + }, + "tar-fs": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-1.16.3.tgz", + "integrity": "sha512-NvCeXpYx7OsmOh8zIOP/ebG55zZmxLE0etfWRbWok+q2Qo8x/vOR/IJT1taADXPe+jsiu9axDb3X4B+iIgNlKw==", + "requires": { + "chownr": "^1.0.1", + "mkdirp": "^0.5.1", + "pump": "^1.0.0", + "tar-stream": "^1.1.2" + } + }, + "tar-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-1.6.2.tgz", + "integrity": "sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A==", + "requires": { + "bl": "^1.0.0", + "buffer-alloc": "^1.2.0", + "end-of-stream": "^1.0.0", + "fs-constants": "^1.0.0", + "readable-stream": "^2.3.0", + "to-buffer": "^1.1.1", + "xtend": "^4.0.0" + } + }, + "through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=" + }, + "to-buffer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.1.1.tgz", + "integrity": "sha512-lx9B5iv7msuFYE3dytT+KE5tap+rNYw+K4jVkb9R/asAb+pbBSM17jtunHplhBe6RRJdZx3Pn2Jph24O32mOVg==" + }, + "type-is": { + "version": "1.6.16", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.16.tgz", + "integrity": "sha512-HRkVv/5qY2G6I8iab9cI7v1bOIdhm94dVjQCPFElW9W+3GeDOSHmy2EBYe4VTApuzolPcmgFTN3ftVJRKR2J9Q==", + "requires": { + "media-typer": "0.3.0", + "mime-types": "~2.1.18" + } + }, + "typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=" + }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=" + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" + }, + "utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" + }, + "vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=" + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + }, + "ws": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-6.1.3.tgz", + "integrity": "sha512-tbSxiT+qJI223AP4iLfQbkbxkwdFcneYinM2+x46Gx2wgvbaOMO36czfdfVUBRTHvzAMRhDd98sA5d/BuWbQdg==", + "requires": { + "async-limiter": "~1.0.0" + } + }, + "xtend": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz", + "integrity": "sha1-pcbVMr5lbiPbgg77lDofBJmNY68=" + } + } +} diff --git a/spotify-ripper-web-backend/package.json b/spotify-ripper-web-backend/package.json new file mode 100644 index 0000000..bab83b9 --- /dev/null +++ b/spotify-ripper-web-backend/package.json @@ -0,0 +1,19 @@ +{ + "name": "spotify-ripper-web", + "version": "1.0.0", + "description": "", + "main": "app.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "dependencies": { + "directory-tree": "^2.2.1", + "dockerode": "^2.5.8", + "express": "^4.16.4", + "rimraf": "^2.6.3", + "stream": "0.0.2", + "ws": "^6.1.3" + } +} diff --git a/spotify-ripper-web-backend/socket/clients.js b/spotify-ripper-web-backend/socket/clients.js new file mode 100644 index 0000000..6a4d4ff --- /dev/null +++ b/spotify-ripper-web-backend/socket/clients.js @@ -0,0 +1,27 @@ +class Clients { + constructor() { + this.clients = new Map(); + } + + add(ws, data) { + const client = this.clients.get(ws); + if (client) { + this.clients.set(ws, { + ...client, + ...data + }); + } else { + this.clients.set(ws, data); + } + } + + get(ws) { + return this.clients.get(ws); + } + + delete(ws) { + this.clients.delete(ws); + } +} + +module.exports = new Clients() \ No newline at end of file diff --git a/spotify-ripper-web-backend/socket/packet-handler.js b/spotify-ripper-web-backend/socket/packet-handler.js new file mode 100644 index 0000000..8db6a1e --- /dev/null +++ b/spotify-ripper-web-backend/socket/packet-handler.js @@ -0,0 +1,29 @@ +const startRipperPacket = require("./packets/incoming/start-ripper.js"); +const stopRipperPacket = require("./packets/incoming/stop-ripper.js"); +const getMusicDirectory = require("./packets/incoming/get-music-directory.js"); + +module.exports = { + handle: function(ws, jsonPacket) { + const parsedPacket = JSON.parse(jsonPacket); + + console.log("Incoming packet ## " + parsedPacket.id); + + switch (parsedPacket.id) { + case "start-ripper": + startRipperPacket.handle(ws, parsedPacket); + break; + + case "stop-ripper": + stopRipperPacket.handle(ws, parsedPacket); + break; + + case "get-music-directory": + getMusicDirectory.handle(ws, parsedPacket); + break; + + default: + console.log("Packet not found! ## ", parsedPacket.id); + break; + } + } +} \ No newline at end of file diff --git a/spotify-ripper-web-backend/socket/packets/incoming/get-music-directory.js b/spotify-ripper-web-backend/socket/packets/incoming/get-music-directory.js new file mode 100644 index 0000000..c56ee37 --- /dev/null +++ b/spotify-ripper-web-backend/socket/packets/incoming/get-music-directory.js @@ -0,0 +1,18 @@ +const dirTree = require("directory-tree"); +const WebSocket = require("ws"); + +const Packet = require.main.require("./socket/packets/outgoing.js"); + +module.exports = { + handle: (ws) => { + const rippedMusic = dirTree(`/ripped_music`, { + extensions: /\.(mp3|jpg)$/, + attributes: ["birthtimeMs"] + }); + + if (ws.readyState === WebSocket.OPEN) { + const responsePacket = Packet("music-directory", rippedMusic); + ws.send(responsePacket); + } + } +} \ No newline at end of file diff --git a/spotify-ripper-web-backend/socket/packets/incoming/start-ripper.js b/spotify-ripper-web-backend/socket/packets/incoming/start-ripper.js new file mode 100644 index 0000000..94fedcf --- /dev/null +++ b/spotify-ripper-web-backend/socket/packets/incoming/start-ripper.js @@ -0,0 +1,135 @@ +const Docker = require("dockerode"); +const docker = new Docker({ socketPath: "/var/run/docker.sock" }); + +const Writable = require("stream").Writable; +const WebSocket = require("ws"); + +const clients = require.main.require("./socket/clients.js"); +const Packet = require.main.require("./socket/packets/outgoing.js"); + +const startContainer = (ws, container) => { + container.start({}, (err) => { + if (err) throw err; + + startLoggerPipe(ws, container); + console.log("spotify-ripper container started.", "Container", container.id); + + if (ws.readyState === WebSocket.OPEN) { + const responsePacket = Packet("container-state", "STARTED"); + ws.send(responsePacket); + } + }); + + return container; +} + +const startLoggerPipe = (ws, container) => { + container.logs({ + follow: true, + stdout: true, + stderr: true, + stdin: true, + steam: true, + tty: true + }, (err, stream) => { + if(err) throw err; + + container.logger = new Writable(); + container.logger._write = (doc, encoding, next) => sendLogToWsClient(ws, doc, next); + + stream.pipe(container.logger); + stream.on("end", () => { + console.log("spotify-ripper container stopped.", "Container", container.id); + + if (ws.readyState === WebSocket.OPEN) { + const responsePacket = Packet("container-state", "STOPPED"); + ws.send(responsePacket); + } + + clients.add(ws, { container: null }); + clients.delete(ws); + + container.logger.end(); + container.remove(); + }); + }); +} + +const sendLogToWsClient = (ws, doc, next) => { + if (ws.readyState === WebSocket.OPEN) { + const StringDecoder = require("string_decoder").StringDecoder; + const decoder = new StringDecoder("utf8"); + const result = decoder.write(doc); + + const lines = result.split("\r\n").filter(line => { + return line ? true : false; + }); + + for (const line of lines) { + if ((/progress_(total|track)(_remaining)*\((.+?)\)/gm).test(line)) { + const responsePacket = Packet("progress", line); + ws.send(responsePacket); + } else { + const responsePacket = Packet("terminal", line); + ws.send(responsePacket); + } + } + } + + next(); +} + +const baseContainerOptions = { + Image: "spotify-ripper", + AttachStdin: true, + AttachStdout: true, + AttachStderr: true, + Tty: true +} + +module.exports = { + handle: (ws, packet) => { + const commands = [ + "spotify-ripper", + "--user", + packet.data.user, + "--password", + packet.data.pass, + ...packet.data.uris, + "--remove-offline-cache" + ]; + + console.log("Trying to download..", "User", packet.data.user); + + const folderName = (new Date().getTime()).toString(36); + const folderPath = `${process.env.APP_PATH}/ripped_music/${ folderName }`; + + const containerOptions = { + ...baseContainerOptions, + + Cmd: commands, + + Volumes: { + "/ripped_music/": {} + }, + + HostConfig: { + "Binds": [ `${folderPath}:/ripped_music` ], + } + }; + + docker.createContainer(containerOptions).then((container) => { + console.log("spotify-ripper container created.", "Container", container.id); + clients.add(ws, { container: container }); + + if (ws.readyState === WebSocket.OPEN) { + const responsePacket = Packet("download-folder", folderName); + ws.send(responsePacket); + } + + return startContainer(ws, container); + }).catch(function(err) { + if (err) throw err; + }); + } +} \ No newline at end of file diff --git a/spotify-ripper-web-backend/socket/packets/incoming/stop-ripper.js b/spotify-ripper-web-backend/socket/packets/incoming/stop-ripper.js new file mode 100644 index 0000000..c6208a9 --- /dev/null +++ b/spotify-ripper-web-backend/socket/packets/incoming/stop-ripper.js @@ -0,0 +1,9 @@ +const clients = require.main.require("./socket/clients.js"); + +module.exports = { + handle: (ws) => { + if (clients.get(ws) && clients.get(ws).container !== null) { + clients.get(ws).container.stop(); + } + } +} \ No newline at end of file diff --git a/spotify-ripper-web-backend/socket/packets/outgoing.js b/spotify-ripper-web-backend/socket/packets/outgoing.js new file mode 100644 index 0000000..cbd9dd3 --- /dev/null +++ b/spotify-ripper-web-backend/socket/packets/outgoing.js @@ -0,0 +1,8 @@ +module.exports = (packetId, packetData) => { + var packet = { + id: packetId, + data: packetData + }; + + return JSON.stringify(packet); +} \ No newline at end of file diff --git a/spotify-ripper-web-frontend/.dockerignore b/spotify-ripper-web-frontend/.dockerignore new file mode 100644 index 0000000..0771922 --- /dev/null +++ b/spotify-ripper-web-frontend/.dockerignore @@ -0,0 +1,2 @@ +node_modules +/dist \ No newline at end of file diff --git a/spotify-ripper-web-frontend/.gitignore b/spotify-ripper-web-frontend/.gitignore new file mode 100644 index 0000000..185e663 --- /dev/null +++ b/spotify-ripper-web-frontend/.gitignore @@ -0,0 +1,21 @@ +.DS_Store +node_modules +/dist + +# local env files +.env.local +.env.*.local + +# Log files +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Editor directories and files +.idea +.vscode +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw* diff --git a/spotify-ripper-web-frontend/README.md b/spotify-ripper-web-frontend/README.md new file mode 100644 index 0000000..2c598df --- /dev/null +++ b/spotify-ripper-web-frontend/README.md @@ -0,0 +1,26 @@ +# spotify-ripper-web-frontend + +## Project setup +``` +yarn install +``` + +### Compiles and hot-reloads for development +``` +yarn run serve +``` + +### Compiles and minifies for production +``` +yarn run build +``` + +### Run your tests +``` +yarn run test +``` + +### Lints and fixes files +``` +yarn run lint +``` diff --git a/spotify-ripper-web-frontend/babel.config.js b/spotify-ripper-web-frontend/babel.config.js new file mode 100644 index 0000000..3ecebf1 --- /dev/null +++ b/spotify-ripper-web-frontend/babel.config.js @@ -0,0 +1,3 @@ +module.exports = { + presets: ["@vue/app"] +}; diff --git a/spotify-ripper-web-frontend/package.json b/spotify-ripper-web-frontend/package.json new file mode 100644 index 0000000..bf9cefa --- /dev/null +++ b/spotify-ripper-web-frontend/package.json @@ -0,0 +1,58 @@ +{ + "name": "spotify-ripper-web-frontend", + "version": "0.1.0", + "private": true, + "scripts": { + "serve": "vue-cli-service serve", + "build": "vue-cli-service build", + "lint": "vue-cli-service lint" + }, + "dependencies": { + "anser": "^1.4.8", + "axios": "^0.18.0", + "file-saver": "^2.0.0", + "jszip": "^3.1.5", + "string_decoder": "^1.2.0", + "vue": "^2.5.22", + "vue-axios": "^2.1.4", + "vue-native-websocket": "^2.0.12", + "vue-router": "^3.0.1", + "vuex": "^3.1.0" + }, + "devDependencies": { + "@vue/cli-plugin-babel": "^3.0.4", + "@vue/cli-plugin-eslint": "^3.0.4", + "@vue/cli-service": "^3.0.4", + "@vue/eslint-config-prettier": "^4.0.1", + "babel-eslint": "^10.0.1", + "eslint": "^5.13.0", + "eslint-plugin-vue": "^5.0.0", + "node-sass": "^4.9.0", + "sass-loader": "^7.1.0", + "vue-template-compiler": "^2.5.21" + }, + "eslintConfig": { + "root": true, + "env": { + "node": true + }, + "extends": [ + "plugin:vue/essential", + "@vue/prettier" + ], + "rules": {}, + "parserOptions": { + "parser": "babel-eslint" + } + }, + "postcss": { + "plugins": { + "autoprefixer": {} + } + }, + "browserslist": [ + "> 1%", + "last 2 versions", + "not ie <= 8" + ] +} diff --git a/spotify-ripper-web-frontend/public/favicon.ico b/spotify-ripper-web-frontend/public/favicon.ico new file mode 100644 index 0000000..c7b9a43 Binary files /dev/null and b/spotify-ripper-web-frontend/public/favicon.ico differ diff --git a/spotify-ripper-web-frontend/public/index.html b/spotify-ripper-web-frontend/public/index.html new file mode 100644 index 0000000..2de4f2c --- /dev/null +++ b/spotify-ripper-web-frontend/public/index.html @@ -0,0 +1,19 @@ + + + + + + + + spotify-ripper-web-frontend + + + + + +
+ + + diff --git a/spotify-ripper-web-frontend/src/App.vue b/spotify-ripper-web-frontend/src/App.vue new file mode 100644 index 0000000..01fb91d --- /dev/null +++ b/spotify-ripper-web-frontend/src/App.vue @@ -0,0 +1,153 @@ + + + + + diff --git a/spotify-ripper-web-frontend/src/assets/css/bootstrap/_alert.scss b/spotify-ripper-web-frontend/src/assets/css/bootstrap/_alert.scss new file mode 100644 index 0000000..dd43e23 --- /dev/null +++ b/spotify-ripper-web-frontend/src/assets/css/bootstrap/_alert.scss @@ -0,0 +1,51 @@ +// +// Base styles +// + +.alert { + position: relative; + padding: $alert-padding-y $alert-padding-x; + margin-bottom: $alert-margin-bottom; + border: $alert-border-width solid transparent; + @include border-radius($alert-border-radius); +} + +// Headings for larger alerts +.alert-heading { + // Specified to prevent conflicts of changing $headings-color + color: inherit; +} + +// Provide class for links that match alerts +.alert-link { + font-weight: $alert-link-font-weight; +} + + +// Dismissible alerts +// +// Expand the right padding and account for the close button's positioning. + +.alert-dismissible { + padding-right: ($close-font-size + $alert-padding-x * 2); + + // Adjust close link position + .close { + position: absolute; + top: 0; + right: 0; + padding: $alert-padding-y $alert-padding-x; + color: inherit; + } +} + + +// Alternate styles +// +// Generate contextual modifier classes for colorizing the alert. + +@each $color, $value in $theme-colors { + .alert-#{$color} { + @include alert-variant(theme-color-level($color, $alert-bg-level), theme-color-level($color, $alert-border-level), theme-color-level($color, $alert-color-level)); + } +} diff --git a/spotify-ripper-web-frontend/src/assets/css/bootstrap/_badge.scss b/spotify-ripper-web-frontend/src/assets/css/bootstrap/_badge.scss new file mode 100644 index 0000000..b87a1b0 --- /dev/null +++ b/spotify-ripper-web-frontend/src/assets/css/bootstrap/_badge.scss @@ -0,0 +1,47 @@ +// Base class +// +// Requires one of the contextual, color modifier classes for `color` and +// `background-color`. + +.badge { + display: inline-block; + padding: $badge-padding-y $badge-padding-x; + font-size: $badge-font-size; + font-weight: $badge-font-weight; + line-height: 1; + text-align: center; + white-space: nowrap; + vertical-align: baseline; + @include border-radius($badge-border-radius); + + // Empty badges collapse automatically + &:empty { + display: none; + } +} + +// Quick fix for badges in buttons +.btn .badge { + position: relative; + top: -1px; +} + +// Pill badges +// +// Make them extra rounded with a modifier to replace v3's badges. + +.badge-pill { + padding-right: $badge-pill-padding-x; + padding-left: $badge-pill-padding-x; + @include border-radius($badge-pill-border-radius); +} + +// Colors +// +// Contextual variations (linked badges get darker on :hover). + +@each $color, $value in $theme-colors { + .badge-#{$color} { + @include badge-variant($value); + } +} diff --git a/spotify-ripper-web-frontend/src/assets/css/bootstrap/_breadcrumb.scss b/spotify-ripper-web-frontend/src/assets/css/bootstrap/_breadcrumb.scss new file mode 100644 index 0000000..25b9d85 --- /dev/null +++ b/spotify-ripper-web-frontend/src/assets/css/bootstrap/_breadcrumb.scss @@ -0,0 +1,38 @@ +.breadcrumb { + display: flex; + flex-wrap: wrap; + padding: $breadcrumb-padding-y $breadcrumb-padding-x; + margin-bottom: $breadcrumb-margin-bottom; + list-style: none; + background-color: $breadcrumb-bg; + @include border-radius($border-radius); +} + +.breadcrumb-item { + // The separator between breadcrumbs (by default, a forward-slash: "/") + + .breadcrumb-item::before { + display: inline-block; // Suppress underlining of the separator in modern browsers + padding-right: $breadcrumb-item-padding; + padding-left: $breadcrumb-item-padding; + color: $breadcrumb-divider-color; + content: "#{$breadcrumb-divider}"; + } + + // IE9-11 hack to properly handle hyperlink underlines for breadcrumbs built + // without `